feat: add solve_full_name_dictionary with Cartesian product matching

Generate first x last name combinations from associate lists, apply
Title Case or UPPER casing, measure rendered widths, and return matches
within tolerance. Also checks associate name variants (initials,
nicknames) with deduplication.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-18 12:00:45 -06:00
1 parent 72df8d3579
commit 7b0802d005
2 files changed
+178

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
"""Tests for dictionary-based full name solving."""
from unittest.mock import MagicMock, patch
from unredact.pipeline.dictionary import solve_full_name_dictionary
from unredact.pipeline.solver import SolveResult
def _mock_font(width_map: dict[str, float]) -> MagicMock:
"""Create a mock font that returns widths from a map."""
font = MagicMock()
font.getlength.side_effect = lambda text: width_map.get(text, len(text) * 7.0)
return font
class TestSolveFullNameDictionary:
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_basic_match(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john", "jane"]
mock_lasts.return_value = ["doe", "smith"]
mock_variants.return_value = []
font = _mock_font({"John Doe": 50.0, "John Smith": 60.0, "Jane Doe": 48.0, "Jane Smith": 58.0})
results = solve_full_name_dictionary(font, 50.0, 1.0)
texts = [r.text for r in results]
assert "John Doe" in texts
assert "Jane Doe" not in texts # 48.0 is outside tolerance
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_uppercase_mode(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john"]
mock_lasts.return_value = ["doe"]
mock_variants.return_value = []
font = _mock_font({"JOHN DOE": 55.0})
results = solve_full_name_dictionary(font, 55.0, 1.0, uppercase_only=True)
assert len(results) == 1
assert results[0].text == "JOHN DOE"
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_includes_associate_variants(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john"]
mock_lasts.return_value = ["doe"]
mock_variants.return_value = ["j. doe", "johnny doe"]
font = _mock_font({
"John Doe": 50.0,
"J. Doe": 35.0,
"Johnny Doe": 60.0,
})
results = solve_full_name_dictionary(font, 50.0, 1.0)
texts = [r.text for r in results]
assert "John Doe" in texts
# J. Doe title-cases to "J. Doe" with width 35.0 -- outside tolerance
# Johnny Doe title-cases to "Johnny Doe" with width 60.0 -- outside tolerance
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_context_chars(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john"]
mock_lasts.return_value = ["doe"]
mock_variants.return_value = []
font = _mock_font({
"<John Doe>": 60.0,
"<": 5.0,
">": 5.0,
})
results = solve_full_name_dictionary(font, 50.0, 1.0, left_context="<", right_context=">")
assert len(results) == 1
assert results[0].text == "John Doe"
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_dedup_variants_and_cartesian(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john"]
mock_lasts.return_value = ["doe"]
mock_variants.return_value = ["john doe"] # duplicate of cartesian result
font = _mock_font({"John Doe": 50.0})
results = solve_full_name_dictionary(font, 50.0, 1.0)
texts = [r.text for r in results]
assert texts.count("John Doe") == 1 # no duplicates
@patch("unredact.pipeline.word_filter._get_associate_firsts")
@patch("unredact.pipeline.word_filter._get_associate_lasts")
@patch("unredact.pipeline.word_filter._get_associate_variants")
def test_sorted_by_error(self, mock_variants, mock_lasts, mock_firsts):
mock_firsts.return_value = ["john", "jane"]
mock_lasts.return_value = ["doe"]
mock_variants.return_value = []
font = _mock_font({"John Doe": 50.5, "Jane Doe": 50.0})
results = solve_full_name_dictionary(font, 50.0, 1.0)
assert len(results) == 2
assert results[0].error <= results[1].error
+66
View File
@@ -60,3 +60,69 @@ def solve_dictionary(
results.sort(key=lambda r: (r.error, r.text))
return results
def solve_full_name_dictionary(
font: ImageFont.FreeTypeFont,
target_width: float,
tolerance: float = 0.0,
left_context: str = "",
right_context: str = "",
uppercase_only: bool = False,
) -> list[SolveResult]:
"""Match associate first x last name combinations against target width.
Generates the Cartesian product of associate first and last names,
applies casing (Title Case or UPPER), and checks each combination's
rendered width. Also checks associate name variants (initials, nicknames).
"""
from unredact.pipeline.word_filter import (
_get_associate_firsts,
_get_associate_lasts,
_get_associate_variants,
)
firsts = _get_associate_firsts()
lasts = _get_associate_lasts()
variants = _get_associate_variants()
results: list[SolveResult] = []
seen: set[str] = set()
def _check(text: str):
if text in seen:
return
seen.add(text)
if left_context or right_context:
full = left_context + text + right_context
full_len = font.getlength(full)
left_len = font.getlength(left_context) if left_context else 0.0
right_len = font.getlength(right_context) if right_context else 0.0
width = full_len - left_len - right_len
else:
width = font.getlength(text)
error = abs(width - target_width)
if error <= tolerance:
results.append(SolveResult(text=text, width=float(width), error=float(error)))
# Cartesian product of first x last names
for first in firsts:
for last in lasts:
if uppercase_only:
text = (first + " " + last).upper()
else:
text = first.title() + " " + last.title()
_check(text)
# Associate variants (J. Doe, Jeff Epstein, etc.)
for variant in variants:
if uppercase_only:
text = variant.upper()
else:
text = variant.title()
_check(text)
results.sort(key=lambda r: (r.error, r.text))
return results