diff --git a/packages/flow/openadapt_flow/repair/self_repair.py b/packages/flow/openadapt_flow/repair/self_repair.py index 39812f57f..aea1b6c9e 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 67013360b..ed4f680a7 100644 --- a/packages/flow/openadapt_flow/runtime/identity.py +++ b/packages/flow/openadapt_flow/runtime/identity.py @@ -1091,12 +1091,44 @@ 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() + # 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() + # 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: - mark(i, exp[i], obs[j]) - explained[j] = True + if ec != oc or (digit and j in digit_used): + continue + 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 + if digit: + digit_used.add(j) + break # split: consecutive recorded tokens -> one observed token for i in range(len(exp)): @@ -1105,6 +1137,7 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: break if all(matched[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: @@ -1129,6 +1162,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 @@ -1142,6 +1177,8 @@ def mark(i: int, expected_raw: str, observed_raw: str) -> None: 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 @@ -1163,6 +1200,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, @@ -1198,24 +1237,42 @@ 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] 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( 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 +1312,46 @@ def _contradicted( return contradicted +def _glues_digits(fragments: list[str]) -> bool: + """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:]) + ) + + +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 _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 _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, @@ -1405,11 +1502,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], + 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) + or _name_token_replaced(exp, obs, matched, explained) ) return BandMatch( matched_chars / total_chars, @@ -1427,7 +1529,15 @@ 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 _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/openadapt_flow/runtime/identity_template.py b/packages/flow/openadapt_flow/runtime/identity_template.py index 99927e9b4..a9c8c4042 100644 --- a/packages/flow/openadapt_flow/runtime/identity_template.py +++ b/packages/flow/openadapt_flow/runtime/identity_template.py @@ -438,21 +438,49 @@ 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): + if not t.digit: + continue for j in range(no): - if t.c == obs_c[j]: + 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 + 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 + if t.digit: + digit_used.add(j) + 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 @@ -472,6 +500,8 @@ def mark(i: int, obs_raw_key: str, obs_squashed: str) -> None: 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 @@ -483,6 +513,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 @@ -517,7 +549,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( @@ -526,12 +558,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: @@ -624,13 +665,26 @@ 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), + 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)) + ) + 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 new file mode 100644 index 000000000..34c41b2b4 --- /dev/null +++ b/packages/flow/tests/test_identity_identifier_contradiction.py @@ -0,0 +1,224 @@ +"""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"), + # 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"), +] + + +@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") + + +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") + + +@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" + + +@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 + + +@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)