From 0ce08f95c9a085963c2fe9ff6324e274d0dc2961 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 13:02:04 -0500 Subject: [PATCH 1/7] identity: a differing identifier is a contradiction, never a tolerated gap Found on Open Dental in the Windows lab: a row band padded with a static label let a same-name sibling with another PatNum and age verify. "15 47" vs "16 50" was a 4-char uncovered run, within UNCOVERED_RUN_CAP, and 2-char tokens were below every contradiction rule. - An unmatched recorded token carrying a digit (id, date, age, count), of any length, is contradicted when the live band shows an unexplained digit-bearing token: a differing value is a different record. - Absent outright (OCR dropout), it marks identity evidence short, so the OCR tier abstains; it is never a tolerated uncovered run. - Same rules in the hashed-template path. - The covered-band repair weighs whole words only: the word the dialog cuts is already checked as a prefix of the recorded word. --- .../flow/openadapt_flow/repair/self_repair.py | 8 +- .../flow/openadapt_flow/runtime/identity.py | 27 ++++- .../runtime/identity_template.py | 11 +- .../test_identity_identifier_contradiction.py | 113 ++++++++++++++++++ 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 packages/flow/tests/test_identity_identifier_contradiction.py diff --git a/packages/flow/openadapt_flow/repair/self_repair.py b/packages/flow/openadapt_flow/repair/self_repair.py index 39812f57f4..aea1b6c9ee 100644 --- a/packages/flow/openadapt_flow/repair/self_repair.py +++ b/packages/flow/openadapt_flow/repair/self_repair.py @@ -2828,8 +2828,14 @@ def words(text: str) -> list[str]: return False if not band[len(read) - 1].startswith(read[-1]): return False + whole = observed + if read[-1] != band[len(read) - 1]: + # The word the dialog cuts is checked above as a prefix of the + # recorded word; the template weighs whole words only, so a cut + # date or id ("1984-") is not read as a different value. + whole = " ".join(observed.split()[:-1]) try: - match = _band_match_template(template, observed) + match = _band_match_template(template, whole) except Exception: # noqa: BLE001 - an unreadable match is doubt return False return ( diff --git a/packages/flow/openadapt_flow/runtime/identity.py b/packages/flow/openadapt_flow/runtime/identity.py index 67013360bd..5b90951518 100644 --- a/packages/flow/openadapt_flow/runtime/identity.py +++ b/packages/flow/openadapt_flow/runtime/identity.py @@ -1198,7 +1198,11 @@ def _contradicted( confusion-equivalent — a changed middle initial ('J' -> 'K'), the SEX column ('M' -> 'F'), a 2-char name ('Al' -> 'Bo'). These sat below MIN_BLOCK and were invisible to every rule; - - generational suffix on either side, unmatched. + - generational suffix on either side, unmatched; + - an unmatched recorded token carrying a digit (an id, date, age or + count), whatever its length, while an unexplained observed token + carries one too: a differing value is what tells same-name records + apart, never a tolerated uncovered run. """ exp_c = [ocr_canonical(t) for t in exp] obs_c = [ocr_canonical(t) for t in obs] @@ -1210,12 +1214,23 @@ def _contradicted( obs_suffix_unexplained = any( not explained[j] and _is_generational_suffix(o) for j, o in enumerate(obs) ) + unexplained_digit = any( + not explained[j] and _has_digit(o) for j, o in enumerate(obs) + ) for i, token in enumerate(exp): if matched[i]: continue if _is_generational_suffix(token): contradicted[i] = True continue + if _has_digit(token) and unexplained_digit: + # A recorded id, date, age or count missing while the live band + # shows another digit-bearing token is a different record, never + # a small gap: same-name siblings differ ONLY in such tokens + # (PatNum 15 vs 16, age 47 vs 50), often under MIN_BLOCK. Pure + # absence is charged in band_match (absent identifying field). + contradicted[i] = True + continue if len(token) < MIN_BLOCK: # Short-token replacement (Blocker 2) is handled with # COUNT-based accounting in band_match: a replaced initial @@ -1255,6 +1270,14 @@ def _contradicted( return contradicted +def _digit_token_absent(exp: list[str], matched: list[bool]) -> bool: + """Whether a digit-bearing recorded token (id, date, age, count) is + unmatched. Such a token is never a tolerated uncovered run: absent, the + band lacks identifying evidence (the OCR tier abstains); replaced by + another digit token, it is a contradiction (:func:`_contradicted`).""" + return any(not m and _has_digit(t) for t, m in zip(exp, matched)) + + def band_match( expected_text: str, observed_text: str, @@ -1410,7 +1433,7 @@ def band_match( matched, raw_matched, [_has_lookalike(t) for t in exp], - ) + ) or _digit_token_absent(exp, matched) return BandMatch( matched_chars / total_chars, max(uncovered_runs, default=0), diff --git a/packages/flow/openadapt_flow/runtime/identity_template.py b/packages/flow/openadapt_flow/runtime/identity_template.py index 99927e9b4b..d1889ccb25 100644 --- a/packages/flow/openadapt_flow/runtime/identity_template.py +++ b/packages/flow/openadapt_flow/runtime/identity_template.py @@ -526,12 +526,21 @@ def _contradicted_template( obs_suffix_unexplained = any( not explained[j] and _id._is_generational_suffix(o) for j, o in enumerate(obs) ) + unexplained_digit = any( + not explained[j] and _id._has_digit(o) for j, o in enumerate(obs) + ) for i, t in enumerate(toks): if matched[i]: continue if t.gen: contradicted[i] = True continue + if t.digit and unexplained_digit: + # Parity with identity._contradicted: an unmatched digit-bearing + # recorded token (id, date, age) of any length, with another + # digit-bearing token unexplained in the live band. + contradicted[i] = True + continue if t.n < _id.MIN_BLOCK: continue if unexplained_alpha and t.alpha: @@ -631,7 +640,7 @@ def _band_match_template(tmpl: IdentityTemplate, observed_text: str) -> _id.Band # No plaintext to inspect: every token counts as look-alike, the # conservative side of identity_evidence_shortfall's rule. [True] * len(toks), - ) + ) or any(not m and t.digit for t, m in zip(toks, matched)) return _id.BandMatch( matched_chars / total_chars if total_chars else 0.0, diff --git a/packages/flow/tests/test_identity_identifier_contradiction.py b/packages/flow/tests/test_identity_identifier_contradiction.py new file mode 100644 index 0000000000..a9709718cf --- /dev/null +++ b/packages/flow/tests/test_identity_identifier_contradiction.py @@ -0,0 +1,113 @@ +"""A differing identifier is a contradiction, never a tolerated gap. + +Found on Open Dental's Select Patient dialog (Windows lab, 2026-11-03): a row +band padded with a static label let a same-name sibling with another PatNum +and age verify. "15 47" vs "16 50" left a 4-char uncovered run, within +UNCOVERED_RUN_CAP, and 2-char tokens were below every contradiction rule. +Every case here must refuse on the plaintext and the template path. +""" + +from __future__ import annotations + +import pytest + +from openadapt_flow.runtime import identity as I +from openadapt_flow.runtime.identity_template import ( + build_identity_template, + verify_template_identity, +) + +LABEL = "Hint: enter values in multiple boxes." + + +def _statuses(recorded: str, observed: str) -> tuple[str, str]: + plain = I.verify_target_identity(recorded, observed, params={}, param_examples={}) + tmpl = build_identity_template(recorded, param_examples={}) + assert tmpl is not None + hashed = verify_template_identity(tmpl, observed, params={}, param_examples={}) + return plain.status, hashed.status + + +WRONG_RECORDS = [ + # The pair found on Open Dental: same name, another PatNum and age. + (f"Smith John 15 47 {LABEL}", f"Smith John 16 50 {LABEL}"), + # Label padding before the row, and on both sides. + (f"{LABEL} Smith John 15 47", f"{LABEL} Smith John 16 50"), + ( + f"Last Name First Name {LABEL} Smith John 15 47 Phone Address City", + f"Last Name First Name {LABEL} Smith John 16 50 Phone Address City", + ), + # The label glued by OCR on the live side. + (f"Smith John 15 47 {LABEL}", "Smith John 16 50 Hintentervaluesinmultipleboxes."), + # Only the PatNum differs, or only the age. + (f"Smith John 15 47 {LABEL}", f"Smith John 16 47 {LABEL}"), + (f"Smith John 15 47 {LABEL}", f"Smith John 15 50 {LABEL}"), + # Another date of birth, MRN, or account under a shared address. + ( + "Ashford Jane 02/23/1975 125 Satin Heights Springfield Active", + "Ashford Jane 02/24/1975 125 Satin Heights Springfield Active", + ), + ( + "Ashford Jane MRN 40081 125 Satin Heights Springfield Active", + "Ashford Jane MRN 40082 125 Satin Heights Springfield Active", + ), + ( + f"Ashford Jane Acct 7 {LABEL}", + f"Ashford Jane Acct 8 {LABEL}", + ), + # A single-digit counter or age. + (f"Smith John 9 {LABEL}", f"Smith John 8 {LABEL}"), + # OCR confusions on identifiers (1/l, 0/O): the live id is another one, + # or an OCR-ambiguous read of one. + (f"Smith John 15 47 {LABEL}", f"Smith John l6 47 {LABEL}"), + (f"Smith John 105 47 {LABEL}", f"Smith John l05 47 {LABEL}"), + ( + "Ashford Jane MRN 40081 125 Satin Heights", + "Ashford Jane MRN 4OO8l 125 Satin Heights", + ), + # The recorded id dropped and another digit token in its place. + (f"Smith John 15 {LABEL}", f"Smith John {LABEL} 16"), +] + + +@pytest.mark.parametrize("recorded,observed", WRONG_RECORDS) +def test_a_differing_identifier_refuses(recorded: str, observed: str) -> None: + plain, hashed = _statuses(recorded, observed) + assert plain != "verified", (recorded, observed) + assert hashed != "verified", (recorded, observed) + + +@pytest.mark.parametrize( + "recorded,observed", + [ + # OCR dropped the identifier: not a contradiction, but not verified. + (f"Smith John 15 47 {LABEL}", f"Smith John {LABEL}"), + (f"Smith John 15 {LABEL}", f"Smith John {LABEL}"), + ], +) +def test_an_absent_identifier_never_verifies(recorded: str, observed: str) -> None: + plain, hashed = _statuses(recorded, observed) + assert plain != "verified" + assert hashed != "verified" + assert I.band_match(recorded, observed).identity_evidence_short + + +def test_the_digit_contradiction_is_charged_not_the_run_cap() -> None: + match = I.band_match(f"Smith John 15 47 {LABEL}", f"Smith John 16 50 {LABEL}") + assert match.max_uncovered_run <= I.UNCOVERED_RUN_CAP # the old hole + assert match.contradicted_chars == 4 + + +@pytest.mark.parametrize( + "recorded,observed", + [ + (f"Smith John 25 47 {LABEL}", f"Smith John 25 47 {LABEL}"), + (f"{LABEL} Smith John 25 47", f"Smith John 25 47 {LABEL}"), + ( + "Ashford Jane 02/23/1975 RC79284 Active", + "Ashford Jane 02/23/1975 RC79284 Active", + ), + ], +) +def test_the_true_row_still_verifies(recorded: str, observed: str) -> None: + assert _statuses(recorded, observed) == ("verified", "verified") From 0879718b309729055961a80ccb2af71b50d59b7a Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 13:08:29 -0500 Subject: [PATCH 2/7] identity: a digit token takes one observed copy; no glued digit pairs; params keep short ids Review blockers on this PR: - a second copy of the recorded value elsewhere in the band ("15 patients found") explained the recorded id, so a sibling's differing id went uncharged; a digit-bearing recorded token now matches one observed copy of its own, on both paths; - param mode dropped a short id lying inside the example value ("15" in "20150101"); a digit token is never dropped for being inside the value. Should-fix: split and join never glue two digit-bearing tokens ("15" "47" is not id 1547). --- .../flow/openadapt_flow/runtime/identity.py | 36 ++++++++++++++++--- .../runtime/identity_template.py | 18 +++++++--- .../test_identity_identifier_contradiction.py | 28 +++++++++++++++ 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/packages/flow/openadapt_flow/runtime/identity.py b/packages/flow/openadapt_flow/runtime/identity.py index 5b90951518..d728391609 100644 --- a/packages/flow/openadapt_flow/runtime/identity.py +++ b/packages/flow/openadapt_flow/runtime/identity.py @@ -1091,12 +1091,21 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: elif _suspicious_pair(expected_raw, observed_raw): suspect_evidence[i] = True - # single-token equivalence (mark every equivalent observed copy) + # single-token equivalence (mark every equivalent observed copy). A + # digit-bearing recorded token takes ONE observed copy of its own: a + # second copy of the recorded value elsewhere in the band ("15 patients + # found") must not stand in for a sibling's differing id. + digit_used: set[int] = set() for i, ec in enumerate(exp_c): + digit = _has_digit(exp[i]) for j, oc in enumerate(obs_c): - if ec == oc: - mark(i, exp[i], obs[j]) - explained[j] = True + if ec != oc or (digit and j in digit_used): + continue + mark(i, exp[i], obs[j]) + explained[j] = True + if digit: + digit_used.add(j) + break # split: consecutive recorded tokens -> one observed token for i in range(len(exp)): @@ -1105,6 +1114,8 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: break if all(matched[i : i + size]): continue + if _glues_digits(exp[i : i + size]): + continue concat_raw = "".join(exp[i : i + size]) concat_c = ocr_canonical(concat_raw) if len(concat_c) < MIN_BLOCK: @@ -1139,6 +1150,8 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: for size in (2, 3, 4): if j + size > len(obs): break + if _glues_digits(obs[j : j + size]): + continue concat_raw = "".join(obs[j : j + size]) if ocr_canonical(concat_raw) == exp_c[i]: mark(i, token, concat_raw) @@ -1270,6 +1283,15 @@ def _contradicted( return contradicted +def _glues_digits(fragments: list[str]) -> bool: + """Whether joining ``fragments`` would glue two digit-bearing tokens: + "15" + "47" is not OCR's reading of "1547" but two values, and a record + whose id is 1547 must not match them (nor the reverse).""" + return any( + _has_digit(a) and _has_digit(b) for a, b in zip(fragments, fragments[1:]) + ) + + def _digit_token_absent(exp: list[str], matched: list[bool]) -> bool: """Whether a digit-bearing recorded token (id, date, age, count) is unmatched. Such a token is never a tolerated uncovered run: absent, the @@ -1450,7 +1472,11 @@ def _token_belongs_to(token: str, value_squashed: str) -> bool: """Whether a band token is (part of) a parameter's demonstrated value.""" if not token or not value_squashed: return False - if token in value_squashed or value_squashed in token: + if value_squashed in token: + return True + if token in value_squashed and not _has_digit(token): + # A digit token is never dropped for being INSIDE the value: "15" + # (a PatNum) sits inside "20150101" (a date) and is its own field. return True ratio = difflib.SequenceMatcher(None, token, value_squashed, autojunk=False).ratio() # Same-token-with-OCR-jitter question ("Phi1" ~ "Phil"): the token diff --git a/packages/flow/openadapt_flow/runtime/identity_template.py b/packages/flow/openadapt_flow/runtime/identity_template.py index d1889ccb25..e2703749ce 100644 --- a/packages/flow/openadapt_flow/runtime/identity_template.py +++ b/packages/flow/openadapt_flow/runtime/identity_template.py @@ -298,6 +298,8 @@ def build_identity_template( for size in (2, 3, 4): if i + size > len(squashed_tokens): break + if _id._glues_digits(squashed_tokens[i : i + size]): + continue concat_raw = "".join(squashed_tokens[i : i + size]) concat_c = _id.ocr_canonical(concat_raw) if len(concat_c) < _id.MIN_BLOCK: @@ -438,12 +440,18 @@ def mark(i: int, obs_raw_key: str, obs_squashed: str) -> None: ): suspect_evidence[i] = True - # single-token equivalence + # single-token equivalence; a digit-bearing recorded token takes one + # observed copy of its own (parity with identity._match_tokens). + digit_used: set[int] = set() for i, t in enumerate(toks): for j in range(no): - if t.c == obs_c[j]: - mark(i, obs_r[j], obs[j]) - explained[j] = True + if t.c != obs_c[j] or (t.digit and j in digit_used): + continue + mark(i, obs_r[j], obs[j]) + explained[j] = True + if t.digit: + digit_used.add(j) + break # split: consecutive recorded tokens -> one observed token for ct in tmpl.concats: @@ -469,6 +477,8 @@ def mark(i: int, obs_raw_key: str, obs_squashed: str) -> None: for size in (2, 3, 4): if j + size > no: break + if _id._glues_digits(obs[j : j + size]): + continue concat_raw = "".join(obs[j : j + size]) if canon(concat_raw) == t.c: mark(i, raw(concat_raw), concat_raw) diff --git a/packages/flow/tests/test_identity_identifier_contradiction.py b/packages/flow/tests/test_identity_identifier_contradiction.py index a9709718cf..7dbf818c54 100644 --- a/packages/flow/tests/test_identity_identifier_contradiction.py +++ b/packages/flow/tests/test_identity_identifier_contradiction.py @@ -67,6 +67,15 @@ def _statuses(recorded: str, observed: str) -> tuple[str, str]: ), # The recorded id dropped and another digit token in its place. (f"Smith John 15 {LABEL}", f"Smith John {LABEL} 16"), + # Review blocker 1: a second copy of the recorded value elsewhere in the + # band must not stand in for the sibling's differing id. + ("Smith John 15 47 15 patients found", "Smith John 16 47 15 patients found"), + ("Smith John 15 47 Page 1 of 15", "Smith John 16 47 Page 1 of 15"), + ("Smith John 3 47 Select 1 of 3 boxes", "Smith John 4 47 Select 1 of 3 boxes"), + (f"Smith John 15 15 {LABEL}", f"Smith John 16 15 {LABEL}"), + # Review should-fix: two values read glued are not one id. + ("Smith John 15 47 Active", "Smith John 1547 Active"), + ("Smith John 1547 Active", "Smith John 15 47 Active"), ] @@ -111,3 +120,22 @@ def test_the_digit_contradiction_is_charged_not_the_run_cap() -> None: ) def test_the_true_row_still_verifies(recorded: str, observed: str) -> None: assert _statuses(recorded, observed) == ("verified", "verified") + + +def test_param_mode_keeps_a_short_id_inside_the_example_value() -> None: + # Review blocker 2: "15" sits inside the date example "20150101"; the + # substring test dropped it from the band before matching. + recorded = "Smith John 15 47 2015-0l-01 Hint" + observed = "Smith John 16 47 2016-01-01 Hint" + kwargs = {"params": {"dob": "2016-01-01"}, "param_examples": {"dob": "2015-01-01"}} + plain = I.verify_target_identity(recorded, observed, **kwargs) + tmpl = build_identity_template(recorded, param_examples=kwargs["param_examples"]) + assert tmpl is not None + hashed = verify_template_identity(tmpl, observed, **kwargs) + assert plain.status != "verified" + assert hashed.status != "verified" + + +def test_a_repeated_value_still_verifies_the_true_row() -> None: + band = f"Smith John 25 25 {LABEL} 25 patients found" + assert _statuses(band, band) == ("verified", "verified") From 0ee3c860d135eddcf34ec875e5513eb11f8962e8 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 15:46:08 -0500 Subject: [PATCH 3/7] identity: a match that glues two digit fragments abstains One id split by OCR ("100" "512" vs "100512") and two values beside a record whose id is 1547 ("15" "47") read alike; such a split or join now matches but flags the fragments glyph-ambiguous, so the band abstains, as the 9th-reopening split tests require, instead of verifying. --- .../flow/openadapt_flow/runtime/identity.py | 21 ++++++++++++------- .../runtime/identity_template.py | 13 ++++++++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/flow/openadapt_flow/runtime/identity.py b/packages/flow/openadapt_flow/runtime/identity.py index d728391609..364a194c72 100644 --- a/packages/flow/openadapt_flow/runtime/identity.py +++ b/packages/flow/openadapt_flow/runtime/identity.py @@ -1096,6 +1096,10 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: # second copy of the recorded value elsewhere in the band ("15 patients # found") must not stand in for a sibling's differing id. digit_used: set[int] = set() + # Recorded tokens matched only by gluing two digit-bearing fragments + # ("100" "512" vs "100512", or "15" "47" vs "1547"): one id split by OCR + # and two values read as one look the same, so the band abstains. + digit_glued: set[int] = set() for i, ec in enumerate(exp_c): digit = _has_digit(exp[i]) for j, oc in enumerate(obs_c): @@ -1114,8 +1118,7 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: break if all(matched[i : i + size]): continue - if _glues_digits(exp[i : i + size]): - continue + glued = _glues_digits(exp[i : i + size]) concat_raw = "".join(exp[i : i + size]) concat_c = ocr_canonical(concat_raw) if len(concat_c) < MIN_BLOCK: @@ -1140,6 +1143,8 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: raw_matched[m] = True elif _suspicious_pair(concat_raw, obs[j]): suspect_evidence[m] = True + if glued: + digit_glued.add(m) explained[j] = True # join: one recorded token -> consecutive observed tokens @@ -1150,11 +1155,11 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: for size in (2, 3, 4): if j + size > len(obs): break - if _glues_digits(obs[j : j + size]): - continue concat_raw = "".join(obs[j : j + size]) if ocr_canonical(concat_raw) == exp_c[i]: mark(i, token, concat_raw) + if _glues_digits(obs[j : j + size]): + digit_glued.add(i) for m in range(j, j + size): explained[m] = True break @@ -1176,6 +1181,8 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: for i in range(len(exp)): if raw_matched[i] and _is_glyph_vulnerable_identifier(exp[i]): glyph_ambiguous_id[i] = True + if i in digit_glued: + glyph_ambiguous_id[i] = True return ( matched, explained, @@ -1284,9 +1291,9 @@ def _contradicted( def _glues_digits(fragments: list[str]) -> bool: - """Whether joining ``fragments`` would glue two digit-bearing tokens: - "15" + "47" is not OCR's reading of "1547" but two values, and a record - whose id is 1547 must not match them (nor the reverse).""" + """Whether joining ``fragments`` glues two digit-bearing tokens: OCR's + split of one id ("100" "512") and two values ("15" "47" beside a record + whose id is 1547) read alike, so such a match can only abstain.""" return any( _has_digit(a) and _has_digit(b) for a, b in zip(fragments, fragments[1:]) ) diff --git a/packages/flow/openadapt_flow/runtime/identity_template.py b/packages/flow/openadapt_flow/runtime/identity_template.py index e2703749ce..4debdc74f6 100644 --- a/packages/flow/openadapt_flow/runtime/identity_template.py +++ b/packages/flow/openadapt_flow/runtime/identity_template.py @@ -298,8 +298,6 @@ def build_identity_template( for size in (2, 3, 4): if i + size > len(squashed_tokens): break - if _id._glues_digits(squashed_tokens[i : i + size]): - continue concat_raw = "".join(squashed_tokens[i : i + size]) concat_c = _id.ocr_canonical(concat_raw) if len(concat_c) < _id.MIN_BLOCK: @@ -454,13 +452,18 @@ def mark(i: int, obs_raw_key: str, obs_squashed: str) -> None: break # split: consecutive recorded tokens -> one observed token + digit_glued: set[int] = set() for ct in tmpl.concats: if all(matched[ct.i : ct.i + ct.size]): continue + window = toks[ct.i : ct.i + ct.size] + glued = any(a.digit and b.digit for a, b in zip(window, window[1:])) for j in range(no): if obs_c[j] == ct.c: rawok = ct.r == obs_r[j] for m in range(ct.i, ct.i + ct.size): + if glued: + digit_glued.add(m) matched[m] = True if rawok: raw_matched[m] = True @@ -477,11 +480,11 @@ def mark(i: int, obs_raw_key: str, obs_squashed: str) -> None: for size in (2, 3, 4): if j + size > no: break - if _id._glues_digits(obs[j : j + size]): - continue concat_raw = "".join(obs[j : j + size]) if canon(concat_raw) == t.c: mark(i, raw(concat_raw), concat_raw) + if _id._glues_digits(obs[j : j + size]): + digit_glued.add(i) for m in range(j, j + size): explained[m] = True done = True @@ -493,6 +496,8 @@ def mark(i: int, obs_raw_key: str, obs_squashed: str) -> None: for i, t in enumerate(toks): if raw_matched[i] and t.glyph: glyph_ambiguous_id[i] = True + if i in digit_glued: + glyph_ambiguous_id[i] = True return matched, explained, raw_matched, suspect_evidence, glyph_ambiguous_id From 25e6f8c53abd4a79c49824116f61a029842682cc Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 15:55:56 -0500 Subject: [PATCH 4/7] identity: param mode drops a digit token only when it is the value itself Review blocker: the similarity fallback still dropped a PatNum near the example account number (547 vs 15470), so a sibling's differing PatNum contradicted nothing. A digit token now belongs to a parameter only when the value is inside it or it is the value read with OCR confusions (l5470). --- .../flow/openadapt_flow/runtime/identity.py | 10 +++++++--- .../test_identity_identifier_contradiction.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/flow/openadapt_flow/runtime/identity.py b/packages/flow/openadapt_flow/runtime/identity.py index 364a194c72..ca5b45aec6 100644 --- a/packages/flow/openadapt_flow/runtime/identity.py +++ b/packages/flow/openadapt_flow/runtime/identity.py @@ -1481,9 +1481,13 @@ def _token_belongs_to(token: str, value_squashed: str) -> bool: return False if value_squashed in token: return True - if token in value_squashed and not _has_digit(token): - # A digit token is never dropped for being INSIDE the value: "15" - # (a PatNum) sits inside "20150101" (a date) and is its own field. + if _has_digit(token): + # A digit token is never dropped for being inside the value or merely + # similar to it: "15" (a PatNum) sits inside "20150101" (a date) and + # "547" is near "15470"; each is its own field. Only the value itself, + # read with OCR's confusions ("l5470"), belongs to it. + return ocr_canonical(token) == ocr_canonical(value_squashed) + if token in value_squashed: return True ratio = difflib.SequenceMatcher(None, token, value_squashed, autojunk=False).ratio() # Same-token-with-OCR-jitter question ("Phi1" ~ "Phil"): the token diff --git a/packages/flow/tests/test_identity_identifier_contradiction.py b/packages/flow/tests/test_identity_identifier_contradiction.py index 7dbf818c54..d0015babc1 100644 --- a/packages/flow/tests/test_identity_identifier_contradiction.py +++ b/packages/flow/tests/test_identity_identifier_contradiction.py @@ -139,3 +139,22 @@ def test_param_mode_keeps_a_short_id_inside_the_example_value() -> None: def test_a_repeated_value_still_verifies_the_true_row() -> None: band = f"Smith John 25 25 {LABEL} 25 patients found" assert _statuses(band, band) == ("verified", "verified") + + +@pytest.mark.parametrize( + "recorded,observed", + [ + # Review blocker 3: a PatNum merely similar to the example account + # number was dropped from the band by the similarity fallback. + ("Price Mark 547 l5470", "Price Mark 548 99999"), + ("Price Mark 1547 l5470", "Price Mark 1548 99999"), + ], +) +def test_param_mode_keeps_a_digit_token_similar_to_the_example(recorded, observed): + kwargs = {"params": {"acct": "99999"}, "param_examples": {"acct": "15470"}} + plain = I.verify_target_identity(recorded, observed, **kwargs) + tmpl = build_identity_template(recorded, param_examples=kwargs["param_examples"]) + assert tmpl is not None + hashed = verify_template_identity(tmpl, observed, **kwargs) + assert plain.status != "verified" + assert hashed.status != "verified" From d9987a8b28a9317bcc500644becd966617a3002d Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 19:31:45 -0500 Subject: [PATCH 5/7] identity: exact digit copies first; an extra unexplained digit token abstains; a 2-letter name replaces a longer one --- .../flow/openadapt_flow/runtime/identity.py | 46 ++++++++++++++++--- .../runtime/identity_template.py | 36 +++++++++++---- .../test_identity_identifier_contradiction.py | 43 +++++++++++++++++ 3 files changed, 109 insertions(+), 16 deletions(-) diff --git a/packages/flow/openadapt_flow/runtime/identity.py b/packages/flow/openadapt_flow/runtime/identity.py index ca5b45aec6..70ff595db3 100644 --- a/packages/flow/openadapt_flow/runtime/identity.py +++ b/packages/flow/openadapt_flow/runtime/identity.py @@ -1100,8 +1100,22 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: # ("100" "512" vs "100512", or "15" "47" vs "1547"): one id split by OCR # and two values read as one look the same, so the band abstains. digit_glued: set[int] = set() + # Digit tokens take a raw-equal copy first: a word that only folds to + # the value ("Bo" ~ "80", "Li" ~ "11") must not take the slot the exact + # copy fills. + for i, token in enumerate(exp): + if not _has_digit(token): + continue + for j, o in enumerate(obs): + if o == token and j not in digit_used: + mark(i, token, o) + explained[j] = True + digit_used.add(j) + break for i, ec in enumerate(exp_c): digit = _has_digit(exp[i]) + if digit and matched[i]: + continue for j, oc in enumerate(obs_c): if ec != oc or (digit and j in digit_used): continue @@ -1227,8 +1241,11 @@ def _contradicted( exp_c = [ocr_canonical(t) for t in exp] obs_c = [ocr_canonical(t) for t in obs] contradicted = [False] * len(exp) + # A 2-letter observed name ("Eva" -> "Bo") replaces a recorded name as + # surely as a longer one; shorter recorded tokens are counted in + # band_match. unexplained_alpha = any( - not explained[j] and len(o) >= MIN_BLOCK and _alpha_dominated(o) + not explained[j] and len(o) >= 2 and _alpha_dominated(o) for j, o in enumerate(obs) ) obs_suffix_unexplained = any( @@ -1307,6 +1324,17 @@ def _digit_token_absent(exp: list[str], matched: list[bool]) -> bool: return any(not m and _has_digit(t) for t, m in zip(exp, matched)) +def _extra_digit_token(exp: list[str], obs: list[str], explained: list[bool]) -> bool: + """Whether the live band shows a digit-bearing token nothing recorded + explains while the recorded band shows one. A recorded id can match a + copy of its value elsewhere in the live band ("15 patients found") + while the record's own differing id sits unexplained, so the band + abstains.""" + return any(_has_digit(t) for t in exp) and any( + not explained[j] and _has_digit(o) for j, o in enumerate(obs) + ) + + def band_match( expected_text: str, observed_text: str, @@ -1457,12 +1485,16 @@ def band_match( # verify_target_identity reads a positive charge here as ABSTAIN (OCR # cannot certify), not verify and not mismatch. glyph_ambiguous_id_chars = glyph_id_chars - evidence_short = identity_evidence_shortfall( - [identity_kind(t) for t in exp], - matched, - raw_matched, - [_has_lookalike(t) for t in exp], - ) or _digit_token_absent(exp, matched) + evidence_short = ( + identity_evidence_shortfall( + [identity_kind(t) for t in exp], + matched, + raw_matched, + [_has_lookalike(t) for t in exp], + ) + or _digit_token_absent(exp, matched) + or _extra_digit_token(exp, obs, explained) + ) return BandMatch( matched_chars / total_chars, max(uncovered_runs, default=0), diff --git a/packages/flow/openadapt_flow/runtime/identity_template.py b/packages/flow/openadapt_flow/runtime/identity_template.py index 4debdc74f6..09be5c7b82 100644 --- a/packages/flow/openadapt_flow/runtime/identity_template.py +++ b/packages/flow/openadapt_flow/runtime/identity_template.py @@ -442,6 +442,17 @@ def mark(i: int, obs_raw_key: str, obs_squashed: str) -> None: # observed copy of its own (parity with identity._match_tokens). digit_used: set[int] = set() for i, t in enumerate(toks): + if not t.digit: + continue + for j in range(no): + if t.r == obs_r[j] and j not in digit_used: + mark(i, obs_r[j], obs[j]) + explained[j] = True + digit_used.add(j) + break + for i, t in enumerate(toks): + if t.digit and matched[i]: + continue for j in range(no): if t.c != obs_c[j] or (t.digit and j in digit_used): continue @@ -532,7 +543,7 @@ def _contradicted_template( toks = tmpl.tokens contradicted = [False] * len(toks) unexplained_alpha = any( - not explained[j] and len(o) >= _id.MIN_BLOCK and _id._alpha_dominated(o) + not explained[j] and len(o) >= 2 and _id._alpha_dominated(o) for j, o in enumerate(obs) ) unexplained_ident = any( @@ -648,14 +659,21 @@ def _band_match_template(tmpl: IdentityTemplate, observed_text: str) -> _id.Band if not explained[j] and _id._name_shaped(raw_tok, obs[j]) ) - evidence_short = _id.identity_evidence_shortfall( - [_template_identity_kind(t, canon) for t in toks], - matched, - raw_matched, - # No plaintext to inspect: every token counts as look-alike, the - # conservative side of identity_evidence_shortfall's rule. - [True] * len(toks), - ) or any(not m and t.digit for t, m in zip(toks, matched)) + evidence_short = ( + _id.identity_evidence_shortfall( + [_template_identity_kind(t, canon) for t in toks], + matched, + raw_matched, + # No plaintext to inspect: every token counts as look-alike, the + # conservative side of identity_evidence_shortfall's rule. + [True] * len(toks), + ) + or any(not m and t.digit for t, m in zip(toks, matched)) + or ( + any(t.digit for t in toks) + and any(not explained[j] and _id._has_digit(o) for j, o in enumerate(obs)) + ) + ) return _id.BandMatch( matched_chars / total_chars if total_chars else 0.0, diff --git a/packages/flow/tests/test_identity_identifier_contradiction.py b/packages/flow/tests/test_identity_identifier_contradiction.py index d0015babc1..31b5bf1044 100644 --- a/packages/flow/tests/test_identity_identifier_contradiction.py +++ b/packages/flow/tests/test_identity_identifier_contradiction.py @@ -158,3 +158,46 @@ def test_param_mode_keeps_a_digit_token_similar_to_the_example(recorded, observe hashed = verify_template_identity(tmpl, observed, **kwargs) assert plain.status != "verified" assert hashed.status != "verified" + + +@pytest.mark.parametrize( + "recorded,observed", + [ + # Review round 3, blocker 1: a copy of the recorded value the recorded + # band did not show stands in for the record's own differing value. + (f"Smith John 15 47 {LABEL}", f"Smith John 16 47 {LABEL} 15 patients found"), + ("Smith John 03/04/1980 Active", "Smith John 03/05/1980 Active 03/04/1980"), + ("Smith John 15 Active", "Smith John 16 15 Active"), + # Review round 3: a 3-letter name replaced by a 2-letter one. + ("Nguyen Eva 47 Recall Due", "Nguyen Bo 47 Recall Due"), + ("Nguyen Eva 47 Patient Chart Balance", "Nguyen Bo 47 Patient Chart Balance"), + ("Okafor Ann 05/14/1988 Active", "Okafor Bo 05/14/1988 Active"), + ("Hall Ali 03/04/1980 Active", "Hall Bo 03/04/1980 Active"), + ], +) +def test_review_round_3_wrong_records_refuse(recorded: str, observed: str) -> None: + plain, hashed = _statuses(recorded, observed) + assert plain != "verified", (recorded, observed) + assert hashed != "verified", (recorded, observed) + + +@pytest.mark.parametrize( + "recorded,observed", + [ + # Review round 3, blocker 2: a word that folds to a digit value + # ("Bo" ~ "80", "Li" ~ "11") must not take the exact copy's slot. + ("Price Bo 80 03/04/1980", "Price Bo 80 03/04/1980"), + ("Lee Li 11 03/04/1980 Active", "Lee Li 11 03/04/1980 Active"), + ], +) +def test_a_word_folding_to_a_value_never_turns_a_reread_into_a_mismatch( + recorded: str, observed: str +) -> None: + plain, hashed = _statuses(recorded, observed) + assert plain != "mismatch", recorded + assert hashed != "mismatch", recorded + + +def test_an_exact_digit_copy_is_taken_before_a_folded_word() -> None: + match = I.band_match("Smith John 25 47 Active", "Smith John 2S 25 47 Active") + assert match.contradicted_chars == 0 From c82c0c3fcb17a1f8649e54d544295a8d55f4eecc Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 20:28:48 -0500 Subject: [PATCH 6/7] identity: only a recorded value explains a live value; a replaced name token abstains --- .../flow/openadapt_flow/runtime/identity.py | 17 +++++++++++++++ .../runtime/identity_template.py | 8 +++++++ .../test_identity_identifier_contradiction.py | 21 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/packages/flow/openadapt_flow/runtime/identity.py b/packages/flow/openadapt_flow/runtime/identity.py index 70ff595db3..6d9c05c34c 100644 --- a/packages/flow/openadapt_flow/runtime/identity.py +++ b/packages/flow/openadapt_flow/runtime/identity.py @@ -1119,6 +1119,10 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: for j, oc in enumerate(obs_c): if ec != oc or (digit and j in digit_used): continue + if not digit and _has_digit(obs[j]): + # Only a recorded value explains a live value: a word that + # folds to one ("Bo" ~ "80") must not hide a differing id. + continue mark(i, exp[i], obs[j]) explained[j] = True if digit: @@ -1335,6 +1339,18 @@ def _extra_digit_token(exp: list[str], obs: list[str], explained: list[bool]) -> ) +def _name_token_replaced( + exp: list[str], obs: list[str], matched: list[bool], explained: list[bool] +) -> bool: + """Whether a recorded alphabetic token is unmatched while the live band + shows an alphabetic token nothing recorded explains. A replaced name of + any length ("Ann" -> "J", "al" -> "joan") may be OCR noise or another + record, so the band abstains.""" + return any(not m and _alpha_dominated(t) for t, m in zip(exp, matched)) and any( + not explained[j] and _alpha_dominated(o) for j, o in enumerate(obs) + ) + + def band_match( expected_text: str, observed_text: str, @@ -1494,6 +1510,7 @@ def band_match( ) or _digit_token_absent(exp, matched) or _extra_digit_token(exp, obs, explained) + or _name_token_replaced(exp, obs, matched, explained) ) return BandMatch( matched_chars / total_chars, diff --git a/packages/flow/openadapt_flow/runtime/identity_template.py b/packages/flow/openadapt_flow/runtime/identity_template.py index 09be5c7b82..893a85c910 100644 --- a/packages/flow/openadapt_flow/runtime/identity_template.py +++ b/packages/flow/openadapt_flow/runtime/identity_template.py @@ -456,6 +456,8 @@ def mark(i: int, obs_raw_key: str, obs_squashed: str) -> None: for j in range(no): if t.c != obs_c[j] or (t.digit and j in digit_used): continue + if not t.digit and _id._has_digit(obs[j]): + continue mark(i, obs_r[j], obs[j]) explained[j] = True if t.digit: @@ -673,6 +675,12 @@ def _band_match_template(tmpl: IdentityTemplate, observed_text: str) -> _id.Band any(t.digit for t in toks) and any(not explained[j] and _id._has_digit(o) for j, o in enumerate(obs)) ) + or ( + any(not m and t.alpha for t, m in zip(toks, matched)) + and any( + not explained[j] and _id._alpha_dominated(o) for j, o in enumerate(obs) + ) + ) ) return _id.BandMatch( diff --git a/packages/flow/tests/test_identity_identifier_contradiction.py b/packages/flow/tests/test_identity_identifier_contradiction.py index 31b5bf1044..34c41b2b4b 100644 --- a/packages/flow/tests/test_identity_identifier_contradiction.py +++ b/packages/flow/tests/test_identity_identifier_contradiction.py @@ -201,3 +201,24 @@ def test_a_word_folding_to_a_value_never_turns_a_reread_into_a_mismatch( def test_an_exact_digit_copy_is_taken_before_a_folded_word() -> None: match = I.band_match("Smith John 25 47 Active", "Smith John 2S 25 47 Active") assert match.contradicted_chars == 0 + + +@pytest.mark.parametrize( + "recorded,observed", + [ + # Review round 4, blocker 1: a recorded word folding to the live + # record's differing id ("Bo" ~ "80", "Io" ~ "10") explained it. + ("Price Bo 81 Active", "Price Bo 80 Active 81 found"), + ("Smith Io 15 Active", "Smith Io 10 Active 15"), + # Review round 4, blocker 2: a name replaced by a 1-letter token, or + # a short name by a longer lowercase one. + ("Okafor Ann 05/14/1988 Active", "Okafor J 05/14/1988 Active"), + ("Price Al 02/16/1963 76 Active", "Price J. 02/16/1963 76 Active"), + ("nguyen al 74 03/04/1980 pending", "nguyen joan 74 03/04/1980 pending"), + ("Nguyen Eva 47 Recall Due", "Nguyen J 47 Recall Due"), + ], +) +def test_review_round_4_wrong_records_refuse(recorded: str, observed: str) -> None: + plain, hashed = _statuses(recorded, observed) + assert plain != "verified", (recorded, observed) + assert hashed != "verified", (recorded, observed) From 43ff31046a0924808186a029b6fea107af4098e0 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 21:07:06 -0500 Subject: [PATCH 7/7] identity: a word still explains digit noise inside a word; only digit-shaped tokens need a recorded value --- packages/flow/openadapt_flow/runtime/identity.py | 3 ++- packages/flow/openadapt_flow/runtime/identity_template.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/flow/openadapt_flow/runtime/identity.py b/packages/flow/openadapt_flow/runtime/identity.py index 6d9c05c34c..ed4f680a71 100644 --- a/packages/flow/openadapt_flow/runtime/identity.py +++ b/packages/flow/openadapt_flow/runtime/identity.py @@ -1119,9 +1119,10 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: for j, oc in enumerate(obs_c): if ec != oc or (digit and j in digit_used): continue - if not digit and _has_digit(obs[j]): + if not digit and _has_digit(obs[j]) and not _alpha_dominated(obs[j]): # Only a recorded value explains a live value: a word that # folds to one ("Bo" ~ "80") must not hide a differing id. + # Digit noise inside a word ("5ample") is still the word. continue mark(i, exp[i], obs[j]) explained[j] = True diff --git a/packages/flow/openadapt_flow/runtime/identity_template.py b/packages/flow/openadapt_flow/runtime/identity_template.py index 893a85c910..a9c8c40420 100644 --- a/packages/flow/openadapt_flow/runtime/identity_template.py +++ b/packages/flow/openadapt_flow/runtime/identity_template.py @@ -456,7 +456,11 @@ def mark(i: int, obs_raw_key: str, obs_squashed: str) -> None: for j in range(no): if t.c != obs_c[j] or (t.digit and j in digit_used): continue - if not t.digit and _id._has_digit(obs[j]): + if ( + not t.digit + and _id._has_digit(obs[j]) + and not _id._alpha_dominated(obs[j]) + ): continue mark(i, obs_r[j], obs[j]) explained[j] = True