Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f96fb83
duplicate check: ask the system of record for the patient (FHIR, REST…
abrichr Oct 24, 2026
78e33a4
duplicate check: nicknames, swapped names, prior names, contact tie-b…
abrichr Oct 24, 2026
6f9efcd
test: the record duplicate check's halt keeps the no_duplicate_candid…
abrichr Oct 24, 2026
9fce552
duplicate check: an initial matches its first name; plant production-…
abrichr Oct 25, 2026
7be163d
docs: duplicate check, the exact test-set size and misses by kind
abrichr Oct 25, 2026
09d0298
duplicate check: one source for every Replayer; a program can pin the…
abrichr Oct 25, 2026
2d2894d
duplicate check: review fixes, one resolution for every path, REST op…
abrichr Oct 25, 2026
a100398
duplicate check: the halted step carries a structured outcome
abrichr Oct 25, 2026
b464cd1
duplicate check: live OpenEMR probe and what it found
abrichr Oct 25, 2026
647af76
duplicate check: a program can choose FHIR beside an OpenEMR REST rec…
abrichr Oct 25, 2026
b69c1f8
duplicate check: per-item results out of the tree
abrichr Oct 25, 2026
aee3ad7
duplicate check: live FHIR rates harness (runs on the fixture lease)
abrichr Oct 25, 2026
4b635f2
CAPABILITIES: no duplicate-check rates until the live FHIR re-run
abrichr Oct 25, 2026
4434641
Merge origin/main into ti-1516
abrichr Nov 3, 2026
1025d09
Duplicate check docs: exact matches stop, close matches go to a perso…
abrichr Nov 3, 2026
9cd90ca
Duplicate check: zero dates, names as typed, FHIR cut-short pages, Op…
abrichr Nov 3, 2026
8ee678e
dup_check README: table compares stored name text
abrichr Nov 3, 2026
be53e92
classify: a record date that is no real date counts as none, whatever…
abrichr Nov 3, 2026
5d91b6d
Merge main into feat/dup-check-record-channel
abrichr Nov 4, 2026
f13cea2
Duplicate check: app knowledge lives in data, not in the engine
abrichr Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ improvising or retrying when something is off.
| Repair by default: once a repair agent is configured (outside a browser, with `repair.session`, [#1717](https://github.com/OpenAdaptAI/OpenAdapt/pull/1717)), every stop that wrote nothing goes to it without anyone starting it; a stop after a possible write goes to reconciliation; every stop is a row in a ledger with the funnel from stop to resumed item | On main ([#1360](https://github.com/OpenAdaptAI/OpenAdapt/pull/1360)) | Synthetic (MockMed, 22 drift modes, real Claude agent: 6 of 21 repairable stops resumed and verified, 0 wrong saves in 66 runs); routing over recorded OpenEMR stops; live openIMIS relabel: 3 of 3 reached repair and stopped with no wrong save; silent-saves 1.1.0 UI-change blocks: 18 of 28 items that always stopped now correct, 0 wrong | `run` (on by default, `--no-auto-repair`), `repair:` in `deploy.yaml`, [benchmark/auto_repair](packages/flow/benchmark/auto_repair/README.md) |
| Repair a relabeled save button: prove from the recorded identity hash that only the label changed (same control id, kind and form), re-qualify the fix on the new release, and have a person admit the certified copy afresh, since a save button's identity is part of the record check | On main | Real app (local OpenEMR, synthetic data): after the save button was relabeled, the stopped item re-ran and saved once after 8.3 operator minutes and 763 s of re-qualification; 896 s from stop to done ([#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513)) | `openadapt_flow/repair/relabel.py`, [SELF_REPAIR.md](packages/flow/docs/SELF_REPAIR.md#a-relabeled-control-proof-that-only-the-label-changed) |
| A duplicate check that reads the app's match list (`no_duplicate_candidate`): stop only on a listed record with the item's last name or date of birth, fail closed on an unreadable or unloaded list | On main | Real app (local OpenEMR, synthetic data): false stops per 100 items 70.4 before, 16.4 after; 0 silent wrongs in 30 saves ([#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513)) | [DUPLICATE_CHECK.md](packages/flow/docs/DUPLICATE_CHECK.md) |
| A duplicate check that asks the system of record (generic FHIR R4 `Patient` search; OpenEMR REST and SQL adapters): every page read, stop on an exact match, send a close match to a person, proceed otherwise, fail closed on any read problem. Close matching is heuristic and misses some duplicates; it is not patient identity resolution. One source for qualification, runs and resume; a program can pin it | On main | Synthetic: unit and HTTP tests; live OpenEMR 8.0.0.3 search decided 6 of 6 known referrals right through FHIR | [DUPLICATE_CHECK.md](packages/flow/docs/DUPLICATE_CHECK.md) |
| A switched-off `problems_only` gate asks a person before the save instead of stopping the item | On main | Synthetic; real app in [#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513) | [APPROVAL_MODES.md](packages/flow/docs/APPROVAL_MODES.md#when-the-mode-turns-itself-off) |
| Fleet drift patches: a verified fix with no patient data, shared across sites under policy, moved as files or through a local reference hub | Released, local reference hub | Simulator | `openadapt-flow fleet`, [FLEET_DRIFT_PATCHES.md](packages/flow/docs/FLEET_DRIFT_PATCHES.md) |

Expand Down
1 change: 1 addition & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ improvising or retrying when something is off.
| Repair by default: once a repair agent is configured (outside a browser, with `repair.session`, [#1717](https://github.com/OpenAdaptAI/OpenAdapt/pull/1717)), every stop that wrote nothing goes to it without anyone starting it; a stop after a possible write goes to reconciliation; every stop is a row in a ledger with the funnel from stop to resumed item | On main ([#1360](https://github.com/OpenAdaptAI/OpenAdapt/pull/1360)) | Synthetic (MockMed, 22 drift modes, real Claude agent: 6 of 21 repairable stops resumed and verified, 0 wrong saves in 66 runs); routing over recorded OpenEMR stops; live openIMIS relabel: 3 of 3 reached repair and stopped with no wrong save; silent-saves 1.1.0 UI-change blocks: 18 of 28 items that always stopped now correct, 0 wrong | `run` (on by default, `--no-auto-repair`), `repair:` in `deploy.yaml`, [benchmark/auto_repair](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/auto_repair/README.md) |
| Repair a relabeled save button: prove from the recorded identity hash that only the label changed (same control id, kind and form), re-qualify the fix on the new release, and have a person admit the certified copy afresh, since a save button's identity is part of the record check | On main | Real app (local OpenEMR, synthetic data): after the save button was relabeled, the stopped item re-ran and saved once after 8.3 operator minutes and 763 s of re-qualification; 896 s from stop to done ([#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513)) | `openadapt_flow/repair/relabel.py`, [SELF_REPAIR.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SELF_REPAIR.md#a-relabeled-control-proof-that-only-the-label-changed) |
| A duplicate check that reads the app's match list (`no_duplicate_candidate`): stop only on a listed record with the item's last name or date of birth, fail closed on an unreadable or unloaded list | On main | Real app (local OpenEMR, synthetic data): false stops per 100 items 70.4 before, 16.4 after; 0 silent wrongs in 30 saves ([#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513)) | [DUPLICATE_CHECK.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/DUPLICATE_CHECK.md) |
| A duplicate check that asks the system of record (generic FHIR R4 `Patient` search; OpenEMR REST and SQL adapters): every page read, stop on an exact match, send a close match to a person, proceed otherwise, fail closed on any read problem. Close matching is heuristic and misses some duplicates; it is not patient identity resolution. One source for qualification, runs and resume; a program can pin it | On main | Synthetic: unit and HTTP tests; live OpenEMR 8.0.0.3 search decided 6 of 6 known referrals right through FHIR | [DUPLICATE_CHECK.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/DUPLICATE_CHECK.md) |
| A switched-off `problems_only` gate asks a person before the save instead of stopping the item | On main | Synthetic; real app in [#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513) | [APPROVAL_MODES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/APPROVAL_MODES.md#when-the-mode-turns-itself-off) |
| Fleet drift patches: a verified fix with no patient data, shared across sites under policy, moved as files or through a local reference hub | Released, local reference hub | Simulator | `openadapt-flow fleet`, [FLEET_DRIFT_PATCHES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/FLEET_DRIFT_PATCHES.md) |

Expand Down
166 changes: 166 additions & 0 deletions packages/flow/benchmark/dup_check/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Duplicate check: screen against record, offline

In the production soak (#1513), the duplicate check was the largest operator
cost after approvals. It stops a new patient who shares a last name or date
of birth with an existing chart. This measures the check that asks the system
of record instead (`runtime/checks/duplicate_record.py`) against the screen
rule, on the same stream and the same patient table.

```bash
python -m benchmark.dup_check.measure --out DIR # per-item rows, kept out of the repo
```

The per-item rows of the run below are private, in openadapt-corpus
(`reliability_recipes/verification_coverage/dup_check_offline-2026-10-24/`);
this README keeps the aggregate tables. No app, no model calls. `measure.py` says exactly what each check sees. The
record check reads an in-memory table with FHIR search semantics
(`TableSource`): a case-insensitive prefix of the stored name text, as a
server compares it, and an exact birth date. (Re-measured on 2026-11-03 after
the search began sending names as typed as well as normalized; the numbers
below did not change.)

## The exact numbers

A miss is a second chart for a patient who already has one.

**Test set:** 500 items from the soak's seed-7 generator
(`lifecycle.workload.generate(7, 500, 10)`), with planted duplicates and extra
charts: 694 items, 440 new patients and 241 true duplicates.
- 47 duplicates come from the generator itself: 12 existing patients referred
again, 19 resends, 10 same people with a mistyped date of birth, and 6
registered by another clerk moments before.
- 194 are planted variants of existing patients.

The set grew in four rounds. The first report's "12 of 110" was round one;
every number here is on the full 241.

| | screen check | record check, first version | record check, now |
|---|---:|---:|---:|
| missed, of 241 | 42 | 90 | 24 |
| missed, of the 47 from the generator | 0 | 0 | 0 |
| false stops, of 440 new patients | 252 (36.3 per 100 items) | 2 (0.3) | 2 (0.3) |

The soak's own 55 items, with the same planting (249 items, 202 true
duplicates): screen 42 missed and 10 false stops; record 24 missed and 0
false stops.

**Every kind** (planted count; missed by the screen, by the first version of
the record check, and by the record check now):

| kind | planted | screen | v1 | now | what catches it now |
|---|---:|---:|---:|---:|---|
| from the generator (`dup_existing`, `dup_resend`, `ambiguous_same_person`, `concurrent_same`) | 47 | 0 | 0 | 0 | exact match, or same name with the date one field off |
| married name (`planted_married`) | 12 | 0 | 0 | 0 | same first name and date of birth |
| married name, new address (`pl_married_moved`) | 12 | 0 | 0 | 0 | same first name and date of birth |
| married name + mistyped day (`heldout_married_dob_typo`) | 12 | 12 | 12 | 0 | first name, a near date, a shared phone, street or email |
| hyphenated name (`planted_hyphenated`) | 12 | 0 | 0 | 0 | a shared surname part |
| second surname, "Garcia Lopez" for "Garcia" (`pl_second_surname`) | 6 | 0 | 0 | 0 | a shared surname part |
| day and month swapped (`planted_swapped_dob`) | 3 | 0 | 0 | 0 | same name, swapped date |
| same name, mistyped day (`pl_ambiguous_same_person`) | 12 | 0 | 0 | 0 | same name, date one field off |
| year digits transposed (`r2_year_transposed`) | 11 | 0 | 0 | 0 | same name, date one field off |
| typo in the first name (`heldout_first_typo`) | 12 | 0 | 0 | 0 | a similar name, same date |
| typo in the last name (`heldout_last_typo`) | 12 | 0 | 0 | 0 | a similar name, same date |
| typo in the last name + mistyped day (`rv_last_typo_dob_typo`) | 12 | 12 | 12 | 0 | same first name, similar last name, a near date |
| initial for the first name (`pl_initial`) | 12 | 0 | 12 | 0 | the initial rule |
| nickname, Bob for Robert (`r2_nickname`) | 6 | 0 | 6 | 0 | the nickname table |
| nickname + mistyped day (`rv_nickname_dob_typo`) | 6 | 0 | 6 | 0 | the nickname table, same last name, a near date |
| first and last name swapped (`r2_name_swap`) | 12 | 0 | 12 | 0 | the crossed names |
| both names misspelled, same contact (`pl_both_typos_same_contact`) | 12 | 0 | 0 | 0 | similar names, same date |
| prior name on the chart, moved, mistyped day (`r2_prior_name_moved_dob_typo`) | 6 | 6 | 6 | 0 | the chart's birth name |
| married name, moved, mistyped day (`limit_married_moved_dob_typo`) | 12 | 12 | 12 | **12** | known limit |
| new first and last name, moved, same day (`limit_new_name_moved_same_dob`) | 12 | 0 | 12 | **12** | known limit |

The rows add to 241. Every match the record check makes is `close` or
`exact`, and both stop the item for a person.

**Known limit: 24 misses, uncatchable without extra data.**
- 12 are a new last name with a new address, phone and email, and a mistyped
day of birth. They share only the first name with the chart.
- 12 are new first and last names with new contact details. They share only
the date of birth, as hundreds of patients do.

Catching them needs an MRN, an insurance member id, or a person. The screen
stops the second kind only because it stops every new patient who shares a
date of birth with anyone. Stopping on a shared date of birth alone would
bring back the screen's `shared_dob` false stops (33 here).

**The trade-off.**
- Misses fell from 90 (first version) to 24, and all 24 are the known limit.
- False stops stayed at 2 of 440 new patients. Both are `shared_dob` items
whose first name equals that of the chart born that day, which the
married-name rule sends to a person on purpose.
- One rule was narrowed along the way. "Another name, one shared contact
detail and a near date of birth" stopped two new patients, because the
generator's phone pool has only 100 numbers (`555-01xx`). It now needs two
shared contact details for a near date, or one for the same date. That
narrowing is tuned to this stream.

## Live OpenEMR (2026-10-24)

`live.py` on the pinned OpenEMR 8.0.0.3 fixture. It seeded 23 synthetic
charts, 13 of them born on one day, and probed both channels. Raw output
(`probe.json`, `decisions.json`) is private, in openadapt-corpus
`reliability_recipes/verification_coverage/live-2026-10-24/dup_check/`.

What OpenEMR does:

| | REST `/api/patient` | FHIR `Patient` |
|---|---|---|
| page size | `_limit` honored | `_count` honored |
| next page | `links.next`, but built on OpenEMR's configured site address, not the address the request went to; no total | no next link at all, and `Bundle.total` equals the rows returned, so `_count=5` silently returns 5 of 13 |
| with no page size | all 120 of 120 rows sharing a birth date | all 120 of 120 |
| `lname` / `family` | prefix match (`Hartwell` finds `Hartwell1`...) | prefix match |
| birth name | not exposed | a birth name isn't returned as an old name; searching it finds nothing |

The check's decisions on six referrals with known answers:
- exact;
- a married name;
- a nickname;
- a swapped day and month;
- a new patient sharing only the birth date;
- a namesake.

| channel | right | notes |
|---|---:|---|
| FHIR, as the check sends it (no `_count`) | 6 / 6 | |
| REST, 500 rows a page | 6 / 6 | |
| REST, 5 rows a page | 4 / 6 | the two that needed a second page were `unreadable`: the next link points to the site address, another origin, so the check refused it (fail closed) |

So:
- REST stays opt-in. Its paging only works where OpenEMR's site address is
the address the check reads.
- `FhirPatientSource` never sends `_count`. OpenEMR returned all rows without
it (up to 120 tested), and the total can't reveal a cap, so a larger result
set than that is untested.
- Prior names are only searchable through the SQL adapter.

## How the set was built

For every existing chart, `plant` inserts duplicates at seeded positions:
- round one: a married name, a hyphenated name and a swapped date, then the
held-out first-name typo, last-name typo, and married name plus a mistyped
day;
- round two (`r2_`): a crossed name, a transposed year, and nicknames and
prior names on extra charts;
- round three (`pl_`): the six cases production-lifecycle named;
- round four (`rv_`): the two the review reproduced.

Each item meets the existing and extra charts, every earlier item's right
outcome from the soak's truth, and another clerk's chart for
`concurrent_same`.

The rules for each round were written knowing that round's kinds, so only
round one's held-out kinds were a blind test. They are why the second round
exists.

## Limits

- Offline. The channel adapters' paging and fail-closed behavior is tested
against a real local HTTP server (`tests/test_duplicate_record_check.py`),
not against OpenEMR.
- The search for prior names assumes a FHIR `family` search also matches a
patient's old names. The SQL adapter does (`birth_lname`).
`patient_history`'s previous names are not read.
- One seed. The screen rule is `no_duplicate_candidate` applied to OpenEMR's
listing as the soak describes it: any chart sharing the first name, the
last name or the date of birth. It is not a run of the browser.
Empty file.
Loading
Loading