lib/akin/algorithms/substring_set.ex

defmodule Akin.SubstringSet do
  @moduledoc """
  Functions for string comparsion where common words between the strings are compared in sets.

  Two sets

  #MapSet<["alice", "in", "wonderland"]>
  #MapSet<["adventures", "alice", "glass", "looking", "s", "the", "through"]>

  Commonality

  * `common_words = "alice"`
  * `common_words_plus_remaining_words_left = "aliceinwonderland"`
  * `common_words_plus_remaining_words_right = "aliceadventuresglasslookingsthethrough"`

  Ratio is based on difference in string length

  * if words are of similar in length according to Akin.Strategy.determine/2
    * ratio is String.jaro_distance
  * if words are of dissimilar in length according to Akin.Strategy.determine/2
    * ratio is Akin.SubstringComparison.similarity/2 * @ratio * scale (determined by Akin.Strategy)

  Match level is based on `opt` :level

  * "normal" returns average ratio
  * "weak" returns maximum ratio
  """
  @behaviour Akin.Task
  import Akin.Util, only: [opts: 2]
  alias Akin.{Corpus, Strategy, Helper.SubstringComparison}

  @bias 0.95

  @spec compare(%Corpus{}, %Corpus{}, Keyword.t()) :: float()
  def compare(
        %Corpus{string: l_string, set: l_set},
        %Corpus{string: r_string, set: r_set},
        opts \\ []
      ) do
    case Strategy.determine(l_string, r_string) do
      :standard ->
        similarity(l_set, r_set) |> score(opts(opts, :level))

      {:substring, scale} ->
        score =
          substring_similarity(l_set, r_set)
          |> score(opts(opts, :level))

        score * @bias * scale

      {:error, _} ->
        0.0
    end
  end

  defp score(scores, "weak"), do: Enum.max(scores)

  defp score(scores, _) do
    Enum.sum(scores) / (Enum.count(scores) - 1)
  end

  defp similarity(left, right) do
    {common_words, common_words_plus_remaining_words_left,
     common_words_plus_remaining_words_right} = set_operations(left, right)

    [
      0.0,
      String.jaro_distance(common_words, common_words_plus_remaining_words_left),
      String.jaro_distance(common_words, common_words_plus_remaining_words_right),
      String.jaro_distance(
        common_words_plus_remaining_words_left,
        common_words_plus_remaining_words_right
      )
    ]
  end

  defp substring_similarity(left, right) do
    similarity(left, right, SubstringComparison)
  end

  defp similarity(left, right, ratio_mod) do
    {common_words, common_words_plus_remaining_words_left,
     common_words_plus_remaining_words_right} = set_operations(left, right)

    [
      0.0,
      ratio_mod.similarity(common_words, common_words_plus_remaining_words_left),
      ratio_mod.similarity(common_words, common_words_plus_remaining_words_right),
      ratio_mod.similarity(
        common_words_plus_remaining_words_left,
        common_words_plus_remaining_words_right
      )
    ]
  end

  defp set_operations(left, right) do
    common_words = MapSet.intersection(left, right)

    common_words_string =
      common_words
      |> Enum.sort()
      |> Enum.join()

    [
      common_words_plus_remaining_words_l_string,
      common_words_plus_remaining_words_r_string
    ] =
      [left, right]
      |> Enum.map(fn x ->
        common_words_string <>
          (x
           |> MapSet.difference(common_words)
           |> Enum.sort()
           |> Enum.join())
      end)

    {
      common_words_string,
      common_words_plus_remaining_words_l_string,
      common_words_plus_remaining_words_r_string
    }
  end
end