← Back to list

EXACT: An Open-Source Precision Clinical Trial Matcher Built on OMOP

Matching a cancer patient to a clinical trial sounds like it ought to be a solved problem. In reality, most matching systems are either (a)…

Adam Blum in CancerBot · 2026-04-24 12:44 · 0 claps · 10.2 min read
#clinical-trials #cancer #omop
Open on Medium ↗
Wiki topics: CLI · Clinical Medicine ONC · Oncology 🔓 · Open Source ⚖️ · Law & Justice

EXACT: An Open-Source Precision Clinical Trial Matcher Built on OMOP

Matching a cancer patient to a clinical trial sounds like it ought to be a solved problem. In reality, most matching systems are either (a) proprietary black boxes buried inside a sponsor’s CTMS, or (b) brittle keyword searches over ClinicalTrials.gov that miss the clinical nuance that determines whether a patient is actually eligible. EXACT — the open-source matcher maintained at github.com/cancerbot-org/exact — takes a different approach: it treats eligibility matching as a structured-data problem against a patient model that was designed, from the ground up, to be matchable.

This article walks through what EXACT does, the data substrate it sits on, and why the combination produces results that a keyword matcher can’t.

The core idea

EXACT is an eligibility-matching engine. You give it a patient (a structured clinical record) and a set of trials (structured eligibility criteria), and it returns which trials the patient qualifies for, which they don’t, and — critically — why.

The “why” is the part most matchers skip. A useful match result isn’t a ranked list; it’s a per-trial trace: this criterion passed because the patient’s ANC is 1.8 × 1⁰⁹/L and the threshold is ≥1.5; that criterion failed because the patient has had three prior lines of therapy and the trial caps at two; this other one is indeterminate because we don’t have a current ECOG score on file. Patients and navigators act on reasons, not scores.

To produce that kind of output, the matcher has to reason over the same vocabulary that the eligibility criteria use. That is where the rest of the stack comes in.

The patient model: CTOMOP

EXACT does not parse patient records. It is a stateless matching engine: it receives a structured patient profile — the same field set that the CTOMOP project defines — and the trial catalog it matches against, then returns verdicts. Nothing about the patient is persisted inside EXACT itself; the only local state it keeps is authentication. CTOMOP (Clinical Trial OMOP) is the patient database that produces those structured profiles. It’s built on the OMOP Common Data Model v6.0, extended with oncology-specific tables and a denormalized projection called PatientInfo that flattens the clinical picture into the 266 fields an eligibility engine actually needs.

The split matters. OMOP is excellent for storage, interoperability, and longitudinal analytics — conditions, drug exposures, measurements, observations, all coded against SNOMED / LOINC / RxNorm. But eligibility criteria aren’t written in OMOP. They’re written in statements like “ECOG performance status ≤ 2,” “no more than two prior lines of therapy,” “triple-negative breast cancer,” “absolute neutrophil count ≥ 1.5 × 1⁰⁹/L,” or “measurable disease per RECIST 1.1.” To evaluate those, you need both the raw coded events and derived clinical judgments that roll up from them.

CTOMOP does both. The underlying OMOP tables hold the ground truth: condition_occurrence, measurement, observation, drug_exposure, plus oncology extensions (omop_oncology.Episode, EpisodeEvent, AILineOfTherapySummary). The PatientInfo model exposes a flattened, eligibility-ready view of that ground truth, with a number of fields computed automatically on save:

  • **therapy_lines_count** — count of non-empty first / second / later therapy fields.
  • **prior_therapy** — categorical, in the exact vocabulary EXACT matches against: "None," "One line," "Two lines," or "More than two lines of therapy."
  • **treatment_refractory_status** — derived from the sequence of per-line outcomes: zero negative outcomes means "Not Refractory"; one means "Primary"; two means "Secondary"; three or more means "Multi-Refractory."
  • **relapse_count** — counts successful outcomes (CR / sCR / VGPR) followed by a new line of therapy, unless manually overridden.
  • **measurable_disease_imwg** — applies IMWG criteria on M-protein and free light chains to decide whether the patient has measurable disease by the myeloma standard.
  • **tp53_disruption** — true iff any entry in the patient's genetic_mutations list has gene = TP53 and interpretation = pathogenic.
  • **lymphocyte_doubling_time** — log-linear fit on serial absolute lymphocyte counts.
  • **bmi, `patient_age`** — unit-aware derivations from weight/height and date of birth respectively.

These aren’t cosmetic conveniences. Every one of them corresponds to a phrasing that appears in real oncology eligibility criteria. By computing them once, in the patient database, with explicit rules that clinicians can audit, EXACT gets to do simple field comparisons instead of re-deriving clinical judgments inside the matcher. The derivation logic lives in one place; the matcher stays boring (in the best possible way).

The eligibility substrate

On the other side of the match is a structured trials database — one row per trial, with columns for every eligibility attribute the matcher knows how to evaluate. Each column corresponds to a PatientInfo field or a derived one, so matching becomes a per-criterion comparison rather than a per-trial free-text read.

Concretely, the trials table carries columns for:

  • Cancer type and subtype — histology (e.g., IDC vs. ILC for breast), disease-specific constraints.
  • Receptor and biomarker status — ER, PR, HER2, HR, TNBC, PD-L1 (tumor cells, IC%, CPS), HRD. Most of these are tri-valued (positive / negative / unknown), because eligibility routinely requires “unknown” to be resolved before enrollment.
  • Staging — T, N, M, overall stage, and disease burden flags like measurable_disease_by_recist_status, bone_only_metastasis_status, metastatic_status.
  • Prior therapy — allowed / required / excluded lines, specific agent exposures (prior_exposure_flags), and categorical line counts in the EXACT vocabulary above.
  • Refractory / relapse state — required or excluded values of treatment_refractory_status and relapse_count.
  • Lab thresholds — hematology (ANC, platelets, hemoglobin), renal (creatinine, eGFR), hepatic (AST, ALT, bilirubin, albumin), electrolytes, coagulation, LDH, inflammation markers, cardiac markers. Each threshold is stored with its unit so UCUM conversions happen explicitly.
  • Genomic requirements — specific mutations or mutation classes (BRCA1/2, PIK3CA, ESR1, TP53 disruption, etc.), using the same structured mutation schema the patient model uses.
  • Performance and eligibility status — ECOG, Karnofsky, consent capability, cognitive status, reproductive safety.
  • Exclusions — concurrent malignancies, active infections (HBV / HCV / HIV status), washout requirements.
  • Administrative constraints — age range, geography (trial sites within a reachable radius), language.

The trials table is the schema-side mirror of PatientInfo. That symmetry is what makes the matcher tractable.

What the matcher actually does

Given that substrate, EXACT’s job decomposes into four things:

1. Attribute-level evaluation. For each (trial, criterion) pair, EXACT compares the trial’s stored requirement against the patient’s field. Numeric thresholds use unit-aware comparison (a threshold expressed in ×1⁰⁹/L matches a patient value stored as cells/µL). Categorical fields use the trial’s allowed-set semantics. Boolean flags get straightforward logic. Missing patient data produces an indeterminate result, never a silent pass.

2. Criterion-level composition. Real eligibility is rarely a single field. “ANC ≥ 1.5 AND platelets ≥ 100 AND no prior anti-PD-L1 exposure” is three attribute comparisons ANDed together; “measurable disease by RECIST OR bone-only metastatic disease with evaluable marker” is a disjunction over two derived flags. EXACT composes attribute results with explicit boolean logic, and propagates indeterminacy correctly — an AND with any fail is a fail, but an AND with a pass and an indeterminate is still indeterminate.

3. Trial-level verdict. EXACT assigns each (patient, trial) pair one of three states: Eligible if all inclusion criteria pass and no exclusion criterion triggers; Ineligible if any inclusion fails or any exclusion fires; Potential if the only thing blocking the verdict is missing data on a small number of criteria. The Potential state is the one that matters most in practice — it’s the matcher’s way of saying “you would qualify for this trial if you also had a recent ECOG score on file,” and it’s what turns a match engine into a useful data-collection prompt rather than a binary gate.

4. Explanation. Every verdict comes with the criterion-by-criterion trace that produced it. This is what makes EXACT auditable: a patient navigator can see exactly which criterion knocked the trial out, point at the underlying PatientInfo field, and decide whether it's wrong (data quality issue), stale (need a fresh lab), or correct (genuinely ineligible).

The vocabulary contract

One detail deserves its own mention. The categorical fields — prior_therapy, treatment_refractory_status, relapse states, receptor statuses — all use controlled vocabularies that are shared between the patient model (CTOMOP) and the trials model. The schema documentation explicitly calls these "EXACT & CB matcher vocabulary" values.

This is the boring infrastructure work that makes the whole thing go. Without it, you end up with patient records saying “2 prior lines” and trial records saying “≤2 prior lines of therapy” and a matcher desperately trying to bridge them with regex. With it, both sides store the same enum values, comparison is a dictionary lookup, and the code stays short.

FHIR as a boundary, not a pivot

EXACT matches against CTOMOP, not FHIR. But CTOMOP itself ingests from FHIR — the documented ETL pipeline validates, deduplicates, and lands FHIR resources into the OMOP tables that back PatientInfo. The schema spreadsheet carries the FHIR mapping for every field, which means a partner system can stream FHIR bundles in at the boundary, and EXACT can match against the resulting structured record without ever itself parsing a Bundle or traversing a Patient.telecom[system=email] path.

The separation of concerns is the point. FHIR is the interchange format. OMOP is the storage and analytics substrate. PatientInfo is the eligibility projection. EXACT is the matcher. Each layer does one job, so each layer stays debuggable.

Running EXACT

EXACT ships as a Django application that can run in two modes: as a REST API server, or as a one-shot batch matcher driven by a shell script. Both modes share the same matching core; they differ only in how the patient profile gets in and how the results come out.

Server mode. For interactive use, run EXACT as a web service. Clone the repo, install dependencies, migrate the local auth database, and point it at your trials catalog:

pip install -r requirements.txt
python manage.py migrate
export TRIALS_DATABASE_URL=postgresql://readonly:secret@trials-db.example.com:5432/trials
python manage.py runserver

In this mode, patient profiles are passed inline with each API request — nothing patient-related is persisted by EXACT. Authentication tokens are the only local state. The full REST reference lives in docs/api.md.

If no external trials database is configured, EXACT falls back to a single local database for everything, which is handy for development. Seed reference data with python manage.py seed_reference_data before running, and see docs/setup.md for environment variable details.

Batch mode. To match a batch of patients directly from the patient database against the trial catalog — no web server, no HTTP — use the trials4patients.sh script:

export TRIALS_DATABASE_URL=postgresql://...
export PATIENT_DATABASE_URL=postgresql://...
bash scripts/trials4patients.sh

The script reads configuration from environment variables (or a .env file in the project root, which is the recommended approach). The useful ones:

  • TRIALS_DATABASE_URL (required) — remote trials PostgreSQL database.
  • PATIENT_DATABASE_URL (required) — patient database (CTOMOP).
  • PERSON_IDS (default: all) — comma-separated CTOMOP person IDs to process.
  • PATIENT_LIMIT (default: all) — cap on number of patients.
  • SEARCH_LIMIT (default: 20) — top N trials returned per patient.
  • RESULTS_CSV — if set, writes an evaluator-ready CSV to this path.

The script always writes a full JSON results file to /tmp/exact_local_test_results.json. Set RESULTS_CSV to additionally get a flat CSV suitable for the evaluator described below. All options are documented in docs/trials4patients.md.

Evaluating results

EXACT ships with an evaluator that scores a run’s output against a ground-truth CSV — the mechanism by which you measure whether a change to the matcher made things better or worse. The evaluator is CSV-only; it needs no database connection.

CSV format. Both the ground truth and the EXACT results use the same four-column schema:

CTOMOP Patient ID,Trial,Eligible/Potential,Suitability Score
20291,NCT03452774,potential,81
20291,NCT07038785,eligible,79

The Eligible/Potential column is the verdict — either eligible or potential. Trials that EXACT considers ineligible are simply absent from the file; the CSV records only the positive-verdict trials, ranked. Rows for the same patient must appear consecutively, and the order within a patient determines each trial's rank (first row = rank 1).

End-to-end workflow. The typical evaluation run has three steps.

  1. Extract the patient cohort from the ground truth. Pull the distinct person IDs out of the ground-truth CSV so EXACT runs against exactly the population you have labels for:
PERSON_IDS=$(tail -n +2 scripts/evaluator/ground_truth.csv \   | cut -d',' -f1 | sort -u | tr '\n' ',' | sed 's/,$//')

2. Run the matcher and write an evaluator-compatible CSV. Reuse trials4patients.sh with RESULTS_CSV set:

PERSON_IDS="$PERSON_IDS" \ SEARCH_LIMIT=5 \ RESULTS_CSV=results.csv \ bash scripts/trials4patients.sh

3. Score the results. Pass both CSVs to the evaluator:

bash scripts/evaluator/evaluate.sh \   scripts/evaluator/ground_truth.csv \   results.cs

For deeper analysis, add --output /tmp/comparison.json to get the full per-trial breakdown as structured JSON.

Metrics. The evaluator computes the following per patient, then micro-averages across the cohort:

  • recall — fraction of expected trials that appear in EXACT's results.
  • precision — fraction of EXACT's results that are in the expected set.
  • f1 — harmonic mean of precision and recall.
  • type_match_rate — among found trials, the fraction where the eligible/potential verdict matches the ground truth exactly.
  • score_match_rate — fraction where the suitability score matches exactly.
  • score_mae — mean absolute error of suitability scores (found trials only).
  • score_bias — mean signed error; positive values mean EXACT scores higher than ground truth.
  • mrr — mean reciprocal rank of expected trials in the ranked result list.
  • avg_rank — average rank of expected trials.

mrr and avg_rank use a penalty_rank for trials that are expected but missing from the results. The evaluator infers top_n as the maximum number of rows any patient has in the results CSV, and sets penalty_rank = top_n + 1. The consequence is worth noting: a lower top_n makes the penalty harsher, because a trial ranked just outside the returned window is treated the same as one that EXACT doesn't return at all. Tuning SEARCH_LIMIT is therefore not just a performance knob — it changes the shape of the metric.

Diagnosing low recall. When recall drops, the cause is one of two things, and distinguishing them is straightforward. If the expected trial exists in the trials database but ranked below top_n, it's a ranking problem — raise SEARCH_LIMIT and re-run; the trial will appear. If the expected trial isn't in the trials database at all, it's a coverage gap — EXACT can't return what the catalog doesn't have. The repo documents the quick check:

python manage.py shell -c \
  "from trials.models import Trial; print(Trial.objects.filter(code='NCT03452774').exists())"

Keeping the two failure modes separate in your head is the difference between spending a week tuning the matcher when you actually need to reload the trial catalog.

Why this design holds up

Trial eligibility is unforgiving in a very specific way: a false positive means a patient is told they qualify for a trial they don’t, which wastes everyone’s time and erodes trust; a false negative means a patient never hears about a trial that could have helped. Both failure modes trace back to the same root cause, which is mushy data. Free-text eligibility, scraped keywords, inferred labs, uncontrolled vocabularies — any one of them is enough to make the output unreliable.

EXACT’s answer is to push the mushiness out of the matcher. The matcher only does comparisons. The clinical derivations happen in CTOMOP, with explicit rules. The vocabulary is controlled on both sides. The thresholds are unit-aware. The explanation is first-class. The whole system is open source, which means the derivations are inspectable by the clinicians whose judgments they encode.

For an oncology population where eligibility criteria run thirty lines deep and a single missing lab can flip the verdict, that discipline is the difference between a trial matcher that clinicians actually use and one that gets demoed twice and quietly retired.

EXACT and CTOMOP are maintained at github.com/cancerbot-org under open-source licenses. The patient schema described in this article — including FHIR and OMOP mappings for all 266 PatientInfo fields — is published alongside CTOMOP as the interoperability reference.


메타데이터
post_id
73bb6c6bf427
slug
exact-an-open-source-precision-clinical-trial-matcher-built-on-omop-73bb6c6bf427
url
https://blog.cancerbot.org/exact-an-open-source-precision-clinical-trial-matcher-built-on-omop-73bb6c6bf427
canonical_url
https://blog.cancerbot.org/exact-an-open-source-precision-clinical-trial-matcher-built-on-omop-73bb6c6bf427
author_url
https://medium.com/@adamsblum
status
ok
fetched_at
2026-06-10 13:37:17