eCRF pipeline apps / Evaluation case study

Evaluation case study · synthetic data only

How the coding and de-identification assists were evaluated

Two of the four apps, the Coding QC assist and the De-identification review assist, have a second, optional "real model path" built from open-source models (Cactus Needle3, hnswlib, Laya and XGBoost). This page reports what that path and the default rule-based stand-in did on a labeled synthetic test set: how the data were built and split, how the probabilities were calibrated, what was measured, and where it failed.

Evidence label: Tools built (synthetic data only). The apps, the synthetic data generator and the evaluation scripts are working artifacts in this repository. Nothing here was run on real patient data, a licensed MedDRA release or real site notes, and nothing here is a validated system. The real-path build and measurement (2026-10-21 and 2026-10-22) were done in AI-assisted coding sessions (Claude Code), whose commits are authored "Claude" in the git history. The headline metrics on this page are read from committed result files written by scripts in eval/; the per-format breakdowns of leaks and false positives, the mean P(direct), the case-sensitivity example and the Stage 0 examples come from the written records in docs/EVALUATION.md and docs/FEASIBILITY.md, which no script regenerates (see Where the numbers come from).

The failures, stated plainly

  • Identifiers left readable. On 220 held-out synthetic notes holding 308 identifiers that should have been redacted, the real model path left 91 of 308 readable (80 direct identifiers, 11 indirect) and the stand-in left 108 of 308 (all direct). The leaks are formats neither proposer covers: 8-digit MRNs ("MRN: 21428912", 22 leaked), 7-digit phone numbers ("555-0185", 22), dates of birth written M/D/YYYY ("9/13/1940", 19) and names with no title in front of them (17). None of Needle3's 5 MRN proposals was right. This alone rules out using either path as a de-identification method without a person reviewing every record.
  • False redactions. The real path made 11 false positives the stand-in did not: drug names taken as patient names ("Alanine" twice, "Levothyroxine"), doses taken as phone numbers ("20 mg prescribed", "100 mg"), bare years taken as dates of birth, an MRN typed as initials, and 3 boundary slips on initials.
  • Colloquial verbatims are not solved. For phrasing such as "calcium down on bloods", the right preferred term ranked first for 52% on the real path against 46% for the stand-in, and none cleared the auto-code gate. The misses are mostly direction words: "calcium down" retrieves "Blood calcium increased" above "decreased", because Needle3's embedding does not separate them.
  • A weak probability that calibration made worse. Laya's "does this field hold a direct identifier?" answer had an AUROC of 0.69 and was biased low (fields that do hold one got a mean probability of 0.24). Fitting a temperature raised its expected calibration error from 0.445 to 0.485, because a temperature cannot fix a bias and the fitted value is shared with the other PHI question.
  • The worked examples did not reproduce the original deck. The illustrative scores the first version of the walkthrough showed (0.958, 0.971, 0.934, 0.882, and "three auto-code, one flags") were not reproduced: on the real path all four worked verbatims got the right preferred term at rank 1 but scored 0.04 to 0.12, so all four went to a human coder. See the case study.
  • Slow on a CPU. About 0.4 s per verbatim and 0.9 to 1.6 s per note, one record at a time, on a 4-vCPU cloud CPU. The real path is a batch job, not the sub-50 ms interactive tool an earlier version of Deck A targeted.
  • No sibling family may auto-code. Given the right neighborhood, the stand-in picked the right lab direction or body-site term for 0.909 and 0.861 of items, below the registered 0.90 floor once uncertainty is counted (lower bounds 0.828 and 0.805), and the tool was not shown to beat a keyword rule (the rule was ahead on lab; on site the tool's 0.042 lead was within bootstrap uncertainty). The lab family is too small to clear the floor even with every item right. See Within-sibling discrimination.
  • The re-ranker lowered top-3. It raised top-1 over plain retrieval order (0.747 to 0.778) but lowered top-3 (0.885 to 0.854), because several lowest-level terms under one preferred term crowd the right one out of the three exported candidates.

Results at a glance

Held-out test splits only; nothing was fitted on them. "Stand-in" is the default [SI] path that runs anywhere, including in the browser; "real" is the optional local model path.

Measure (test split)Stand-in [SI]Real path
Coding: right preferred term at rank 1 (288 verbatims with a correct code)54.2%77.8%
Coding: right preferred term in the exported top 361.5%85.4%
Coding: auto-coded at the default gate 0.88 (all 384 verbatims)25.5%38.0%
Coding: share of auto-codes that were wrong, at 0.887.1%0.0%
Coding: negated verbatims ("denies nausea") auto-coded, at 0.887 of 960 of 96
De-identification: identifiers left readable (308 targets)10891
De-identification: strict precision / recall / F1 (all entities)1.00 / 0.65 / 0.790.95 / 0.70 / 0.81
Time for the whole test split (coding 384 verbatims; PHI 220 notes)2.5 s; under 0.1 s86 s; 279 s

What was measured, on what data

All evaluation data come from eval/make_data.py (seed 42; rerunning it reproduces eval/data/ byte for byte). Everything is invented: terms carry 99-prefixed placeholder codes, not MedDRA codes, and the groupings are not MedDRA's hierarchy.

FileWhat it holds
dictionary.csv3,371 lowest-level terms under 519 preferred terms, in the shape of the demo dictionary: body-site pain, stiffness and swelling; fractures, wounds and burns; lab values increased, decreased and abnormal; infections by organ; common symptoms.
coding_eval.csv1,920 adverse-event verbatims, four per term: exact (a term typed lower-case), colloquial ("forearm feels weak"), misspelled ("palpitaitns") and negated ("denies nausea"). A negated verbatim has no correct code, so any auto-code of one counts as an error.
phi_fields.csv, phi_spans.csv880 free-text eCRF notes from 40 templates with 1,166 gold spans: 1,078 redaction targets (patient names, phone numbers, emails, MRNs, dates of birth, ZIP codes, initials, ages) and 88 identifier-shaped spans that are not targets on their own (a lone initial or ZIP code). The notes also carry look-alikes that are not identifiers: drug names, lab values, doses, times, kit and lot numbers.

How the shipped code was measured. eval/run_eval.py loads each app's own tier functions from its file (everything above its UI marker, through eval/_app.py), so the evaluation runs the code the apps ship rather than a copy. It runs the stand-in on the test split; builds the real Tier 2 and Tier 3 features on every split; fits the Laya temperatures on the calibration split; trains the XGBoost gate on the training split; writes those artifacts into a models dir; then reloads the app from that models dir and runs its real path on the test split. Those reloaded runs are the "real" numbers, and the script asserts that the reloaded app's calibrated probabilities match the ones the gate was trained on (to 0.002).

Scoring rules. Coding: top-1 and top-3 are measured on the 288 verbatims that have a correct code; the auto-code rate is over all 384; an auto-code is an error if its preferred term is wrong or the verbatim is negated. De-identification: a redaction counts as correct only on a strict match (same note, same start and end offset, same entity type); "left readable" counts gold targets with no overlapping redaction of any type, which forgives boundary and type slips and is the number that matters for privacy.

Grouped splits

A random row split would let the test set share terms and note templates with training, so a model could score well by memorizing them. The splits are grouped instead: coding rows by preferred term (a test term never appears in training or calibration) and notes by template (a test template is never seen in training).

SplitUsed forCoding verbatimsPHI notes (templates)
trainfitting the XGBoost re-ranker and span gate1,152484 (22)
calibfitting the Laya temperatures; XGBoost early stopping384176 (8)
testreported; nothing is fitted on it384220 (10)

Calibration

Laya answers each yes/no question with a probability: the softmax of its logits divided by a temperature. The checkpoint ships a temperature of 1.98; the script fits a new one on the calibration split with Laya's own fitting function and reports the test split before (shipped) and after (fitted). Expected calibration error (ECE) uses 10 equal-width bins; lower is better. A temperature changes how confident the probabilities are, not their order, so AUROC does not change.

QuestionLabeled true whenTemperatureTest nECE before → afterBrier before → afterAUROC
coding: unambiguousthe verbatim is not negated1.98 → 1.453840.119 → 0.0840.173 → 0.1720.736
PHI: direct_piithe note holds a direct identifier1.98 → 1.36 (shared)2200.445 → 0.485 (worse)0.411 → 0.4670.686
PHI: reident_riskthe note holds two or more indirect attributes1.98 → 1.36 (shared)2200.154 → 0.0930.045 → 0.0310.998

Laya fits a temperature per answer type and option count only from 2,000 labeled records or more. The calibration splits here are smaller, so it fits one type-level temperature, and the two PHI questions share it. The indirect question's near-perfect AUROC reflects templated synthetic notes, an easy case, not a general result. The coding question's AUROC of 0.74 means Laya separates negated from plain verbatims only moderately; it is the re-ranker, combining that answer with the retrieval features, that kept every negated verbatim below the gate.

Coding: stand-in vs real

Test split: 384 verbatims (96 of each variant) coded against the 3,371-term dictionary. The real path retrieves the top 10 candidates with Needle3 and hnswlib, adds Laya's unambiguous probability, and re-ranks with a trained XGBoost classifier; the stand-in uses a hashed bag-of-words and a fixed weighted sum.

PathGateTop-1Top-3Auto-codedWrong share of auto-codesErrors per 100Negated auto-coded
stand-in [SI]0.880.5420.61525.5%7.1%1.87 of 96
stand-in [SI]0.900.5420.61524.0%1.1%0.31 of 96
real0.880.7780.85438.0%0.0%0.00 of 96
real0.900.7780.85438.0%0.0%0.00 of 96
Variant (96 each)Stand-in top-1Real top-1Stand-in auto-coded (0.90)Real auto-coded (0.90)
exact0.9791.00090.6%100%
misspelled0.1880.8134.2%52.1%
colloquial0.4580.5210%0%
negatedno correct codeno correct code1.0%0%
Ablation, real path (same 288 verbatims)Top-1Top-3
Needle3 retrieval order only, no re-ranker0.7470.885
the same, with terms and verbatims embedded as typed (no lower-casing)0.6220.729
full real path (top 10 retrieved, re-ranked by XGBoost)0.7780.854
  • The gain is mostly on misspellings (top-1 0.81 against 0.19), and the re-ranker auto-coded about half of them with no error. Exact matches were all auto-coded.
  • The gate value did not matter on the real path here. The re-ranker's scores were bimodal (134 above 0.95, most of the rest below 0.5, none between 0.88 and 0.90). On the stand-in, the same 0.02 moved 6 negated verbatims across the gate.
  • Case mattered. Needle3's embedding is case-sensitive ("insomnia" scored 0.948 against "Insomnia", below several unrelated terms). The first real run had this defect; the app now embeds lower-cased text on both sides, which is worth 0.125 top-1 on retrieval alone.
  • Small dictionary. 3,371 terms is far sparser than a full release (about 86,800 lowest-level terms). Accuracy at release scale was not measured and should be expected to be lower.

Within-sibling discrimination

Evidence label: Tools built. An adaptation of the within-site permutation design from my AlphaGenome audit to coding-assist evaluation, on synthetic, non-MedDRA data. Closely related to contrast-set evaluation in NLP. Not validated on a real MedDRA release or on real coding data.

Built and run on 2026-10-25 in an AI-assisted coding session (Claude Code); those commits carry a Claude co-author line in the git history.

Many colloquial misses land in the right neighborhood and pick the wrong member: "calcium down on bloods" retrieves "Blood calcium increased" above "decreased". Top-1 accuracy mixes two failures, finding the neighborhood and choosing within it. This check isolates the second: the neighborhood is given, and the only question is which member the tool prefers. Everything below was registered before any scoring in eval/sibling_plan.md, which is locked by its SHA-256. Both scripts refuse to run when the plan or an input file differs from what was registered, and the evaluation script also refuses when the coding app's scoring code does.

  • Sibling sets, fixed from preferred-term names: lab direction (44 analytes, each "increased", "decreased" and "abnormal") and body site (51 sites with four or six of "Pain in", "Swelling of", "Numbness of", "Tenderness of", "Weakness of" and "Cramp in").
  • Items: the test-split verbatims whose correct term is in a set, 66 lab items (22 terms) and 144 site items (48 terms), three variants each. Negated verbatims have no correct code and are left out.
  • The tool's choice: it scores every member of the set with its own shipped scorer and picks the highest. Within-set accuracy is the share picked right; the margin is the right member's score minus the best wrong member's.
  • Chance, three ways, each relabeling the right answer within the set once per term: uniform (N1); by how often each member is the answer in training (N2); and, for colloquial items, uniform over the members whose words overlap the verbatim as much as the right one does (N3). The accuracy tail is exact; the margin's is Monte Carlo with 9,999 draws.
  • A keyword rule ("low", "down", "below" for decreased, and so on), written into the plan before scoring, as a comparator.
  • The gates decide auto-coding, not the p-values. Gate A: the one-sided 95% lower bound on within-set accuracy, at the effective size after clustering by term, must reach 0.90. Gate B: the tool must beat the keyword rule (one-sided 95% cluster-bootstrap lower bound above 0). A family that fails either gate, or could not reach 0.90 even with every item right, routes to human coding.
Family (registered before scoring)ItemsTermsBest attainable boundAssessable at 0.90
lab direction66220.873no: not assessable at this size
body site144480.939yes

With every item right the bound is 0.051/m for m terms, so a 0.90 floor needs at least 29 terms and 0.95 needs 59. The locked plan's own table prints 0.940 for 48 terms and 0.951 for 59; the exact values round to 0.939 and 0.950. The scripts compute the bound themselves, so no decision used the printed values.

Stand-in path, test splitWithin-set accuracyKeyword ruleMarginGate A bound (best)Gate B lower boundRouting
lab direction0.9090.9700.1560.828 (0.873)-0.121human coding: not assessable; fails Gates A and B
body site0.8610.8190.1660.805 (0.939)-0.014human coding: fails Gates A and B
Family, variantItemsToolKeyword ruleWhole-dictionary top-1
lab, exact221.0001.0001.000
lab, colloquial220.7271.0000.455
lab, misspelled221.0000.9090.455
site, exact481.0000.9791.000
site, colloquial480.6040.8130.542
site, misspelled480.9790.6670.104

The last column is the stand-in's rank-1 accuracy over the whole dictionary on the same items, from the Stage 3 rows, for comparison.

  • With the neighborhood given, the stand-in picks the right member most of the time (0.909 lab, 0.861 site, against a whole-dictionary top-1 of 0.636 and 0.549 on the same items). Most of its misses are in finding the neighborhood; colloquial verbatims carry the within-set misses.
  • It beats chance but not lexical overlap. Both families beat N1 and N2 on accuracy and margin (p below 0.001). On colloquial items whose words overlap two or more members equally (11 lab, 22 site), accuracy is not distinguishable from chance under N3 (0.545, p = 0.12; 0.273, p = 0.08). The margin beats that null (p = 0.012 and 0.022) without separating the right member: on those items the mean margin is 0.007 on lab and -0.045 on site, where the right member is picked for only 6 of 22.
  • It is not accurate enough to auto-code either family. Lab is too small to reach 0.90 with every item right, and its bound of 0.828 fails anyway; site's bound is 0.805.
  • The tool was not shown to beat a keyword rule: the rule was ahead on lab (0.970 against 0.909); on site the tool was ahead (0.861 against 0.819), but its 0.042 lead was within bootstrap uncertainty (lower bound -0.014), so it failed Gate B. The rule's lexicon was written with the generator's templates visible, so this baseline is optimistic.
  • Real path: not run in this pass (no models dir was available). The shipped re-ranker scores only candidates from the retrieved top 10, so the plan registers the Needle3 cosine as the real-path score. "calcium down on bloods" is picked correctly on the stand-in path; its Stage 3 failure is on the real path.
Known-answer check (400 simulated datasets; 2,000 for coverage)Must showResult
(a) accurate but not enough, mean accuracy 0.80beats chance (q < 0.05) and fails Gate A, each in at least 95%100%; 100%
(b) always "increased" firstaccuracy about 0.60; N1 rejects in at least 99%; N2 in 0.028 to 0.072; fails both gates0.599; 100%; 0.065; 100%
(c) pure noiseeach null rejects in at most 0.072; margin p-values uniform0.043, 0.040, 0.028; KS p 0.24, 0.18, 0.69
(d) good enough, mean accuracy 0.97Gate A passes in most; Gate B in at least 95%100%; 100%
(e) true accuracy 0.85, intraclass correlation 0.2Gate A bound below 0.85 in at least 93%96.5% (93.3% with the design effect left out; reported, not registered)

Every registered criterion was met. In the negative control, with the stand-in's scores shuffled among the members of each set 400 times, the accuracy test under N1 rejected 2.8% (lab) and 4.0% (site) of the time, against a registered ceiling of 7.2%. These criteria alone do not show that the gates work (row (e) still clears 93% with the design effect left out), so the test suite also checks the gates directly, outside the plan: the bound, the intraclass correlation and Gate B against hand-worked values, Gate B at the point where the tool and the rule are equally accurate (it passed in 4.5% of 1,000 such families), and the tails against enumeration.

Caveats. The dictionary is synthetic and not MedDRA, and its sibling families are regular by construction. The gold codes were generated, so they are independent of the tool by construction; real use needs gold codes adjudicated without the tool's suggestions, a licensed MedDRA release and a sibling rule registered before scoring. An "abnormal" colloquial verbatim such as "albumin off on labs" carries no direction, and its correct code is the "abnormal" term: code what is reported and query the site; nothing here infers a direction from linked lab values.

Prior art. Contrast sets (Gardner et al., Findings of EMNLP 2020) and CheckList (Ribeiro et al., ACL 2020) test models on minimally different inputs; hierarchical precision and recall (Kiritchenko et al. 2006), shortcut learning (Geirhos et al. 2020) and the conditional logit (McFadden 1974) are close relatives; the design effect (Kish 1965), the Clopper-Pearson bound (1934) and false discovery control (Benjamini and Hochberg 1995; Benjamini and Yekutieli 2001) are standard. What is new is modest: a base-rate-weighted within-set null, the keyword rule as a required comparator, and routing gates registered in advance with an attainability check. FDA's January 2025 guidance on AI to support regulatory decision-making is a draft, not for implementation, and the FDA-EMA good AI practice principles (January 2026) are non-binding; this check is not a validation under either.

Citation. Iben P. What do within-site comparisons of AlphaGenome Atlas predictions measure? An audit at ClinVar sites in 11 genes. Human Genomics, submitted. Preprint: doi:10.5281/zenodo.23543154. Code and data: doi:10.5281/zenodo.23537469. The full write-up is in EVALUATION.md.

De-identification: stand-in vs real

Test split: 220 notes from 10 templates never seen in training, holding 308 redaction targets. The real path proposes spans with Needle3's typed extraction plus the stand-in's patterns, and a trained XGBoost gate decides each one; the stand-in uses the patterns and a fixed rule. "Needle3 alone" uses Needle3's values directly as redactions, with no patterns and no gate, to show what the extractor contributes on its own.

EntityGoldStand-in P / R / F1Real P / R / F1Needle3 alone P / R
PERSON_NAME661.00 / 0.33 / 0.500.94 / 0.74 / 0.830.45 / 0.70
PHONE441.00 / 0.50 / 0.670.92 / 0.50 / 0.650.30 / 0.55
EMAIL221.00 / 1.00 / 1.001.00 / 1.00 / 1.000.95 / 0.86
MRN441.00 / 0.50 / 0.671.00 / 0.50 / 0.670.00 / 0.00
DOB441.00 / 0.55 / 0.710.92 / 0.55 / 0.690.31 / 0.34
ZIP221.00 / 1.00 / 1.001.00 / 1.00 / 1.000.21 / 0.55
INITIALS441.00 / 1.00 / 1.000.91 / 0.89 / 0.900.60 / 0.64
AGE221.00 / 1.00 / 1.001.00 / 0.73 / 0.840.69 / 1.00
all3081.00 / 0.65 / 0.790.95 / 0.70 / 0.810.42 / 0.54
left readable (of 308)108 (all direct)91 (80 direct, 11 indirect)

P = precision, R = recall, strict match on offsets and entity type.

  • The real path finds more names. Needle3 proposes names with no title in front ("Spoke with Linda Miller"), which the stand-in's patterns require, and the gate keeps most of them: name recall 0.33 to 0.74 at 0.94 precision.
  • It lost some indirect attributes the stand-in kept (age recall 1.00 to 0.73, initials 1.00 to 0.89): the stand-in redacts any indirect attribute when two co-occur, and the trained gate learned a softer rule.
  • The gate leans on span evidence, not on Laya. With direct_pii weak and biased low, the redactions that are right are right because of the span features.

Speed and index cost

One record at a time through each real tier, on the worked examples (25 timed calls after a warm-up; medians, ranges across four records):

AppTier 2Tier 3 (Laya, one call)Tier 4 (XGBoost, with features)Per record
codingNeedle3 embed and search: 40 to 48 ms328 to 346 ms (1 question)7 to 9 msabout 390 ms
de-identificationNeedle3 extraction and patterns: 434 to 1,077 ms449 to 523 ms (2 questions)under 1 ms0.9 to 1.6 s

Retrieval index (coding Tier 2): Needle3 vectors are 3072-dimensional. Over the 3,371-term dictionary the index held 40.4 MB, search took 0.54 ms (median) against 0.60 ms for exact brute force, and recall@10 against exact search was 0.994. At a full release's size, measured on 86,800 random unit vectors of the same width (index cost only, not retrieval quality), the index held about 1,040 MB and search took 5.8 ms. Embedding the verbatim (about 30 ms) costs more than the search.

Before the build: feasibility checks

Before any build work (Stage 0, 2026-10-21 and 2026-10-22), both models were downloaded, loaded offline on a CPU and checked against what the apps assumed. The full record is in FEASIBILITY.md. What it changed:

  • The assumed embedding was wrong. The original design said "384-dim (SapBERT)"; Needle3's vectors are 3072-dimensional and it is not a biomedical encoder. Its similarity scores are compressed (unrelated terms scored 0.93 against 0.96 for the right one), so no fixed cosine cutoff carries over.
  • Laya is over-confident as shipped (its authors say so) and near chance zero-shot on its authors' decision benchmark. One measured example went the wrong way: "Does the patient report a headache?" on "Patient denies headache." gave 0.655. That is why calibration and a labeled negation set were required before any Laya output drove a decision.
  • Five wiring defects in the apps would have stopped or mislabeled the real path: the model modules were never bound to names; the Laya call used route(), which returns a routing decision, not answers; the banners claimed REAL when only a library was importable; the guarded artifact names (needle3.onnx, laya.onnx) do not exist in either model repository; and the Laya probe reported REAL with torch absent. All five were fixed, and each fix is pinned by an always-run check in tests/qc_test.py (RW1 to RW16, stub packages, no models needed).
  • Network and telemetry. Needle3's Python package sends usage telemetry unless it is switched off, and its loader asks the Hugging Face Hub for a config file on every load, even with the weights cached. The apps set NEEDLE_TELEMETRY=0, DO_NOT_TRACK=1 and HF_HUB_OFFLINE=1 before any model loads and read only the models dir. An unrelated PyPI package also installs the import name needle; the apps reject it.

Where the numbers come from

NumbersResult fileScript
Index size, latency, recall@10eval/results/stage1_index.jsoneval/bench_index.py
Calibration, coding and de-identification results, ablationseval/results/stage2_3.json (per-verbatim rows in coding_test_real.csv, coding_test_standin.csv)eval/run_eval.py
Worked examples on the real path, per-record latencyeval/results/deck_b_worked.json (named for the retired Deck B)eval/deck_b_worked.py
Within-sibling discrimination, its known-answer checks and negative controleval/results/sibling_summary.json, sibling_items.csv, sibling_knownanswer.csv, under the plan eval/sibling_plan.mdeval/sibling_eval.py, eval/sibling_knownanswer.py
Leaks by format (22 MRN, 22 phone, 19 date of birth, 17 names), the breakdown of the 11 false positives, the mean P(direct) of 0.24, "insomnia" scoring 0.948, a full release's size of about 86,800 lowest-level termsdocs/EVALUATION.md, the written record of the Stage 1 to 4 runs. Of these, only the leak totals (91 left readable, 80 of them direct) are in stage2_3.json, and only the benchmark size (86,800 random vectors) is in stage1_index.jsonnone: written up from the runs, not regenerated by a script
Known answers for Participant risk screening (52 participants, 5 test cases) and Central statistical monitoring (3 injected outliers plus SUBJ-A150, 3 injected notes), under What this does not shownone: the harness runs each app on the bundled sample_data/ or anomaly_data/ and asserts them on every run (checks R9b, R10 to R16, A8c and A14)tests/qc_test.py
Stage 0 examples: the assumed 384-dim encoder, unrelated terms at 0.93 against 0.96, Laya's 0.655 on "Patient denies headache."docs/FEASIBILITY.md, the Stage 0 session record (2026-10-21 and 2026-10-22)none: recorded during the Stage 0 checks

Measured on 2026-10-22 on one cloud container: Intel Xeon @ 2.80GHz, 4 vCPU, no GPU, 15 GB RAM, Linux 6.18, Python 3.12.3, with cactus-needle 3.2.8 (engine 3.3.3), Laya 0.4.13 (English checkpoint convaiinnovations/laya at commit 55cf4c4e), hnswlib 0.8.0 (M 32, efConstruction 200, efSearch 64), xgboost-cpu 3.4.1, torch 2.14.1+cpu and transformers 5.20.2. The stand-in numbers come from the same runs. Everything is seeded and CPU inference is deterministic, so a rerun on the same versions reproduces the committed files. To rerun, follow eval/README.md: install the pinned runtimes, build a models dir with python eval/fetch_models.py --out models, then:

python eval/make_data.py                        # regenerate eval/data/ (byte-identical)
python eval/bench_index.py --models models      # index cost
python eval/run_eval.py   --models models       # calibrate, train, evaluate
python eval/deck_b_worked.py --models models    # worked examples, per-record latency
ECRF_MODELS_DIR=models python tests/qc_test.py  # adds the RM real-path checks
python eval/sibling_eval.py --models models     # within-sibling check (without --models: stand-in only)

The long-form report, with measurement notes, is EVALUATION.md.

What this does not show

  • Real data. Accuracy on a licensed MedDRA release or on real site notes is unknown and should be expected to be lower.
  • Retrieval quality at release scale. The 86,800-term figures are index cost on random vectors.
  • The within-sibling check on the real path. It ran on the stand-in path only; it needs a models dir.
  • Participant risk screening and Central statistical monitoring. Neither has a trained-model path or a labeled evaluation, so no accuracy, sensitivity or false-positive rate was measured for either. They run labeled rule-based stand-ins (plus optional scikit-learn and POT detectors in the monitoring app), checked only against signals planted in the synthetic data, by the QC suite in tests/qc_test.py. Risk screening: of the 52 participants in the bundled cut, the 5 test-case participants the suite checks get the category or reason they were built for (fatal SUBJ-9015 and multi-system SUBJ-9001 CRITICAL, severe-AE SUBJ-9002 HIGH, an ALT breach cited for SUBJ-9013 and a QTc breach for SUBJ-9014; checks R10 to R16). Monitoring, on the 150-participant longitudinal demo as the in-browser page runs it: all 3 injected numeric outliers are flagged, plus 1 participant with no injected outlier (SUBJ-A150, MODERATE at z = 3.0, a side effect of the injected LAB-C AST shift; check A8c), and exactly the 3 injected unusual notes (A14). Known answers like these show the checks fire on what was planted; they do not estimate how often a real signal is missed or a clean participant is flagged.
  • Validation. These are evaluated prototypes, not validated systems. Reported study numbers belong to the sponsor's validated, double-programmed SAS or R programs; nothing here replaces that or the medical coder's and privacy reviewer's judgment.