From 7b0802d00560b88f791e8fa53db52a663ba0b476 Mon Sep 17 00:00:00 2001 From: Alex-Gilbert Date: Wed, 18 Feb 2026 12:00:45 -0600 Subject: [PATCH] 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 --- tests/test_full_name_dictionary.py | 112 +++++++++++++++++++++++++++++ unredact/pipeline/dictionary.py | 66 +++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 tests/test_full_name_dictionary.py diff --git a/tests/test_full_name_dictionary.py b/tests/test_full_name_dictionary.py new file mode 100644 index 0000000..a8c4884 --- /dev/null +++ b/tests/test_full_name_dictionary.py @@ -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({ + "": 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 diff --git a/unredact/pipeline/dictionary.py b/unredact/pipeline/dictionary.py index 576998e..9256b50 100644 --- a/unredact/pipeline/dictionary.py +++ b/unredact/pipeline/dictionary.py @@ -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