diff --git a/packages/flow/openadapt_flow/runtime/identity.py b/packages/flow/openadapt_flow/runtime/identity.py index b151351b1..820697261 100644 --- a/packages/flow/openadapt_flow/runtime/identity.py +++ b/packages/flow/openadapt_flow/runtime/identity.py @@ -364,8 +364,31 @@ def squash(text: str) -> str: - """Lowercase and remove ALL whitespace (OCR-tolerant comparison form).""" - return "".join(text.lower().split()) + """Lowercase and drop whitespace and OCR-volatile name punctuation. + + Comma and period on letter tokens are stripped because thin identity-band + crops routinely make RapidOCR read ``Smith,`` as ``Smith.`` (and the + reverse). Leaving them in the comparison form turned a glyph-collapse + ABSTAIN into a false affirmative MISMATCH on native Windows while Mac + stayed consistent. + + Periods that separate digits (``01.01.1900``) are kept: collapsing them + turns a date into a digit run that the glyph-vulnerable-identifier gate + then mistreats as an MRN-like token and spuriously abstains. + """ + lowered = text.lower() + out: list[str] = [] + for i, ch in enumerate(lowered): + if ch.isspace(): + continue + if ch in ",.": + prev = lowered[i - 1] if i else "" + nxt = lowered[i + 1] if i + 1 < len(lowered) else "" + if prev.isdigit() and nxt.isdigit(): + out.append(ch) # date / decimal separator + continue + out.append(ch) + return "".join(out) def band_region(point: Point, band_height: int, viewport: tuple[int, int]) -> Region: diff --git a/packages/flow/tests/test_identity.py b/packages/flow/tests/test_identity.py index 5d5e1fdc6..314dca793 100644 --- a/packages/flow/tests/test_identity.py +++ b/packages/flow/tests/test_identity.py @@ -347,6 +347,23 @@ def test_tokenize(self): assert tokenize(" Jane Li \n panel ") == ["jane", "li", "panel"] assert tokenize("") == [] + def test_comma_period_name_punctuation_is_ocr_noise(self): + # Recorded thin-band context often stores "Smith." while the live + # band (or another engine) returns "Smith," — same patient row. + match = band_match( + "100512 Smith. John 01/15/1980 Active", + "100512 Smith, John 01/15/1980 Active", + ) + assert match.coverage == 1.0 + assert match.contradicted_chars == 0 + assert match.unexplained_name_tokens == 0 + # A real near-name sibling must still mismatch. + bad = band_match( + "100512 Smith. John 01/15/1980 Active", + "100512 Smith, Joan 01/15/1980 Active", + ) + assert bad.contradicted_chars > 0 or bad.unexplained_name_tokens > 0 + class TestOperatingPoint: """Pin the ROC-chosen decision parameters and their boundaries @@ -456,6 +473,19 @@ def test_coverage_ignores_scattered_short_blocks(self): assert coverage("abcdef", "abcdef") == 1.0 assert coverage("", "x") == 0.0 + def test_squash_drops_comma_period(self): + # Thin identity-band crops make RapidOCR read "Smith," as "Smith." + # (Windows native); both must compare equal so glyph abstain is not + # overwritten by a false punctuation mismatch. + assert squash("Smith,") == squash("Smith.") + assert squash("Smith, John") == "smithjohn" + assert squash("Jr.") == "jr" + # Date / digit separators must survive: collapsing "01.01.1900" into + # "01011900" makes the glyph-vulnerable-identifier gate spuriously + # abstain on a clean MRN + period-separated DOB pair (CI fuzz). + assert squash("01.01.1900") == "01.01.1900" + assert squash("01/01/1900") == "01/01/1900" + def test_band_region_clamps_to_viewport(self): assert band_region((500, 10), 64, (1280, 800)) == (0, 0, 1280, 64) assert band_region((500, 795), 64, (1280, 800)) == (0, 736, 1280, 64)