Health Agent Environment
Generation and Evaluation in One Pipeline
Generate synthetic patients and clinical scenarios, then evaluate health agents against code-derived ground truth.
English · 简体中文 | Live demo · Packs · Patients · Quick start · How it works · Scoring · Results · Your agent · Skills · Cite
The whole pipeline is public: the world that renders the patients, the generators that write the questions, and the judges. The canary string (
CANARY.md) marks this pack's text so its presence in a corpus can be detected; training on the public sample is permitted; the official board uses private seeds.
One synthetic patient. Left of T is the prompt; right of T
is hidden and used for grading. Adherence falls before T, and the weight regain it drives
begins after T at the latent reversal point.
What it measures
HAEnv (Health Agent Environment) lives in the mirobody-env repository and installs as the haenv
Python package and command-line tool.
HAEnv grades clinical decisions on a patient's record over time. A synthetic patient's record
grows over months; the agent sees it up to an index time T and makes one decision. The world
that rendered the record fixes the right answer before the record is written, and the answer is
hidden from the agent.
HAEnv generates the patients, sets their difficulty and derives their gold standard. To run an existing system against published health benchmarks such as ESL-Bench, use mirobody-eval.
The four packs
The benchmark is four task packs. Each item asks for one core decision.
| Pack | Decision at T |
Answer | Judged by |
|---|---|---|---|
| ① Differential diagnosis | which conditions explain the record, when a second condition hides behind findings the first one explains; whether a clinician should review | a differential, the tests to order, a review flag | code against the world's truth; one LLM judge matches the free-text diagnoses and tests to the gold |
| ② Acute triage | where the patient should go now | ed_now · within_24h · routine_followup · watchful_waiting |
code, against the world's truth |
| ③ Chronic medication adjustment | what to do with the drug at this follow-up visit | uptitrate · downtitrate · maintain · switch · check_adherence_or_adverse_effect |
code, against the dose line, refills and the control target the world records |
| ④ Follow-up interpretation | whether the change since the last result is real | true_change · analytic_biological_noise · preanalytical · method_difference, plus the size of the true change |
code, against the world's true values and its per-reading perturbation records |
The two diagnosis tracks of the 1.x board, ddx-timeline and ddx-workup, stay in the
repository as bridge tracks: pack ① carries bridge items drawn from them, and they are the only
link between the 1.x board and the packs.
- Gold standard first. Disease, drug response, adherence, true lab values and measurement noise are set before the course is rendered, and each pack's gold is derived from them by code.
- Coherent patients. Weight, laboratory values, medication, wearable streams and life events are rendered by one set of rules on one timeline. A known comorbidity enters the world tables, so in packs ③ and ④ it changes the right answer where it should.
- Records read like records. Complaints are in the patient's own words, and the record lists the patient's known conditions with the medication for each.
- Difficulty as a parameter. Measurement artifacts, distractor events and the timing of the clinical turn are settings in the job file.
- Safety failures are not averaged away. Hard gates, among them an unauthorised medication change, fabricated evidence, a missed emergency and premature closure, zero the item whatever else scored well. Two propensity gates, over-triage and a missed clinician review, are graded and reported but do not enter the multiplier.
| Term | Meaning |
|---|---|
index time T |
the cut: the prompt holds the record up to T; grading uses what follows or what the world fixed |
| latent variables | the hidden state the world fixes before rendering: disease, drug response, adherence, true values, noise |
| pack | one task's question set, built from a job file by its generator and checked by the pack audit |
| seed | orders which cases and answer classes a pack draws; a pack records only the seed's sha256 |
| emission gate | the checks a generated case must pass to be released: premise check, per-item verification, leak probe |
| hard gate | a safety failure that zeroes its scoring unit |
| batch | one run directory: its cases, answers, scores and fingerprints |
Packs are pipelines
A pack is not a fixed file. You choose its size and seed, and one command writes the job, builds the pack with the deterministic generator and runs the generation-time audit that every pack shares. It makes no model call:
uv run --with scikit-learn --with joblib python tools/make_pack.py --pack p4 --n 50 --out packs/p4
--pack is m2 (①), pack2 (②), p3 (③) or p4 (④); --n is the item count (50, 100, …);
--seed (or HAENV_PACK_SEED) sets the seed. The pack is the same for the same pack, size and
seed. The job carries a header pack: {n_items, seed_sha256}, and batch.json records the size,
the seed's hash and the job, never the seed. Class shares follow each pack's proportion table at
every size.
| Pack | Job file | Items at N = 50 | Answer classes at N = 50 |
|---|---|---|---|
| ① Differential diagnosis | inputs/m2-pack1.job.yaml |
50 | 34 new items (24 with a hidden condition, 10 without) and 16 bridge items |
| ② Acute triage | inputs/pack2-triage.job.yaml |
50 | ed_now 13 · within_24h 13 · routine_followup 12 · watchful_waiting 12 |
| ③ Chronic medication adjustment | inputs/p3-meds.job.yaml |
50 | maintain 12 · downtitrate 12 · check_adherence_or_adverse_effect 12 · uptitrate 8 · switch 6 |
| ④ Follow-up interpretation | inputs/p4-followup.job.yaml |
50 | true_change 13 · analytic_biological_noise 13 · preanalytical 12 · method_difference 12 |
- Public sample packs. The shipped job files use the public seed; building them reproduces the public sample packs.
- Official boards use a private seed. The board's batch records only the seed's hash, so the board's items cannot be rebuilt from the repository. When a board is replaced, its seed is published and the retired board becomes reproducible.
- The audit is a release gate.
tools/pack_audit.pychecks the built batch: every cell the job planned is present, gold re-derived from the records equals the stored gold, and question-blind stubs and surface features do not predict the answer.haenv runrefuses a pack's items without a passing audit.
The bridge tracks and two smaller example tasks ship as plain job files:
| Task | Job file | Case specifications | Format |
|---|---|---|---|
| Differential diagnosis at several time points (bridge) | inputs/ddx-timeline.job.yaml |
145 | questions at several time points |
| Budgeted test ordering (bridge) | inputs/ddx-workup.job.yaml |
145 | the agent orders tests against a budget |
| Weight-regain forecast and driver attribution | inputs/early_warning-20.job.yaml |
20 | single question at T |
| Multi-round follow-up review | inputs/tracking_review-20.job.yaml |
20 | rounds; the agent may revise |
A specification becomes a case only if it passes the emission gate. The question packs, their case counts and the known gaps are in the data card.
One pipeline, several tasks. A task is a gold-standard shape plus an answer contract. The same
world renders every pack's patients, the same emission gate checks them item by item, and each pack
registers its gold and its scoring as a judge group. Adding a task type of your own is a package
outside this repository, not a change to it: Evaluate your own agent and
docs/design/external-task-contract.md.
Language. Prompts and case content are in Chinese: the instruction block and the free-text fields of the record (complaints, context, events). Field names, stream names, enumerated answer values and identifiers are in English.
Synthetic patients
The live demo renders a patient in the browser; its knobs change the course, the labs and adherence.
(a) Two specifications, 60 case ids each. Every patient draws an individual
response to the drug inside the band its declared driver allows (shaded). (b) Over the first 90
days, maintainers' fasting glucose and HbA1c fall together; low responders barely move on either.
(c) The 12 of the 20 early_warning-20 specifications that pass the emission gate, each
aligned on its own T: regain and maintenance mostly separate after T, so a
forecast at T must rest on earlier signals.
The same patient at three difficulty settings. The underlying series is
identical (grey dots mark the default where it differs), and outcome and driver do not change. Each
transient spike is recorded in the gold standard as a trap: in the multi-round format an agent whose
risk turns high at the artifact loses reversal-tracking credit, and in single-question formats
escalating on an artifact reading without marking it suspect trips the
acted_on_unverified_signal gate, which is reported without zeroing the case. The high
distractor level adds unrelated symptoms in the same text format as real ones; its declared symptom
rate is raised to 0.5 to match, otherwise the gate refuses the case.
Quick start
Generate, verify, answer and score offline. No API keys, no cost.
git clone https://github.com/thetahealth/mirobody-env && cd mirobody-env
uv run haenv build inputs/example-ew.job.yaml --gen deterministic --fresh # generate patients and cases
uv run haenv verify inputs/example-ew.job.yaml --gen deterministic # verify every item
uv run haenv run inputs/example-ew.job.yaml --offline # answer with offline reference solvers
uv run haenv report inputs/example-ew.job.yaml --offline # score and write the report
The four commands in a fresh clone, replayed from their recorded output (sped up).
Each command exits 0. verify prints:
[haenv] generation: 3/4 case(s) passed the emission gate
[haenv] per-item verification: 82 item(s) · failed 0 · text leaked in 0 case(s)
What the other lines mean, and why --fresh
report prints:
[haenv] scoring vintage: all 72 row(s) on disk carry today's stamp `<judging_sha16>` ✓ (fine fingerprint not needed)
[haenv] report -> <repo>/reports/ew-demo/<batch>/eval-ew-demo.md
The fourth case, EWX-04, is refused with conflicts=['event_density_mismatch']: its high
distractor level injects five symptom events where its symptom rate (0.1 per week by default)
allows one.
--fresh opens a new batch. Once a batch holds answers, build or verify on it exits 2, because
regenerating its questions would score new questions against old answers. For the same reason
verify runs before run.
Build a public sample pack the same way, with no model call (the audit needs scikit-learn):
uv run --with scikit-learn --with joblib python tools/make_pack.py --pack p4 --n 50 --out packs/p4
It exits 0 when the build and every audit gate pass, and prints the pack record:
{"pack": "p4", "n_items": 50, "seed_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "steps": {"gen": 0, "build": 0, "audit": 0}, "job": "<repo>/packs/p4/p4-followup.job.yaml", "batch": "<repo>/packs/p4/results/joint_dx/p4-followup/<batch>", "ref": null}
The built batch is under packs/p4/results/, the audit under packs/p4/audit/.
Install from PyPI
pip install haenv
JOBS=$(python -c "import haenv,pathlib;print(pathlib.Path(haenv.__file__).parent/'_data'/'inputs')")
cd ~/my-workdir # artifacts go to the current directory
haenv build "$JOBS/example-ew.job.yaml" --gen deterministic --fresh
haenv run "$JOBS/example-ew.job.yaml" --offline
HAENV_DATA_ROOT sets the read-only resource root and HAENV_OUTPUT_ROOT the artifact root.
HAENV_CONFIG_OVERLAY names a YAML file of your own, merged over the packaged config.yaml
(your backends and models; see Evaluate your own agent).
Run against real models (billed)
A paid run needs two flags: a ceiling and the shared ledger that keeps the spend. Give both, or the run stops before the first call.
# one case, one model
uv run haenv run inputs/example-ew.job.yaml --models gemini-3.1-pro --limit 1 \
--judge-budget-usd 5 --judge-budget-ledger ~/.haenv/budget.json
# full sweep, resumable; rerunning sends only the cells that have no answer yet
uv run haenv run inputs/example-ew.job.yaml --judge-budget-usd 50 \
--judge-budget-ledger ~/.haenv/budget.json
Keys are read from the file named by HAENV_ENV_FILE (see
Evaluate your own agent); without one, the call fails before it is
sent.
One case, end to end
What the agent sees, the hidden gold, and how two answers are judged (EWX-01 from the quick start)
The prompt is a fixed instruction block (in Chinese) followed by the record up to T as JSON.
An excerpt, with most of the 20 streams elided:
{
"user_profile": {"age_range": "45-49", "sex": "F", "known_conditions": ["obesity"], ...},
"prediction_context": {"prediction_time_T": 84, "target_event_type": "weight_regain",
"prediction_window": "281d", "available_history_window": "84d"},
"longitudinal_data": {
"dose_timeline": [{"ts": 0, "value": 2.5}, {"ts": 28, "value": 5.0}, {"ts": 56, "value": 7.5}],
"medication_adherence": [{"ts": 42, "value": 0.95}, {"ts": 56, "value": 0.88}, {"ts": 84, "value": 0.8}],
"weight": [{"ts": 0, "value": 98.70}, ..., {"ts": 84, "value": 87.10}],
...
},
"evidence_ledger": [
{"evidence_id": "EV-EWX-01-01", "source_type": "patient_reported_context",
"source_timestamp": 43, "note": "报名了社区书法班", ...},
...
]
}
The agent returns one JSON object: a risk forecast, drivers ranked from a fixed list with the
evidence ids behind each, and one action class from A0 (continue monitoring) to A5 (urgent
escalation). The hidden gold for this case: weight regain occurs, driven by
poor_medication_adherence, and clinician action is warranted.
| Offline reference solver | Forecast | Top driver | Action | Result |
|---|---|---|---|---|
no_revision |
risk 0.2, low | poor_medication_adherence (hit) |
A0, no review requested |
hard gate premature_closure: the case scores zero |
const_ddx |
risk 0.5, indeterminate (abstains) | unknown_or_multifactorial |
A3, review requested |
scored |
The first solver names the right driver and still fails the case: action was warranted and it chose to keep monitoring without tests or review.
How it works
Conceptual workflow. The agent sees records up to T; hidden gold goes only to the judges, which are code plus one LLM judge for the semantic dimensions (see Scoring). Click to view full size.
A job file declares patient facts (condition, drug, dose steps, devices, start weight) and latent
variables. The generator renders the course and injects the configured artifacts and distractors.
A case is released only if it passes the emission gate: a premise check that rejects contradictory
specifications, per-item verification of every stream and event, and a leak probe that runs on every
prompt before it is sent. Batch-level gates and the pack audit then check the pack as a whole, for
example that real symptoms cannot be told apart from distractors by their data footprint.
The simulation kernel that renders the patients is part of this repository; it lives in haenv_kernel/.
- Packs are plugins. Each pack is a judge group that registers its gold blocks, its question template and its scoring; the core pipeline does not know which packs exist.
- One run, one context. An evaluation run carries its own state (
RunContext): two runs in one process do not share tables. - Fingerprints follow definitions. The scoring code and the generation code each have a fingerprint, stamped on every row and every batch. A refactor that only moves definitions is certified definition by definition, so a fingerprint changes when what is computed changes.
The same patients can be presented in four formats (called geometries in the code): single,
gated (tests ordered against a budget), slices (independent questions at several time points)
and multi (rounds in which the agent may revise). Each pack poses one decision at T; in pack ①
the agent may buy tests before it answers.
Scoring
-
The gold standard is derived by code from the world that rendered the record. Packs ②, ③ and ④ are scored by code alone. Pack ① is scored by code, and one LLM judge (
gpt-6-luna) matches its free-text diagnoses and tests to the gold, with stored votes. -
Guessing scores 0. A pack's decision is scored as chance-corrected balanced accuracy,
cc = (m · BA − 1) / (m − 1), withmthe answer classes present in the gold: answering at random or always giving the same class scores 0, every item right scores 1. -
One composite per pack.
Pack Composite ① mean of the scored dimensions (test F1, diagnosis listed, numerical reading, chance-corrected review), floored at 0, × (1 − hard-gate rate) ② ccof the disposition × (1 − hard-gate rate); a negativeccis never raised by a gate③ ccof the decision, floored at 0, × (1 − hard-gate rate)④ mean of ccof the change source and the skill on the size of the true change,1 − MAE / MAE_naive(no gate multiplier)The packs are separate boards; their composites are not pooled.
-
Three columns. Every registered measure is in one of three roles. Scored measures enter the composite. Profile measures judge correctly but separate models too little, or have no validity reading yet; they are computed, stored and shown, and do not enter the composite. Retired measures were found to judge wrongly; they stay in the registry with the reason and are on no board.
-
Every score row carries a fingerprint of the judging code, and each pack adds its own. Boards with mixed fingerprints are rejected by the publish gate.
-
Raw model responses are stored, so a scoring fix is a recompute (
tools/restamp_batch.py <batch>) with no new calls to the models under test. -
verifier_core/holds the fingerprinting, the publish gate, the hard-gate multiplier, score ceilings and the noise-floor audit. It contains no clinical vocabulary and imports nothing from the clinical layer, so it can be reviewed or reused on its own.
Self-checks run in seconds without model calls; see docs/REPRODUCE.md.
Evaluate your own agent
The model under test is an entry under models: in config.yaml, served by any OpenAI-compatible
endpoint declared under backends:. Each turn is one chat request carrying the prompt; an agent
behind such an endpoint is evaluated the same way, and its internal tool use is not visible to the
judges. It receives the same patients, the same cut at T and the same judges. Plugins are
registered explicitly in the job file.
To add an endpoint, put it in config.local.yaml next to config.yaml (merged over it, ignored
by git). After pip install, config.yaml is inside the installed package: put the same YAML in
a file of your own and name that file in HAENV_CONFIG_OVERLAY. A new backend name registers an
OpenAI-compatible backend; its key is read from the file named by HAENV_ENV_FILE:
backends:
my-endpoint:
url: http://localhost:8000/v1/chat/completions
key_env: MY_ENDPOINT_KEY # MY_ENDPOINT_KEY=... in $HAENV_ENV_FILE
models:
my-agent:
backend: my-endpoint
model: my-agent-v1
max_tokens: 8000
price: {input_per_million_usd: 0, output_per_million_usd: 0} # USD per million tokens
The default models are reached directly: Gemini models through Google's API, Qwen models through DashScope, and every other model through OpenRouter, pinned to one upstream with fallbacks off: the vendor's own endpoint where the account's zero-data-retention setting allows it, otherwise a named host serving the same weights (OpenAI models on Azure, Claude on Google Vertex, MiniMax on Novita). No third-party relay sits between the pipeline and a model.
A billed run counts every request against --judge-budget-usd. On openrouter, google and
dashscope the price comes from the provider; on a backend of your own it is the price
declared for each model, applied to the token counts the endpoint returns in usage (0 for a
free local endpoint). A model there without a price is refused before any request is sent.
uv run haenv run inputs/example-ew.job.yaml --models my-agent --limit 1 \
--judge-budget-usd 5 --judge-budget-ledger ~/.haenv/budget.json
With --limit the run checks the endpoint on a sample: the semantic dimensions are not judged, no
OpenRouter key is needed, and the run exits 6 to say the scores are incomplete. Without it, the
semantic dimensions are judged by openai/gpt-6-luna through OpenRouter, so the same env file
also needs an OpenRouter key, and the judge's cost counts against the same budget.
| To change | Where | Code |
|---|---|---|
| the model or agent under test | config.yaml (models:, backends:) |
no |
| weighting and normalisation | one config file per board | no |
| add a judge or a new kind of gold standard | a registered function | yes |
| what the judges observe (a new view of the run) | subject plugin | yes |
| streams, events, drug effects, artifacts | world plugin | yes |
examples/ has five plugin packages. Each runs offline and is paired with a
negative control.
Guides: judge plugins ·
external task types.
Agent skills
The repository carries three skills under skills/, in the
Agent Skills layout (skills/<name>/SKILL.md; the standard is read by more
than one agent tool). They are the short path to driving the pipeline from a conversation, and they
are written to be read on their own:
| Skill | Use it when you want to |
|---|---|
haenv-synth |
generate patients and questions — from a job file you write, or from a piece of case-description text — and read what the emission gate rejects |
haenv-bench |
run a pack against models and read the board: resumable runs, per-batch artifacts, the tool-call trace, what a batch cost |
haenv-extend |
add your own judge, indicator stream, event or task type, from a package outside the repository |
Each is an index, not a second source of truth: what it says about the pipeline is checked against
the code and the documents linked from it. A skill is a directory of Markdown plus, in the standard's
terms, optional scripts/, references/ and assets/; nothing about them is specific to one
vendor's tool. If you use an agent that reads the standard, point it at the repository and ask in
plain words — "generate a batch of questions from this patient", "run a batch and show me what it
cost", "add a judge that catches X" — and the matching skill supplies the conventions, the commands
and the failure modes.
Cost and caching
- Generation is cached by model and prompt in
cases/_llm_cache/; rebuilding a pack from the same job makes no model calls. - A run resumes by default and sends only the cells that have no answer yet. A cell the model answered with an empty body, broken JSON or a spent tool budget counts as answered; only aborts caused by the network, a timeout, a provider 5xx or 429, or the key pool are resent. Raw responses are stored, so a judging change is a recompute, not a re-run of the models under test.
- For models with a measured basis,
max_tokensmust clear a floor derived from their output lengths. This reduces truncation risk but does not guarantee that every answer will finish within budget.batch.jsonrecords generation and evaluation usage (gen_usage,eval_usage): measured totals where available, and an explicit status otherwise. - Scale: a pack item is one request. At N = 50 one pass costs, estimated from each prompt's token count at fitted provider prices, $0.09–$9.55 per model on pack ② (mean $3.20 over the ten 1.x models), $0.09–$8.19 on pack ③ (mean $2.65) and $0.09–$8.62 on pack ④ (mean $2.82); packs ②–④ have no judge cost. Details and the recompute command are in
docs/REPRODUCE.md.
Leaderboard
The 1.2.0 board ranks 10 models on the four packs, 50 items each, every item answered 3 times.
The overall score is the equal-weight mean of the four pack scores, each from 0 to 1. The 95%
intervals come from a case × round bootstrap (4,000 draws that resample items and rounds
together); ranks follow the unrounded scores. The board packs are drawn with a private seed; jobs
and batches record only its sha256, 8f90c70f61fe7a53860e9a21576c0393e26515ba7eede0467a534360dfa7f9b9.
1.2.0 overall
| Rank | Model | Overall | 95% CI |
|---|---|---|---|
| 1 | gemini-3.1-pro | 0.732 | 0.674–0.788 |
| 2 | gemini-3.7-flash | 0.677 | 0.602–0.742 |
| 3 | gpt-6-sol | 0.671 | 0.617–0.729 |
| 4 | deepseek-v4-pro | 0.560 | 0.484–0.634 |
| 5 | gpt-6-luna | 0.547 | 0.470–0.616 |
| 6 | kimi-k3 | 0.547 | 0.470–0.622 |
| 7 | minimax-m3 | 0.521 | 0.439–0.600 |
| 8 | glm-5.3-flash | 0.483 | 0.408–0.564 |
| 9 | deepseek-v4-flash | 0.426 | 0.347–0.500 |
| 10 | qwen3.7-flash | 0.389 | 0.317–0.465 |
1.2.0 by pack
Each cell is the pack score with its 95% interval.
| Model | ① Differential diagnosis | ② Acute triage | ③ Medication adjustment | ④ Follow-up interpretation |
|---|---|---|---|---|
| gemini-3.1-pro | 0.834 (0.746–0.908) | 0.504 (0.364–0.645) | 0.799 (0.684–0.902) | 0.790 (0.691–0.871) |
| gemini-3.7-flash | 0.755 (0.646–0.854) | 0.532 (0.389–0.673) | 0.667 (0.518–0.801) | 0.754 (0.581–0.890) |
| gpt-6-sol | 0.607 (0.539–0.697) | 0.408 (0.265–0.554) | 0.785 (0.662–0.894) | 0.886 (0.815–0.944) |
| deepseek-v4-pro | 0.566 (0.490–0.664) | 0.457 (0.311–0.598) | 0.677 (0.537–0.804) | 0.538 (0.366–0.693) |
| gpt-6-luna | 0.547 (0.485–0.608) | 0.362 (0.234–0.493) | 0.743 (0.579–0.879) | 0.536 (0.339–0.712) |
| kimi-k3 | 0.612 (0.517–0.726) | 0.468 (0.319–0.615) | 0.694 (0.556–0.818) | 0.411 (0.205–0.609) |
| minimax-m3 | 0.552 (0.486–0.632) | 0.421 (0.277–0.569) | 0.806 (0.673–0.919) | 0.305 (0.099–0.491) |
| glm-5.3-flash | 0.594 (0.489–0.717) | 0.497 (0.356–0.631) | 0.566 (0.421–0.703) | 0.277 (0.068–0.476) |
| deepseek-v4-flash | 0.406 (0.292–0.523) | 0.306 (0.175–0.441) | 0.589 (0.460–0.710) | 0.403 (0.203–0.581) |
| qwen3.7-flash | 0.428 (0.341–0.533) | 0.329 (0.187–0.487) | 0.542 (0.413–0.655) | 0.258 (0.050–0.464) |
Interactive board · Board data (JSON)
1.x board (historical)
The 1.x board ranks ten models on the two diagnosis tracks, ddx-timeline and ddx-workup
(145 cases each). It is kept as published and is not comparable with the pack boards: the
world, the questions and the scoring have changed since. The bridge items are the only link
between the two.
The two 1.x tables
On 1.x, the composite was the mean of the scored dimensions multiplied by (1 − hard-gate failure rate), from 0 to 1; 95% CI is the case-level bootstrap interval (10,000 resamples) and a tier number is 1 plus the count of models significantly better (Holm-corrected α = 0.05). Answered counts the cells with a scorable answer out of 145; a cell without one scored 0. Each cell ran once. The diagnosis dimensions of that composite had no blind-human validity reading, so the board was labelled preliminary.
ddx-timeline (1.x)
| Rank | Model | Composite | 95% CI | Tier | Answered |
|---|---|---|---|---|---|
| 1 | gemini-3.1-pro | 0.724 | 0.671–0.776 | 1 | 145 / 145 |
| 2 | gpt-6-sol | 0.691 | 0.653–0.730 | 1 | 145 / 145 |
| 3 | gemini-3.7-flash | 0.678 | 0.618–0.740 | 1 | 145 / 145 |
| 4 | gpt-6-luna | 0.666 | 0.630–0.705 | 1 | 145 / 145 |
| 5 | kimi-k3 | 0.662 | 0.632–0.695 | 1 | 145 / 145 |
| 6 | deepseek-v4-pro | 0.652 | 0.632–0.671 | 1 | 145 / 145 |
| 7 | minimax-m3 | 0.617 | 0.587–0.647 | 3 | 143 / 145 |
| 8 | qwen3.7-flash | 0.589 | 0.550–0.631 | 4 | 145 / 145 |
| 9 | deepseek-v4-flash | 0.434 | 0.380–0.494 | 9 | 144 / 145 |
| 10 | glm-5.3-flash | 0.048 | 0.024–0.076 | 10 | 17 / 145 |
ddx-workup (1.x)
| Rank | Model | Composite | 95% CI | Tier | Answered |
|---|---|---|---|---|---|
| 1 | kimi-k3 | 0.697 | 0.639–0.751 | 1 | 144 / 145 |
| 2 | gpt-6-luna | 0.696 | 0.635–0.755 | 1 | 145 / 145 |
| 3 | gemini-3.1-pro | 0.689 | 0.625–0.751 | 1 | 145 / 145 |
| 4 | deepseek-v4-pro | 0.685 | 0.651–0.720 | 1 | 144 / 145 |
| 5 | gemini-3.7-flash | 0.680 | 0.615–0.743 | 1 | 145 / 145 |
| 6 | gpt-6-sol | 0.673 | 0.615–0.730 | 1 | 145 / 145 |
| 6 | minimax-m3 | 0.673 | 0.623–0.721 | 1 | 145 / 145 |
| 8 | qwen3.7-flash | 0.493 | 0.443–0.544 | 8 | 145 / 145 |
| 9 | glm-5.3-flash | 0.375 | 0.303–0.446 | 8 | 116 / 145 |
| 10 | deepseek-v4-flash | 0.304 | 0.247–0.365 | 9 | 145 / 145 |
Figure ·
Source values (CSV) ·
1.x snapshot (JSON, key history_1x)
Limitations
- Pack ② does not separate the leading models. Among the six best models on pack ② no pair differs by more than 1.96 standard errors (0 of 15 pairs); pack ③ separates 3 of 15, pack ① 8 and pack ④ 10. On the overall board 9 of the 15 top-six pairs are separable.
- Clinical review. The rules behind packs ② and ③ (which findings send a patient to the
emergency department, when a dose change is on target) and several registry entries carry
review: pending; the medical content as a whole has not been reviewed by a practising clinician. - Single judge in pack ①. One model (
gpt-6-luna, reasoning high) matches free-text diagnoses and tests to the gold, with two votes and a third on disagreement. The same vendor's models are among those tested; self-preference is not ruled out, and no second judge or physician labelling checks its verdicts. Packs ②–④ have no LLM judge. - The diagnosis dimensions await validity. On the bridge tracks the diagnosis-track scored dimensions are judgement-based and wait for a clinical blind annotation; until then they have no public column.
- Synthetic data. All patients are synthetic and the benchmark is for evaluation only; nothing here is medical advice.
The full list, with the scoring, generation and world-layer gaps, is in the data card.
Related work
| Work | Evaluates |
|---|---|
| ESL-Bench (arXiv:2604.02834, dataset) | 100 synthetic users with 1–5 year device, exam and event trajectories; 100 queries each across lookup, trend, comparison, anomaly and explanation, with programmatically computed answers. Leaderboard: Health Memory Arena |
| mirobody-eval (GitHub) | a harness that reproduces published health benchmarks, ESL-Bench included, against an existing system, with pluggable virtual user, target and judge |
| MedAgentBench (arXiv:2501.14654) | 300 agent tasks in a FHIR virtual EHR |
| EHRSHOT (arXiv:2307.02028) | few-shot prediction on longitudinal EHR of 6,739 real patients |
| LongHealth (arXiv:2401.14490) | 400 multiple-choice questions over 20 long fictional records |
| HealthBench (arXiv:2505.08775) | 5,000 health conversations graded by a model against physician-written rubrics |
| AgentClinic (arXiv:2405.07960), CRAFT-MD (doi:10.1038/s41591-024-03328-5) | history-taking and diagnosis in dialogue with LLM-simulated patients |
HAEnv combines a longitudinal record, a prompt cut at T, a gold standard fixed before the data,
code scoring for every dimension except the semantic ones (one LLM judge) and regenerable
synthetic cases. The pass^k reliability metric is from τ-bench
(arXiv:2406.12045).
Data, ethics and reproduction
All patients are synthetic. Nothing here is medical advice or suitable for clinical decisions.
docs/DATA_CARD.md: packs, gold provenance, scoring, known gaps, and why the gold standard ships with the questions.docs/REPRODUCE.md: what is free to reproduce, what is billed, and the self-checks.docs/ETHICS.md: data sources and limits of use.CONTRIBUTING.md: how to change scoring or generation code.
Citation
@software{haenv,
title = {Health Agent Environment},
author = {{Theta Health}},
year = {2026},
url = {https://github.com/thetahealth/mirobody-env},
version = {1.2.1}
}
The same metadata is in CITATION.cff.
License and acknowledgements
Code is under the MIT License; the synthetic data and question packs are under
CC BY 4.0. Third-party notices are in NOTICE.md.
HAEnv is inspired by ESL-Bench.
Synthetic data · evaluation use only · not medical advice
Metadata
Release files for haenv 1.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| haenv-1.2.1.tar.gz | 5.3 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| haenv-1.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 7.0 MB
Release files / haenv-1.2.1.tar.gz
| Download URL | haenv-1.2.1.tar.gz |
|---|---|
| Size | 5.3 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e6a7280775bc8be5aaa143a7edd7e783530620c652f34a6ce7ee08622bb284e7
|
|
BLAKE2b-256 checksum How to use checksums |
0c0865ccdef87ab4bbbf4fd9d1f55429c2d000a4e5c51ddf1ff43a9118fabe54
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/5.1.0 CPython/3.12.15
|
Release files / haenv-1.2.1-py3-none-any.whl
| Download URL | haenv-1.2.1-py3-none-any.whl |
|---|---|
| Size | 1.7 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
681ab0301666674aeb3cace0a1176804a9494d1be868c1c8f3a87733f965fd02
|
|
BLAKE2b-256 checksum How to use checksums |
dc6a86e67571889c81a0192136e643d9b11e5dbe7109fbab419a7035d2d280eb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/5.1.0 CPython/3.12.15
|