From 6e77070538286ee2d203780a75cd7dfa58a0ce26 Mon Sep 17 00:00:00 2001 From: Alex-Gilbert Date: Sun, 22 Feb 2026 14:44:33 -0600 Subject: [PATCH] fix: known_start/end casing for multi-word name width calculation When known_start was set with capitalized casing, the unknown portion was forced to all-lowercase, breaking width measurements for multi-word names (e.g., "hn doe" instead of "hn Doe"). Added _case_unknown_portion helper that respects word boundaries. Also fixed effective_left/right kerning context to use correctly cased characters from the display string. Co-Authored-By: Claude Opus 4.6 --- tests/test_full_name_dictionary.py | 73 ++++++++++++----------- tests/test_name_dictionary.py | 16 +++++ unredact/pipeline/dictionary.py | 95 +++++++++++++++--------------- 3 files changed, 99 insertions(+), 85 deletions(-) diff --git a/tests/test_full_name_dictionary.py b/tests/test_full_name_dictionary.py index d274657..02c3a2c 100644 --- a/tests/test_full_name_dictionary.py +++ b/tests/test_full_name_dictionary.py @@ -14,13 +14,9 @@ def _mock_font(width_map: dict[str, float]) -> MagicMock: 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 = [] + def test_basic_match(self, mock_variants): + mock_variants.return_value = ["john doe", "john smith", "jane doe", "jane smith"] font = _mock_font({"John Doe": 50.0, "John Smith": 60.0, "Jane Doe": 48.0, "Jane Smith": 58.0}) @@ -30,13 +26,9 @@ class TestSolveFullNameDictionary: 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 = [] + def test_uppercase_mode(self, mock_variants): + mock_variants.return_value = ["john doe"] font = _mock_font({"JOHN DOE": 55.0}) @@ -45,13 +37,9 @@ class TestSolveFullNameDictionary: 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"] + def test_includes_associate_variants(self, mock_variants): + mock_variants.return_value = ["john doe", "j. doe", "johnny doe"] font = _mock_font({ "John Doe": 50.0, @@ -65,13 +53,9 @@ class TestSolveFullNameDictionary: # 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 = [] + def test_context_chars(self, mock_variants): + mock_variants.return_value = ["john doe"] font = _mock_font({ "": 60.0, @@ -83,13 +67,9 @@ class TestSolveFullNameDictionary: 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 + def test_dedup(self, mock_variants): + mock_variants.return_value = ["john doe", "john doe"] # duplicate font = _mock_font({"John Doe": 50.0}) @@ -97,16 +77,37 @@ class TestSolveFullNameDictionary: 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 = [] + def test_sorted_by_error(self, mock_variants): + mock_variants.return_value = ["john doe", "jane doe"] 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 + + @patch("unredact.pipeline.word_filter._get_associate_variants") + def test_known_start_multiword_casing(self, mock_variants): + """Unknown portion of multi-word name should preserve word-boundary casing. + + Name: 'john doe', known_start='jo', unknown='hn Doe' (not 'hn doe'). + The width measurement must use 'hn Doe' not 'hn doe'. + effective_left = last char of _apply_casing('jo') = 'o', so + font sees 'o' + 'hn Doe' = 'ohn Doe' (kerning context string). + """ + mock_variants.return_value = ["john doe"] + + # Mock: the kerning-context string "ohn Doe" vs "ohn doe". + # font.getlength("o") defaults to 7.0 (1 char * 7.0). + # width = font.getlength("ohn Doe") - font.getlength("o") = 42.0 - 7.0 = 35.0 + font = _mock_font({ + "ohn Doe": 42.0, # correct cased unknown (width = 35.0) + "ohn doe": 38.0, # wrong (current bug: all-lowercase, width = 31.0) + }) + + results = solve_full_name_dictionary( + font, 35.0, 1.0, known_start="jo", casing="capitalized", + ) + texts = [r.text for r in results] + assert "John Doe" in texts diff --git a/tests/test_name_dictionary.py b/tests/test_name_dictionary.py index f2165b1..5318a49 100644 --- a/tests/test_name_dictionary.py +++ b/tests/test_name_dictionary.py @@ -108,6 +108,22 @@ class TestSolveNameDictionary: assert len(results) == 2 assert results[0].error <= results[1].error + @patch("unredact.pipeline.word_filter._get_associate_firsts") + @patch("unredact.pipeline.word_filter._get_associate_lasts") + def test_known_start_capitalized_casing(self, mock_lasts, mock_firsts): + """With known_start and capitalized casing, unknown part is mid-word lowercase.""" + mock_firsts.return_value = ["john"] + mock_lasts.return_value = [] + + # known_start="j", unknown="ohn", should measure "ohn" (lowercase) + font = _mock_font({"ohn": 21.0}) + + results = solve_name_dictionary( + font, 21.0, 1.0, known_start="j", casing="capitalized", + ) + texts = [r.text for r in results] + assert "John" in texts + @patch("unredact.pipeline.word_filter._get_associate_firsts") @patch("unredact.pipeline.word_filter._get_associate_lasts") def test_context_chars(self, mock_lasts, mock_firsts): diff --git a/unredact/pipeline/dictionary.py b/unredact/pipeline/dictionary.py index 1f57205..6b4b6d2 100644 --- a/unredact/pipeline/dictionary.py +++ b/unredact/pipeline/dictionary.py @@ -71,6 +71,33 @@ def _apply_casing(text: str, casing: str) -> str: return text +def _case_unknown_portion(unknown: str, known_start: str, casing: str) -> str: + """Apply casing to the unknown portion of a name, respecting word boundaries. + + When known_start is set and casing is 'capitalized', the unknown text + starts mid-word — its first fragment should be lowercase, but subsequent + words (after spaces) should be title-cased. + """ + if casing == "uppercase": + return unknown.upper() + if casing != "capitalized": + return unknown # lowercase — no change + + if not known_start: + return unknown.title() + + # If known_start ends with a space, unknown starts a new word + if known_start.endswith(" "): + return unknown.title() + + # Unknown starts mid-word: first fragment lowercase, rest title-cased + parts = unknown.split(" ") + result = [parts[0].lower()] + for part in parts[1:]: + result.append(part.title() if part else "") + return " ".join(result) + + def solve_full_name_dictionary( font: ImageFont.FreeTypeFont, target_width: float, @@ -81,23 +108,17 @@ def solve_full_name_dictionary( known_start: str = "", known_end: str = "", ) -> list[SolveResult]: - """Match associate first x last name combinations against target width. + """Match per-person full name variants against target width. - Generates the Cartesian product of associate first and last names, - applies casing, and checks each combination's rendered width. - Also checks associate name variants (initials, nicknames). + Uses pre-built multi-word name variants from associates.json + (full names, initial+last, nickname+last) — each tied to a real + person rather than a Cartesian product of unrelated names. If known_start/known_end are set, filters candidates and measures only the unknown portion against the target width. """ - from unredact.pipeline.word_filter import ( - _get_associate_firsts, - _get_associate_lasts, - _get_associate_variants, - ) + from unredact.pipeline.word_filter import _get_associate_variants - firsts = _get_associate_firsts() - lasts = _get_associate_lasts() variants = _get_associate_variants() results: list[SolveResult] = [] @@ -106,33 +127,28 @@ def solve_full_name_dictionary( ks_lower = known_start.lower() ke_lower = known_end.lower() - def _check(raw: str, display: str): - """Check a candidate. raw is lowercase, display is cased.""" + for variant in variants: + raw = variant.lower() + display = _apply_casing(variant, casing) + if display in seen: - return + continue seen.add(display) - # Filter by known start/end if ks_lower and not raw.startswith(ks_lower): - return + continue if ke_lower and not raw.endswith(ke_lower): - return + continue if known_start or known_end: - # Measure only the unknown portion end_idx = len(raw) - len(known_end) if known_end else len(raw) unknown_raw = raw[len(known_start):end_idx] if not unknown_raw: - return - unknown_display = _apply_casing(unknown_raw, casing) - # If known_start exists and casing is capitalized, unknown part is mid-word - if known_start and casing == "capitalized": - # The unknown part follows known text, so it's lowercase - # (title() would wrongly capitalize first char of unknown) - unknown_display = unknown_raw.lower() + continue + unknown_display = _case_unknown_portion(unknown_raw, known_start, casing) - effective_left = known_start[-1] if known_start else left_context - effective_right = known_end[0] if known_end else right_context + effective_left = _apply_casing(known_start, casing)[-1] if known_start else left_context + effective_right = display[end_idx] if known_end else right_context if effective_left or effective_right: full = effective_left + unknown_display + effective_right @@ -156,18 +172,6 @@ def solve_full_name_dictionary( if error <= tolerance: results.append(SolveResult(text=display, width=float(width), error=float(error))) - # Cartesian product of first x last names - for first in firsts: - for last in lasts: - raw = first + " " + last - display = _apply_casing(raw, casing) - _check(raw, display) - - # Associate variants (J. Doe, Jeff Epstein, etc.) - for variant in variants: - display = _apply_casing(variant, casing) - _check(variant.lower(), display) - results.sort(key=lambda r: (r.error, r.text)) return results @@ -236,19 +240,12 @@ def solve_name_dictionary( if not unknown: continue # entire name is known, nothing to measure - # Apply same casing to the unknown portion - if casing == "uppercase": - unknown_display = unknown.upper() - elif casing == "capitalized": - # Mid-word after known_start: lowercase. At word start: title case. - unknown_display = unknown.lower() if known_start else unknown.title() - else: - unknown_display = unknown + unknown_display = _case_unknown_portion(unknown, known_start, casing) # Determine kerning context # If known_start is set, its last char is left context for the unknown part - effective_left = known_start[-1] if known_start else left_context - effective_right = known_end[0] if known_end else right_context + effective_left = _apply_casing(known_start, casing)[-1] if known_start else left_context + effective_right = display[end_idx] if known_end else right_context # Measure width of unknown portion with kerning context if effective_left or effective_right: