Given an indication thesis (asset + target + disease + biomarker population + endpoint), this engine answers one question: could this trial actually be enrolled, and how long would it take? — with every input traceable to a real ClinicalTrials.gov record. Many drug programs die not because the science is wrong but because nobody can recruit the patients; this simulates that risk before money is spent.
months = requiredN / (sites × velocity × max(prevalence × eligibility, 0.10) × 1/(1 + 0.08 × competitors))
| Term | Where it comes from |
|---|---|
| velocity | Median patients/site/enrolling-month across completed interventional precedent trials in the disease (ClinicalTrials.gov API v2, free, no key). Each precedent's registry window is corrected for its own primary-endpoint follow-up. |
| prevalence × eligibility | The biomarker's share of the disease population × the fraction passing inclusion/exclusion. Eligibility is estimated by Claude reading the actual I/E prose of precedent trials (median of 3 reads); the narrowing penalty is floored at 10% (sites pre-screen for rare markers), with the true cost surfaced as screensPerEnrollee. |
| competitors | Interventional trials actively recruiting the same population (era-correct in retrospective runs). Known limit: the penalty is uncalibrated for extremely crowded indications (~190 concurrent trials) and flags itself when it dominates. |
| requiredN | Textbook two-arm powering, floored by the indication's phase-3 precedent median — history says registrational programs don't run smaller. |
| sites | 75th percentile of site counts among precedents that enrolled at the required scale, clamped [40, 250]. |
When a design is too slow, a counterfactual search (bisection over the months-vs-prevalence curve) reports the smallest biomarker relaxation that reaches ≤18 months or <48 months — or states concretely that no relaxation works and what the binding constraint is.
Requires Bun. Claude-backed commands need ANTHROPIC_API_KEY.
bun install
cp .env.example .env # add ANTHROPIC_API_KEY
bun run typecheck && bun run check # no key needed
bun test # contract + interpretability tests, no key needed
# Every bundled thesis fixture against current registry evidence
bun run demo
# One fixture; optionally with a historical evidence horizon
bun run demo dupi-eoe
bun run demo dupi-eoe 2018-01-01
# Backtest: predictions vs. what completed trials actually did (no key needed)
bun run backtest
bun run backtest NCT03633617 NCT04394351
bun run backtest --condition "Eosinophilic Esophagitis" 10The station's wire contract lives in schemas/ (JSON Schema
2020-12, following the organization convention): requests validate against
input.schema.json, responses against
output.schema.json. Every example below is
committed under schemas/examples/ and produced by a
real run — the schemas are cross-checked against these instances, so they
describe reality, not intention.
bun run simulate <fixture-id | request.json> [--as-of YYYY-MM-DD] [--out file] [--raw]The boundary accepts the org dialect (snake_case, below) or the internal
camelCase shape (thesis.ts) and always answers in the org dialect;
--raw returns the engine-native RecruitabilityResult instead.
Only seven fields are required. Nothing else is ever inferred: no
uniprot_accession means the field stays null downstream, no as_of_date
means "evidence as of today".
{
"id": "my-thesis-001",
"asset": { "name": "anti-IL-13 antibody", "modality": "antibody" },
"target": { "symbol": "IL13", "direction": "block" },
"disease": { "name": "eosinophilic esophagitis" },
"biomarker_population": {
"marker": "peak eosinophil count >= 15/hpf",
"prevalence_in_disease": 0.85,
"assay_available": true
},
"endpoint": { "name": "histologic remission", "type": "binary" },
"mechanism": "IL-13 blockade reduces esophageal type-2 inflammation."
}Dupilumab in eosinophilic esophagitis with "as_of_date": "2018-01-01" —
the run may only use registry evidence that existed before 2018. The drug was
approved for this indication in 2022. Real output (abridged):
An IRAK4 inhibitor in seropositive RA. prevalence_in_disease: 0.62 is
SOURCED (doi:10.1002/acr.80009), not assumed — the cited record pack is in
fixtures/epi/. Real
output (abridged):
{
"input": { "id": "irak4-ra-sourced", "as_of_date": null, "…": "verbatim echo" },
"simulated_months_to_enroll": 398, // a design-failure signal, not a schedule
"score": 0,
"evidence": { "competing_trials": 189 }, // RA: the most crowded market in immunology
"screens_per_enrollee": 4,
"failed_precedents": [ { "nct_id": "NCT02390700", "why_stopped": "Business decision based on lack of enrollment…" } ],
"counterfactual": { // the close-the-loop feedback to the hypothesis stage
"achieves": "none",
"change": "No biomarker relaxation works: even all-comers (100%) predicts 247 months at 85 sites…",
"simulated_months_after": 247
},
"interpretability": { // the shared LABrador contract (see below)
"headline": { "result": "INFEASIBLE_TRIAL_DESIGN", "status": "SUPPORTED", "…": "…" }
}
}The registry itself agrees with the verdict — a real RA trial in the output stopped for "lack of enrollment".
| Key | What it is |
|---|---|
input |
Verbatim echo of the request — never inferred, never back-filled |
simulated_months_to_enroll, simulated_months_range |
The headline and its interquartile band. Named simulated on purpose — renaming them breaks the contract |
score |
0–1 recruitability (linear between 18 and 48 modeled months). Not probability of approval — mapping it there is a category error |
required_n, powering_basis |
Patients to enrol, and exactly how that number was derived |
precedent_median_n, phase3_median_n |
Reality checks from comparable completed trials (null when none exist) |
sites, sites_basis |
Site count and its provenance (input / precedent / default) |
screens_per_enrollee |
Where the low-prevalence cost lands — never hidden inside the months |
eligibility |
A model's read of real precedent I/E prose: multiplier, ranked screen-fail drivers, and the NCT ids it rests on (checkable) |
evidence |
Competing recruiting trials + the precedent NCT ids behind every anchor |
failed_precedents |
Terminated trials in the indication with registry-stated reasons |
counterfactual |
The smallest biomarker relaxation that rescues the design (good/feasible), or none + the binding constraint. Null when nothing needs rescuing |
waterfall_delta, why, as_of_date |
The thesis-scoreboard contribution, the one-paragraph derivation, and the echoed horizon |
interpretability |
The shared LABrador interpretability contract — headline verdict, ordered derivation steps, evidence/assumption links, uncertainty semantics, structured limitations. See the section below |
Every successful or domain-abstaining run carries a top-level
interpretability object conforming to the shared LABrador contract
(schemas/interpretability.schema.json,
required by the output schema — removing the block makes validation fail).
It exists so a UI can answer, without module-specific code: what was
concluded, why, on which evidence and assumptions, how each value was
derived, what uncertainty remains, and what would change the verdict. It is
built deterministically from the authoritative result — nothing is
recomputed, no second model.
headline.resultis the stable feasibility verdict, bucketed from the same 18/48-month thresholds that definescore:FEASIBLE(≤18 simulated months, score 1) ·FEASIBLE_WITH_RISK(18–48 months) ·INFEASIBLE_TRIAL_DESIGN(≥48 months, score 0 — the module abstains from blessing the design; the counterfactual, when one exists, names the smallest rescuing biomarker relaxation).headline.statusisQUALIFIEDinstead ofSUPPORTEDwhenever the run leaned on a fallback (assumed eligibility, fallback velocity) or landed in the with-risk band.stepsis the ordered derivation: precedent pool → velocity → eligibility → narrowing → screening → competition → sample size → powering → sites → effective velocity → enrolment duration → score → feasibility (→ counterfactual) → waterfall, each with method, formula, inputs, and result.evidencecarries one entry per NCT id behind the anchors (evidence.precedent.*,evidence.criteria.*,evidence.failed.*— the registry's stated stop reason is preserved as a verifiedquote), the two registry search descriptors, and any thesis-supplied sources (DOI/PMID URLs derived mechanically; the thesis's 0–1strengthrecodes to HIGH ≥0.7 / MODERATE ≥0.4 / LOW below).assumptionsdeclares the two run inputs that behave as assumptions (biomarker prevalence, the model-read eligibility multiplier — flaggedsyntheticwhen it was an assumed-neutral default) plus every model constant and threshold (competition share 0.08, narrowing floor 0.10, 18/48-month thresholds, dropout inflation 1.3, powering constant, clamps, page sizes, waterfall scale).uncertaintylabelssimulated_months_rangefor what it is: a velocity-percentile scenario band (fast = p75, slow = p25 of observed precedent velocities), not a confidence interval and not percentiles of a months distribution. No RNG, no Monte Carlo:seedanddrawsare null.limitationsare structured (code/severity/message/field_path): the always-on ones state that every number is simulated, that enrollment duration is not launch delay, and thatscoreis a heuristic, not a probability of approval; conditional ones flag fallbacks, unknown-as-null fields, the unsealed LLM horizon in retrospective runs, and uncalibrated competition in crowded indications.- Unknown values stay
nullwith an explaining limitation — never silently 0. IDs are stable machine names; everyevidence_id/assumption_id/metric_idreference resolves (enforced by tests inmanaged/trial-recruitment-forecaster/*.test.ts, runnable viabun test).
The committed examples above contain real generated blocks; regenerate with
bun run simulate <request.json> --out <result.json>.
The schema is strict (additionalProperties: false). A request with
"prevalence_in_disease": 62 (forgetting it's a fraction) fails validation
with 62 is greater than the maximum of 1 before any registry call is made;
a camelCase key inside an otherwise snake_case request fails as an
unexpected property rather than being silently ignored.
- Leak-free retrospective: with the evidence horizon rolled back to 2018-01-01 (registration dates, completion dates, competition era, and failed-precedent citations all horizon-filtered), the model scores dupilumab-in-eosinophilic-esophagitis 100/100 — a drug approved for that indication in 2022. The one open channel is documented: the model-based eligibility read cannot have its knowledge sealed to 2018.
- Backtest harness: predicted enrolling-months vs. reality for completed trials, with per-trial leak-free horizons. It has honestly rejected one proposed model improvement (√-dilution site scaling) when fresh data showed it helps one disease and harms another; the variants remain as labeled EXPERIMENT columns.
- The registry agrees on the failure mode: real trials in the fixture indication were terminated for "inability to complete enrollment due to difficulty in finding subjects" (NCT01458418) and "lack of recruitment" (NCT02881372) — precisely what this engine predicts.
managed/trial-recruitment-forecaster/
thesis.ts IndicationThesis — the input contract (zod)
ctgov.ts ClinicalTrials.gov API v2 client (asOf horizons, endpoint-window parsing)
recruitability.ts the engine (model above + counterfactual search)
boundary.ts org-dialect translation (request → thesis, result → 19-key output)
interpretability.ts deterministic builder for the shared interpretability block
interpretability-invariants.ts contract rules a JSON Schema can't express (IDs, refs, units)
*.test.ts bun:test suite: schema validation, reference integrity, bucketing (bun test)
demo.ts runnable demo over the fixtures
backtest.ts validation harness
fixtures/ 4 theses, one per failure mode, each with a whyInSet rationale
NEXT.md living state-of-play: model, results, ranked limitations
lib/ minimal runtime (Anthropic client + credential hygiene)
schemas/ input/output/interpretability JSON Schemas + committed real-run examples
Every number this engine emits is simulated, and the field names say so
(simulatedMonthsToEnroll, simulatedMonthsRange). Provenance fields
(poweringBasis, sitesBasis, why) state where each factor came from,
and the interpretability block restates the same warnings as structured
limitations (SIMULATED_NOT_OBSERVED, SCORE_IS_HEURISTIC,
ENROLLMENT_DURATION_NOT_LAUNCH_DELAY) so a UI can render them without
parsing prose. This output looks authoritative and is therefore easy to
misread as validated — keep the labels in any UI built on top of it.
Extracted from the LABrador hackathon workspace (GO Hackathon, Track A), where this engine is station 5 of a seven-station pipeline and where its cross-node adapters, glassbox tracing, and full development history live. Canonical pipeline development continues there; this repository is the standalone clinical-simulation component.
MIT — see LICENSE.
{ "input": { "id": "dupi-eoe", "as_of_date": "2018-01-01", "…": "verbatim echo" }, "simulated_months_to_enroll": 15, // vs the real phase 3: ~30 enrolling months at 2.4x the N "score": 1, // enrols comfortably — a green light, four years early "required_n": 88, // phase-3 precedent median floors the d=0.8 formula's 65 "sites": 40, "sites_basis": "precedent", "evidence": { "competing_trials": 20, "precedent_trials": ["NCT…", "…"] }, "failed_precedents": [ { "nct_id": "NCT01458418", "why_stopped": "Inability to complete enrollment…" } ], "counterfactual": null // nothing to fix — present only when the design needs rescue }