Current section
Files
Jump to
Current section
Files
lib/matcher.ex
defmodule Pair2.Matcher do
@moduledoc """
Rules-based matcher for finding optimal 1:1 matches between two lists of maps.
"""
alias Pair2.{
Comparer,
Index
}
@doc """
Performs 1:1 match of two lists of maps, list_l and list_r, by applying
rules from a list of rule structs. For two maps to match, their match score
must be >= min_score.
"""
def match(list_l, list_r, rules, min_score) do
with {:ok, indexed_attrs} <- get_indexed_rule_attrs(rules),
:ok <- Index.load_indexes(list_r, indexed_attrs),
{:ok, all_matches} <- get_all_matches(list_l, indexed_attrs, rules, min_score),
:ok <- Index.close_indexes(indexed_attrs)
do
{:ok, resolve(all_matches)}
else
{:error, reason} -> raise reason
end
end
def get_indexed_rule_attrs(rules) do
indexed_attrs = rules
|> Enum.filter(&(&1.indexed))
|> Enum.map(&(&1.right_attr))
case Enum.count(indexed_attrs) do
0 -> {:error, "At least one attribute must be indexed"}
_ -> {:ok, indexed_attrs}
end
end
def get_all_matches(list_l, indexed_attrs, rules, min_score) do
matches = list_l
|> Flow.from_enumerable()
|> Flow.partition()
|> Flow.map(fn(left_map) ->
right_matches = left_map
|> Index.get_potential_matches(indexed_attrs)
|> Enum.map(fn(right_map) ->
{right_map.id, Comparer.compare_maps(left_map, right_map, rules)}
end)
|> Enum.filter(fn({_rm, score}) -> score >= min_score end)
|> Enum.sort(&(elem(&1, 1) >= elem(&2, 1))) # sort by best score desc
[left_map.id, right_matches]
end)
|> Enum.to_list()
|> Enum.filter(fn([_left, rights]) -> Enum.count(rights) > 0 end) # remove lefts with matches
|> Enum.reduce(%{}, fn(list, map) -> # convert to map of form %{left => [right1, right2, ...]}
{[left], [rights]} = Enum.split(list, 1)
Map.put(map, left, rights)
end)
{:ok, matches}
end
@doc """
Add a right match to the list associated with the left map.
Structure:
{ left1: [{right1, score1}, {right2, score2}, ...], left2... }
"""
def add_match(matches, left, right, score) do
case Map.has_key?(matches, left) do
true ->
Map.update!(matches, left, fn(list) -> [{right, score}] ++ list end)
false ->
Map.put(matches, left, [{right, score}])
end
end
@doc """
Resolves conflicts between left and right sides. Conflicts occur when a single
right map is the highest-scoring match to more than one left map.
Returns a list of finalized match tuples of form:
{left, right, score}
For each left map:
1) Add all to an "unresolved" list.
2) For each left map in the unresolved list, choose the highest available match
if it hasn't already been assigned. If it has been assigned, award the match
to the pair with the highest score. Add the losing map back onto the unresolved list.
3) Continue until the unresolved list is empty.
"""
def resolve(matches) do
unresolved = Map.keys(matches)
resolve(unresolved, matches, %{})
end
def resolve([], _all, matched_rights) do
# Return list of form { left, right, score }
matched_rights
|> Map.keys
|> Enum.reduce([], fn(right, list) ->
{left, score} = Map.fetch!(matched_rights, right)
[{left, right, score}] ++ list
end)
|> Enum.reverse
end
def resolve([uh|ut], all, matched_rights) do
rights = Map.fetch!(all, uh)
if Enum.empty?(rights) do
resolve(ut, all, matched_rights)
end
{right_match, score, new_rights, unresolved} = best_match(uh, rights, matched_rights)
# Update the list of all matches with a reduced list of right match
# options. All options are retained until conflict resolution is
# complete because a given left map may be temporarily matched to
# multiple right maps during the process.
new_all = Map.put(all, uh, new_rights)
new_unresolved = case unresolved do
nil ->
ut
_ ->
[unresolved] ++ ut
end
case right_match do
nil ->
resolve(new_unresolved, new_all, matched_rights)
_ ->
resolve(new_unresolved, new_all, Map.put(matched_rights, right_match, {uh, score}))
end
end
def best_match(_l, [], _mr), do: {nil, 0.0, [], nil} # SOL
@doc """
For a given left map, find the highest-scoring right map
that is available for matching. If a previously-existing matched pair
has a lower score, replace it and add that previous left map back to
the unresolved list.
"""
def best_match(left, [rh|rt], matched_rights) do
{right, score} = rh
case Map.fetch(matched_rights, right) do
{:ok, {previous_left, previous_score}} ->
case score > previous_score do
true ->
# Replace the previous winner with this left.
{right, score, rt, previous_left}
false ->
# Previous winner remains. Keep searching.
best_match(left, rt, matched_rights)
end
:error ->
# No previous match so this left is the winner.
{right, score, rt, nil}
end
end
end