From d04116579bf9c1eee6debd307dc0534ee274b448 Mon Sep 17 00:00:00 2001 From: Alex-Gilbert Date: Mon, 23 Feb 2026 10:45:16 -0600 Subject: [PATCH] feat: add two-word binary search solver (phase 2 of word mode) Co-Authored-By: Claude Opus 4.6 --- tests/test_word_solve.py | 64 +++++++++++++++++++++++++++ unredact/pipeline/dictionary.py | 76 +++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/tests/test_word_solve.py b/tests/test_word_solve.py index f383e51..6c30770 100644 --- a/tests/test_word_solve.py +++ b/tests/test_word_solve.py @@ -86,3 +86,67 @@ class TestSolveWordDictionarySingleWord: target = font.getlength("dog") gen = solve_word_dictionary(font, target, tolerance=0.5, casing="lowercase") assert isinstance(gen, types.GeneratorType) + + +class TestSolveWordDictionaryTwoWord: + def test_finds_two_word_phrase(self): + font = _get_test_font() + target = font.getlength("large house") + results = list(solve_word_dictionary( + font, target, tolerance=1.0, + casing="lowercase", known_start="large", + )) + texts = [r.text for r in results] + assert "large house" in texts + + def test_two_word_capitalized(self): + font = _get_test_font() + target = font.getlength("Large House") + results = list(solve_word_dictionary( + font, target, tolerance=1.0, + casing="capitalized", known_start="Large", + )) + texts = [r.text for r in results] + assert "Large House" in texts + + def test_two_word_plural(self): + font = _get_test_font() + target = font.getlength("large houses") + results = list(solve_word_dictionary( + font, target, tolerance=1.0, + casing="lowercase", ensure_plural=True, known_start="large", + )) + texts = [r.text for r in results] + assert "large houses" in texts + + def test_two_word_known_start(self): + font = _get_test_font() + target = font.getlength("large house") + results = list(solve_word_dictionary( + font, target, tolerance=1.0, + casing="lowercase", known_start="la", + )) + texts = [r.text for r in results] + assert "large house" in texts + assert all(t.lower().startswith("la") for t in texts) + + def test_two_word_known_end(self): + font = _get_test_font() + target = font.getlength("large house") + results = list(solve_word_dictionary( + font, target, tolerance=1.0, + casing="lowercase", known_end="se", + )) + texts = [r.text for r in results] + assert "large house" in texts + assert all(t.lower().endswith("se") for t in texts) + + def test_no_duplicates_across_phases(self): + font = _get_test_font() + target = font.getlength("dog") + results = list(solve_word_dictionary( + font, target, tolerance=1.0, + casing="lowercase", + )) + texts = [r.text for r in results] + assert len(texts) == len(set(texts)) diff --git a/unredact/pipeline/dictionary.py b/unredact/pipeline/dictionary.py index fe75878..9e1f38a 100644 --- a/unredact/pipeline/dictionary.py +++ b/unredact/pipeline/dictionary.py @@ -317,3 +317,79 @@ def solve_word_dictionary( error = abs(width - target_width) if error <= tolerance: yield SolveResult(text=display, width=float(width), error=float(error)) + + # Phase 2: two-word search (word1 + " " + noun) + import bisect + + from unredact.pipeline.word_filter import _get_adjectives + + adjectives = _get_adjectives() + + # Word1 pool: adjectives + nouns (deduplicated, sorted) + word1_set = set(adjectives) | set(nouns) + word1_list = sorted(word1_set) + + # Word2 pool: nouns or plural nouns + word2_list = list(plurals if ensure_plural else nouns) + + # Pre-measure word2 widths and sort + word2_measured: list[tuple[float, str, str]] = [] + for w2 in word2_list: + display2 = _apply_casing(w2, casing) + w = font.getlength(display2) + word2_measured.append((w, display2, w2)) + + word2_measured.sort(key=lambda x: x[0]) + word2_widths = [x[0] for x in word2_measured] + + KERNING_MARGIN = 3.0 + + for w1 in word1_list: + if ks_lower and not w1.startswith(ks_lower): + continue + + display1 = _apply_casing(w1, casing) + + # Measure word1 + space width (with left context kerning) + w1_with_space = display1 + " " + if left_context: + w1_portion = ( + font.getlength(left_context + w1_with_space) + - font.getlength(left_context) + ) + else: + w1_portion = font.getlength(w1_with_space) + + remaining = target_width - w1_portion + if remaining < 0: + continue + + # Binary search for word2 candidates + lo = bisect.bisect_left(word2_widths, remaining - tolerance - KERNING_MARGIN) + hi = bisect.bisect_right(word2_widths, remaining + tolerance + KERNING_MARGIN) + + for i in range(lo, hi): + _, display2, w2_raw = word2_measured[i] + if ke_lower and not w2_raw.endswith(ke_lower): + continue + + phrase = display1 + " " + display2 + if phrase in seen: + continue + + # Verify exact combined width with full kerning context + if left_context or right_context: + full = left_context + phrase + 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 + exact_width = full_len - left_len - right_len + else: + exact_width = font.getlength(phrase) + + error = abs(exact_width - target_width) + if error <= tolerance: + seen.add(phrase) + yield SolveResult( + text=phrase, width=float(exact_width), error=float(error) + )