Repository navigation
Duplicate check asks the system of record (FHIR/REST, SQL fallback) instead of the screen list - #1516
Conversation
…; SQL fallback) no_duplicate_candidate stopped every new patient who shares a last name or a date of birth with any chart. When the deployment's effects read a FHIR server or OpenEMR's patient API, the check now searches the record channel (every page, fail closed) and stops only on an exact or close match. The on-screen list stays the fallback and runtime.duplicate_check: screen keeps it. Offline on the soak's seed-7 stream: false stops per 100 items 44.8 (screen) against 0.4 (record), the same 12 of 110 true duplicates missed by both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reakers The first version missed every married name with a mistyped day of birth; in each, the phone, street and email matched the chart. The record check now also searches by first name, phone and email, knows common nicknames, swapped first and last names and the chart's birth name, and uses shared contact details to tie a mistyped date of birth to a chart. Every new match stops for a person. Extended seed-7 stream (622 items, 169 true duplicates): misses 60 -> 24, all 24 a name change plus a move that shares nothing with the chart; false stops unchanged at 2 (0.3 per 100 items). Screen: 252 false stops, 30 misses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ate prefix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lifecycle's six cases All six cases production-lifecycle named stop as close matches: a married name with a new address, a mistyped day, a second surname, an initial, a crossed name, and both names misspelled with the same contact. The initial was the one gap. Extended stream (676 items, 223 true duplicates): false stops 2 (0.3 per 100 items), misses 24, all name change plus move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… record check The record source was set only in deployment.build_replayer, so qualification (qualify/campaign.py), the lifecycle proof's run, execute and resume built Replayers that silently used the on-screen list. Replayer now resolves the source itself from the effect verifier it is given (resolve_duplicate_source), so every path that reads the same system of record uses the same check. The postcondition's candidate_source: record pins the record check into the qualified program; without a channel the step fails closed instead of reading the screen. The soak's OpenEMR program change pins it. tests/test_duplicate_source_paths.py scans every Replayer(...) construction: none picks a source of its own, the governed paths pass their verifier, the same verifier gives the same source through build_replayer and directly, and a pinned program fails closed without a channel. RestPatientSource and SqlPatientSource are labeled OpenEMR adapters; FhirPatientSource generic R4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A missed duplicate costs far more than a false stop, so the duplicate rule is now chosen for the fewest calibration misses within an estimated false-stop budget (a knapsack: gain-first and gain-per-cost greedy, the better kept). New signals: a generic, model-made table of given-name variants (common names only; no record is sent), the medical record number as an exact match, and typos in both names in the error model. Adoption rule: a generated rule replaces a system's hand-built duplicate check only if it misses no more on the same calibration set (identity.adopt; generate-parts build --reference-duplicate-check). On #1516's stream it does not (it misses 45 or more of 604 calibration duplicates where the hand-built check misses 3), so the hand-built check is kept. The frontier, the reasons and every arm are in benchmark/generated_parts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An explicit switch-off from the gate, R2 or the engine is never classed down to an input problem. - A duplicate stop counts as an input problem only from the duplicate check's structured outcome (StepResult.duplicate_check == 'candidate', set by the record-channel check, #1516), never from the error text. An unreadable list or channel turns the switch off (record_check_lost); a duplicate stop that doesn't say is a program problem. - A re-arm's approver must be its key's registered name. - Only a re-arm that verifies ends an off period, so a forged event can't hide a switch-off or reset the count. - Trusting the registered approvers by default is documented as deliberate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Calibrating a matching rule on one site's few dozen records can't learn which near-matches are one person, so identity is now a reviewed template (patient-identity/1: #1516's record check, never generated) fed by a generated per-site mapping: which record fields feed each slot (given, family, prior last name, date of birth and its format, MRN, phone, email, street) and which task parameter feeds each input. Fields are chosen by grounding on the demonstrations, the slot's name and its value shape; FHIR coded lists are read by code (telecom[system=email].value). The mapping is verified against the demonstrations and a perturbation suite built from the site's own records, with one case per slot that only that slot can catch, and adopted only if it does no worse than the site's hand-written adapter (adopt_mapping; build --reference-identity). Back-test on #1516's people and stream, in three site shapes (#1516's rows, OpenEMR REST, FHIR): the generated mapping matched the hand-written adapters on the suite and the stream (24 of 223 missed, 2 false stops). A FHIR mapping read by first list item only (no email, no prior name) misses 30 suite duplicates against 5 and is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t-in CI: resolution no longer reads attributes a verifier double lacks (unit shards), the CAPABILITIES row states what was measured (copy test), and the public artifact inventory is regenerated (lint). Fail-closed reads: a REST body with any error field or no data list, a partial record birth date (now no date, so a name match is close), a record with no id, and a next-page link to another host are all unreadable instead of a silent none. One resolution: the deployment switch is gone; the program's candidate_source (part of the qualified program) and the run's own verifier decide on every path, and a qualify hook can't set the source. FHIR is the only automatic channel; OpenEMR's REST adapter needs candidate_source: record. candidate_record_fields maps a program's parameter names. The SQL adapter uses MySQL %s parameters and a bound wildcard. Recall: a nickname or a last-name typo with a mistyped date of birth now stops. Extended stream (694 items, 241 duplicates): misses 24 (all name change plus move), false stops 2 (0.3 per 100); screen 42 and 252. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StepResult.duplicate_check is set whenever no_duplicate_candidate fails: exact or close (a record matched) and candidate (a listed row matched) are about the item; unreadable (the record channel or the screen list could not answer, or a record-pinned check had no channel) is about the program. None otherwise and then omitted, so existing reports serialize unchanged. #1517 classes the problems_only switch from it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
benchmark/dup_check/live.py seeds synthetic charts on the pinned fixture and probes OpenEMR's REST and FHIR patient search. REST honors _limit and gives links.next, but on OpenEMR's configured site address, which the check refuses as another origin (fail closed): REST stays opt-in. FHIR gives no next link and a total equal to the rows returned; without _count it returned every row (120 tested). The check decided 6 of 6 referrals right through FHIR and REST at 500 a page. Raw output is private (openadapt-corpus#18). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ord check candidate_channel: fhir with a RestRecordVerifier on .../apis/default/api/patient searches OpenEMR's FHIR API on the same server (.../apis/default/fhir) with the record check's bearer token; rest uses only the REST adapter. Live, REST's next-page links carry OpenEMR's configured site address, so FHIR is the channel that answers completely there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The offline measurement's per-item rows (about 8.5k lines) move to the private openadapt-corpus; the README keeps the per-kind tables. measure.py now requires --out so it never writes into the repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e only) Qualification fault injection, consequential delivery, effect checks and read-back, the API tier, acting by kind, settling and confirming move verbatim into mixins (107 units; verify_moved_code finds no edits). replayer.py goes from 18,300 to 10,926 lines. Left in place because open pull requests change them: identity checks (#1518, #1504), _resolve_step (#1504), postconditions and step evidence (#1516, #1514), the starting-state settle and qualification boundary (#1449), and the run and step drivers. Two methods that name Replayer stay too. Two tests patched module globals the moved code now reads from its new module, and patch them there as well: resolve (scroll readiness is in replay.acting, interstitials in replay.settling) and _DIALOG_WAIT_S (replay.acting). Checked: strict mypy on runtime/core, runtime/replay and replayer.py; package-wide mypy; both import contracts; 3,149 tests across the 146 files that import the replayer plus the real-browser e2e files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s halts a pixel-resolved form field) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s halts a pixel-resolved form field) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…min/item, 0 silent wrongs; Phase 0 gate met) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n, heuristics miss some
…enEMR adapter A record date that is no real date (0000-00-00, 1980-02-30) counts as no date, so a name match is close. Name searches also send the name as typed, since channels compare the stored text. A full FHIR page with no next link is unreadable. OpenEMR's REST and SQL sources and the REST-verifier mapping move to runtime/checks/openemr_patient.py; the core is generic FHIR.
* Docs: scope claims that drifted from the code - Pipelined read-back: durable runs can defer their last step's check. - Production-lifecycle 3.17x: a cost-model ratio with an assumed 3-minute hand entry and the draft #1516, not an observed saving. - Define "no model calls": local OCR and the optional local grounding model still run on the machine. - Fleet patches move as files or through a local hub; CloudPatchFeed is a stub. - induce --held-out and the learn loop's held-out check are structural coverage, not behavioural correctness. * Docs: state model calls, fleet transport and the lifecycle ratio positively
Independent adversarial review (recorded, 2026-11-03)The reviewer was a separate agent with read-only access. It reviewed #1516 after the merge with current main. Round 1: block.
Round 2 (be53e92): approve. Nit: a FHIR server that silently caps a page below 200 rows with no next link is still undetectable. This is documented as a limit. Claims wording, as the founder asked: exact matches stop, close matches go to a person, and the heuristics miss some duplicates. This is not patient identity resolution. |
Conflict in ir.py: keep both the duplicate check's candidate fields and main's TEXT_PRESENT exact flag. The source-path tests now pass the FHIR resource and field map and the REST records key, which main requires.
The engine generality check refused runtime/checks/openemr_patient.py: an OpenEMR adapter in openadapt_flow. Each app's search is now a data file, app_profiles/record_search/<app>.yaml: the REST path marker, where its FHIR API sits beside the REST API, the REST rows key, next-page link, query parameters and field names, and the SQL table and columns. runtime/checks/app_sources.py follows that file. OpenEMR's file holds what the old module hard-coded, so the searches are the same. A REST record check is matched to the one app whose marker its records path contains; none or two matches give no source, so a program pinned to the record check fails closed as before. The SQL table and every column must be plain identifiers, and values stay bound.
|
From: Grok, To: coordinator — review of #1516 at f13cea2: CHANGES REQUESTED What holds: the source contract fails closed when the FHIR/REST/SQL source is unreadable, partial, cross-origin or paged past its limit. The record check runs on both the click and Confirm. The tests prove what they claim. Blocking: a likely duplicate gets through as "none" when the first names differ only by a second given name. The PR compares first names with similar_name, initials and nicknames, and never by the overlap of their parts. Last names do get part overlap. A throwaway probe calling check_duplicate with a one-record TableSource at this head, all with the same last name and date of birth:
Should fix: norm_name keeps only [a-z- ] after NFKD, so names written in Cyrillic, Greek, Arabic or CJK script normalize to "".
Minor: the phone match only reads phones whose use is unset or "home". A mobile-only match is ignored. Testing:
|
Independent adversarial review: BLOCKBLOCK remains at 61e58a0. The main merge leaves Grok's given-name blocker unchanged. The duplicate matcher and both new test modules are byte-identical to the reviewed f13cea2. I reconciled the current source with the earlier measured review; I did not rerun unchanged probes, the benchmark or a live server. The existing Unicode-normalization limitation also remains. This is a generic name-representation defect, with no per-app engine change requested. Held-out real-app evidence is not established by this review. Review by Codex. |
…ame and script A first name whose parts are all among the other's (Mary and Mary Ann, Maria Jose and Maria) is now a close match when the last name and date of birth agree; it was none. FHIR keeps given[1:] and REST/SQL read mname, so a chart's whole given name is compared. Names keep letters of every script, so different Cyrillic, Greek or CJK names no longer normalize to the same empty string, and Soundex applies only to Latin names. A referral phone matches any phone on the chart, not only home.
|
From: Codex BLOCK at A genuine local FHIR HTTP source returns a Patient whose prior HumanName has The original three reported misses now pass on head and fail semantically on the literal parent through the same supported TableSource API. All 32 Unicode and 4 phone controls pass. Whole author modules: head 92 pass / 276 phases, parent 79 pass / 237 phases. Independent matrix: head 62 pass / 1 semantic failure / 189 phases; parent 3 original semantic failures / 9 phases. No errors, skips or deselections. Ruff, formatting, Python3.10 syntax and scoped Mypy pass; collection, Item/JUnit, normal conftest and source/recorded-dependency origins reconcile. The new prior-FHIR case was not run on the parent; no new-regression attribution is claimed. No per-app engine code: yes. Fix the class: not yet; the prior FHIR representation discards an available component. Held-out real-app evidence: not established by these synthetic HTTP/SQLite checks. Own cloud run: 42555885718. Combined237 cases/711 phases,233pass/4semantic failures. |
A FHIR old or maiden HumanName kept only given[0], so a chart formerly Mary Ann Ashford was none for Ann Ashford while the table, REST and SQL channels said close. Every HumanName now keeps all its given parts, and a prior name is compared with the same first-and-middle rule as the current one.
|
From: Codex APPROVE The head passed 159 cases: both complete author modules (96) and the unchanged 63-case representation matrix. The literal parent passed 154 of 155 cases; its one failure reproduces the prior-name second-given-component FHIR miss. The normal birthdate query first returned the synthetic record, then the final duplicate decision incorrectly returned none. This is a semantic comparison through the supported API, not a missing-method or transport failure. All 314 cases and 942 setup/call/teardown phases reconcile with collected items, phase logs and JUnit. No errors, skips, deselections or timeouts occurred. I independently checked 313 loaded source/probe paths against the exact Git refs or recorded normalized probe. Required Ruff, formatting, Python 3.10 syntax lint and scoped Mypy checks passed. Public tracked source and all existing assertions remained intact. The matrix covers full, first and second given components in current and prior names across table, genuine local FHIR HTTP, configured REST HTTP and SQLite sources, with Unicode and phone controls. Evidence uses synthetic records; it does not establish real-app or held-out instance acceptance. No per-app engine code: yes. |
The CLI census requires allow_abbrev=False on every parser.
|
Retriggering CI on a fresh merge ref; the gitleaks failure predates #2122. |
|
Retriggering CI: a gitleaks scan of this PR's own commits (origin/main..head) finds nothing, so the failing run tested a stale merge ref. |
Stacked on #1513 (which is stacked on #1514). The base is
measure/production-lifecycle, so the diff shows only this change.In the production soak (#1513), the duplicate check was the largest operator cost after approvals.
no_duplicate_candidatereads OpenEMR's on-screen match list and stops any new patient who shares a last name or a date of birth with an existing chart.What changes
A check whose source is the record (
runtime/checks/duplicate_record.py; the S5 seam indocs/design/DECOMPOSITION.md). It has three channels:FhirPatientSource: generic FHIR R4Patientsearch;RestPatientSource: an OpenEMR adapter (/api/patient);SqlPatientSource: an OpenEMR adapter, the fallback (patient_data, MySQL%sparameters), wired by hand only.It searches:
It reads every page and decides:
exact: same first name, last name and date of birth, or the same MRNclose: the rules indocs/DUPLICATE_CHECK.md(name variants, nicknames, initials, prior names, near dates with a tie-breaker)noneunreadable: see belowThe check is
unreadablewhen:datalist;A record date that is only a year or a year and month counts as no date, so a name match is
close.One source for every path.
Replayerresolves the source itself, from its own effect verifier and the program's pin (resolve_duplicate_source). So qualification (qualify/campaign.py), a run (build_replayer), and the lifecycle proof's run, execute and resume all use the same check:replayer_kwargshook that tries is refused;records_path, gives no source instead of an error.There is no deployment-level switch any more, so nothing outside the program can make one path differ.
The program decides. The postcondition field
candidate_sourceis part of the qualified program:record: FHIR or OpenEMR REST. A run without the channel fails closed, never the screen.screen: the on-screen list.So REST is opt-in: only a program pinned to
recorduses it, and FHIR is the only automatic channel. The soak's OpenEMR program change pinsrecord.candidate_record_fieldsmaps a program's own parameter names; the default is OpenEMR's.The stop reason reads
failed: no_duplicate_candidate (record check via <channel>: <decision>, ...).unreadableis a program or channel problem, andexactandcloseare about the item; I've told #1517's author the exact form.The exact numbers (offline)
A miss is a second chart for a patient who already has one.
Test set: the soak's seed-7 generator, 500 items with planted duplicates and extra charts. That makes 694 items, 440 new patients and 241 true duplicates: 47 from the generator, 194 planted.
Known limit: the record check's 24 misses, uncatchable without extra data:
Catching them needs an MRN, an insurance id or a person.
The README (
benchmark/dup_check/README.md) has every kind: how many were planted, the misses by check, and the rule that catches it. It also covers the two cases the review reproduced, a nickname plus a date typo and a last-name typo plus a date typo. Both now stop, and the screen misses the second. The planted kinds for rounds two to four were written alongside their rules, so only round one's held-out kinds were a blind test.Limits
benchmark/dup_check/live.py; raw output private, OpenAdaptAI/openadapt-corpus#18):Bundle.totalequals the rows returned, so_counttruncates silently. The check sends no_count, and OpenEMR returned all 120 of 120 rows. A larger set is untested._limit,links.next), but the links carry OpenEMR's configured site address. At 5 a page, the check refused the other-origin link and answeredunreadable(fail closed). That's why REST stays opt-in.StepResult.duplicate_check(exact,close,candidate,unreadable) is set on the halted step whenever the check fails, for problems_only switch policy: input problems leave it on, a person re-arms with one signed tap #1517's switch policy; omitted when unset.Tests
test_duplicate_record_check.py(52) andtest_duplicate_source_paths.py(11).test_no_duplicate_candidate.py, and the deployment, approval, profile, run-gate, qualification and lifecycle tests that build a replayer.test_shipped_only_copy.py).ruff check,ruff format --checkand both mypy gates report nothing in the changed files.🤖 Generated with Claude Code