Arbiter AI

Open reference implementation

Clinical document AI you can audit.

Arbiter AI is an open reference implementation of HIPAA-safe document AI: de-identification before any model call, cited and verified answers, database-enforced patient isolation, and an append-only audit trail — with the evaluation harness that proves each of those properties holds.

Built and maintained by one engineer. Every number on this page comes from eval/run_eval.py or eval/run_extraction_eval.py, run on synthetic records. Nothing here has touched real PHI.

A real trace from the eval harnesssynthetic record P00001
Ingest1 scanned page
  1. Document received
    tenant resolved from the API key, job queued on Redis Streams
    gateway/main.py
  2. Text extracted
    route: ocr (no text layer), Tesseract
    worker/extract.py
  3. PHI removed
    9 placeholders, map encrypted per tenant
    worker/deid.py
  4. Clinical facts extracted
    12 facts, 39 ms
    worker/annotate.py
  5. Chunk embedded
    384-dim, chunk 128
    worker/store.py
  6. Audit written
    job.ingest.completed · wall time 2.1 s
    audit_log
Querymost recent HbA1c?
  1. Question de-identified
    same recognizers as the document
    worker/deid.py
  2. Retrieval scoped
    to (tenant, patient), vector + BM25
    worker/retrieval.py
  3. Answer drafted
    local 7B model, citation [128]
    worker/llm.py
  4. Validation ok
    cites_chunks true · faithfulness 1.0 · phi_leak false
    worker/graph.py
  5. Re-identified
    for the caller only, only the tokens the answer uses
    worker/reid.py
  6. Audit written
    job.query.completed · 3.6 s median
    audit_log
tenant: synthetic-test-clinicmodel: qwen2.5:7b-instruct, local

Four properties

Each one is enforced in code and checked by a test or an eval metric.

Every path below is a link into the repository.

Zero PHI reaches a model unmasked

Every node that calls a model or writes to the index asserts that the state has been de-identified first. Presidio produces the placeholders, with recognizers I had to add for labelled record numbers, phone numbers with extensions, clinician names after a title, and street addresses.

Enforced by
the deidentified assertions in worker/graph.py (annotate, chunk_embed, generate nodes) and Presidio in worker/deid.py
Tested by
tests/test_deid.py and the de-id recall eval in eval/run_eval.py

Per-patient retrieval boundary, enforced in the database

The tenant comes from the API key, never from the request. Row-level security filters every PHI table on it, and retrieval adds a hard patient filter on top, so isolation does not depend on application code being bug-free.

Enforced by
the tenant_isolation row-level-security policies in db/init.sql plus the WHERE tenant_id=… AND patient_id=… filter in worker/retrieval.py
Tested by
tests/test_retrieval.py::test_query_is_hard_filtered_by_tenant_and_patient and the cross-tenant probes (cross_patient_leaks in the eval, 0 in every run)

Every answer carries citations to the source page

An answer must cite a retrieved chunk by id, a judge must score it at or above 0.7 against those chunks, and a second de-identification pass must find nothing. Fail any of the three and no answer is returned.

Enforced by
the validation node in worker/graph.py (cites_chunks, grounded, phi_leak) and the judge in worker/llm.py
Tested by
the eval's queries_failed_validation; the judge prompt measured on qwen2.5:7b at 1.0 for a supported answer and 0.0 for an unsupported one

Append-only audit log

Every ingest, query and fact read writes an audit_log row with the credential, the action and the patient. The application role can insert rows and read them, and nothing else, so there is no code path that could edit history.

Enforced by
REVOKE UPDATE, DELETE ON audit_log FROM app_rw in db/init.sql
Tested by
UPDATE and DELETE on audit_log as app_rw return permission denied

What it demonstrates

Six things a clinical document pipeline has to do, each covered end to end on synthetic records.

Cited answers over discharge summaries

The corpus is one-page discharge summaries. The gold questions ask for the most recent HbA1c and the medication list, and an answer counts only if it cites the chunk it came from and clears the faithfulness judge. 38 of 40 in the last run; both misses are Tesseract misreads the model copied faithfully.

Scanned pages, routed honestly

Half the records are rasterized, tilted and blurred, with no text layer. Those pages go to Tesseract, and when the router wants a vision model that is not configured, the job records vlm_wanted_pages instead of pretending the page was easy.

Structured facts with assertion

Problems, medications with dose and frequency, labs, vitals and allergies, extracted by rules, Med7, bc5cdr and medspacy ConText. "Denies pneumonia" is stored as absent and a family-history line as family, so a problem list never lists what the patient does not have.

Evidence with a page citation

Every fact links to the chunk with the largest span overlap, and GET /v1/patients/{external_id}/facts returns each one with its chunk and page. That is the primitive an evidence packet or a chart-review tool is built from.

Isolation you can probe

The dev database holds two tenants; the second exists only to prove the first cannot see it. Key B gets 404 on tenant A's job, an app_rw session set to A sees zero of B's rows, and a cross-tenant INSERT violates the policy.

Two endpoints and a job poll

POST /v1/documents, POST /v1/queries, GET /v1/jobs/{id}. Idempotency keys make retries safe, and every job records its tokens and cost by model tier, judge calls included.

Benchmarks

The numbers, with the dataset named on every row.

Everything below is measured on synthetic records generated by scripts/make_synthetic_docs.py. The i2b2 row is the one that matters, and it stays empty until I hold credentialed access.

MetricDatasetValueNotes
De-identification recall, whole stringSynthetic 20 records0.967 → 1.000four iterations in a day
De-identification recall, strict per componentSynthetic 20 records≈0.81 → 1.000the metric that exposed surviving ZIP codes
ZIP codes surviving de-identificationSynthetic 20 records14/20 → 0/20
Answer accuracy, strictSynthetic 40 gold questions0.575 → 0.975
Cross-patient leaksSynthetic every run0
Clinical extraction F1: problems (assertion required) / medications (name, dose, frequency) / labsSynthetic 20 records1.00 / 1.00 / 0.99
De-identification recalli2b2 2014 test set (real, credentialed)not yet runthe number that matters; pending credentialed access

On the regenerated corpus that includes OCR'd scans, whole-string recall was 0.992 until 2026-09-05: one medical record number survived because Tesseract read its 'MRN:' label as 'MAN:'. The MRN recognizer now tolerates one OCR error in the label, and the same corpus scores 1.000 whole-string and 1.000 strict (20 records, 40 questions, accuracy 0.975, 0 leaks).

Before → after spans the four eval iterations of 2026-09-03; the repository's changelog records every run. How to reproduce them.

Failure modes I found

Bugs the eval harness caught, written up with the numbers.

Each post leads with the bug and the number, then the fix, then what to check next time.

Author

Yoonbo Cho

I built Arbiter to have a public, checkable answer to what a correct clinical document pipeline looks like.