Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyramid_matching/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pyramid-matching"
version = "0.1.0"
version = "0.1.1"
description = "OpenHEXA pyramid matching development utility library maintained by Bluesquare Data Services team."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
15 changes: 15 additions & 0 deletions pyramid_matching/pyramid_matcher/pyramid_matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,21 @@ def run_matching(
)
list_candidate_not_matched.append(candidate_no_match_level)

if data_matched.is_empty():
self._log(
f"No matches found at {level}. Stopping hierarchical matching."
)
return (
pl.DataFrame(),
pl.DataFrame(),
pl.concat(list_reference_not_matched)
if list_reference_not_matched
else pl.DataFrame(),
pl.concat(list_candidate_not_matched)
if list_candidate_not_matched
else pl.DataFrame(),
)

repeated_levels_check = []
for level in levels_to_match:
data_matched = self._add_repeated_matches(
Expand Down
40 changes: 40 additions & 0 deletions pyramid_matching/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,46 @@
)


# ================================
# run_matching: level_1 matches, level_2 does not
# ================================

# Reference has a single path: PROVINCE_A → DISTRICT_X
# Candidate has a single path: PROVINCE_A → ZONE_OMEGA
# FuzzyMatcher(threshold=80): "PROVINCE_A"↔"PROVINCE_A" scores 100 (match);
# "DISTRICT_X"↔"ZONE_OMEGA" scores far below 80 (no match).

run_matching_l1_match_l2_nomatch_reference = pl.DataFrame(
{
"level_1_name": ["PROVINCE_A"],
"level_2_name": ["DISTRICT_X"],
}
)

run_matching_l1_match_l2_nomatch_candidate = pl.DataFrame(
{
"level_1_name": ["PROVINCE_A"],
"level_2_name": ["ZONE_OMEGA"],
}
)

run_matching_l1_match_l2_nomatch_expected_ref_unmatched = pl.DataFrame(
{
"level_1_name": ["PROVINCE_A"],
"level_2_name": ["DISTRICT_X"],
"unmatched_level": ["level_2"],
}
)

run_matching_l1_match_l2_nomatch_expected_can_unmatched = pl.DataFrame(
{
"level_1_name": ["PROVINCE_A"],
"level_2_name": ["ZONE_OMEGA"],
"unmatched_level": ["level_2"],
}
)


# ================================
# Matcher candidates
# ================================
Expand Down
83 changes: 81 additions & 2 deletions pyramid_matching/tests/test_pyramid_matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,11 @@ def _get_attrs_side(lvls, suffix):
patch.object(
pm,
"_match_level",
return_value=(pl.DataFrame(), pl.DataFrame(), pl.DataFrame()),
return_value=(
pl.DataFrame({"dummy": ["x"]}),
pl.DataFrame(),
pl.DataFrame(),
),
)
)
stack.enter_context(
Expand Down Expand Up @@ -726,7 +730,7 @@ def ml_side_effect(
):
captured_target_levels.append(target_level)
captured_levels_already_matched.append(list(levels_already_matched))
return pl.DataFrame(), pl.DataFrame(), pl.DataFrame()
return pl.DataFrame({"dummy": ["x"]}), pl.DataFrame(), pl.DataFrame()

with stack:
with (
Expand Down Expand Up @@ -820,6 +824,81 @@ def test_run_matching_returns_empty_unmatched_dfs_and_correct_tuple_order_when_a
assert result[3].is_empty()


# --- Scenario: level_1 matches, level_2 does not ---
# 9. When level_1 produces matches but level_2 produces no matches, the function returns early.
# Outputs: matched/simplified are empty; unmatched DataFrames carry the unmatched level_2
# entries with an "unmatched_level" column. Call counts: _match_level ×2, _match_level_group
# ×2, _select_group ×1 (only when already_matched is non-empty), _add_repeated_matches ×0
# and _reorder_match_columns ×0 (never reached due to early return).
# → test_run_matching_level1_match_level2_no_match_outputs
# → test_run_matching_level1_match_level2_no_match_call_counts


def test_run_matching_level1_match_level2_no_match_outputs(
pyramid_matcher: PyramidMatcher,
):
"""When level_1 matches but level_2 does not, matched/simplified are empty and the
unmatched DataFrames carry the unmatched level_2 entries tagged with unmatched_level."""
matched, simplified, ref_unmatched, can_unmatched = pyramid_matcher.run_matching(
test_config.run_matching_l1_match_l2_nomatch_reference,
test_config.run_matching_l1_match_l2_nomatch_candidate,
)

assert matched.is_empty()
assert simplified.is_empty()
assert_frame_equal(
ref_unmatched,
test_config.run_matching_l1_match_l2_nomatch_expected_ref_unmatched,
check_row_order=False,
)
assert_frame_equal(
can_unmatched,
test_config.run_matching_l1_match_l2_nomatch_expected_can_unmatched,
check_row_order=False,
)


def test_run_matching_level1_match_level2_no_match_call_counts(
pyramid_matcher: PyramidMatcher,
):
"""When level_1 matches but level_2 does not, internal methods are called the expected
number of times: _match_level ×2, _match_level_group ×2, _select_group ×1,
_add_repeated_matches ×0 and _reorder_match_columns ×0 (early return skips both)."""
with (
patch.object(
pyramid_matcher, "_match_level", wraps=pyramid_matcher._match_level
) as mock_match_level,
patch.object(
pyramid_matcher,
"_match_level_group",
wraps=pyramid_matcher._match_level_group,
) as mock_match_level_group,
patch.object(
pyramid_matcher, "_select_group", wraps=pyramid_matcher._select_group
) as mock_select_group,
patch.object(
pyramid_matcher,
"_add_repeated_matches",
wraps=pyramid_matcher._add_repeated_matches,
) as mock_add_repeated,
patch.object(
pyramid_matcher,
"_reorder_match_columns",
wraps=pyramid_matcher._reorder_match_columns,
) as mock_reorder,
):
pyramid_matcher.run_matching(
test_config.run_matching_l1_match_l2_nomatch_reference,
test_config.run_matching_l1_match_l2_nomatch_candidate,
)

assert mock_match_level.call_count == 2
assert mock_match_level_group.call_count == 2
assert mock_select_group.call_count == 1
assert mock_add_repeated.call_count == 0
assert mock_reorder.call_count == 0


# ================================
# _select_group
# ================================
Expand Down
Loading