TTE-FILT-*/TTE-DERIV-*/TTE-ANIM-* rows carry the revision. Sections §4–§6
below describe the original ADTTE design and are retained as drafted.safety.viz gains a time-to-event module: Kaplan–Meier curves by treatment group with censoring tick marks, pointwise 95% confidence bands, and the at-risk / cumulative-events strip table the FDA ST&F guide mandates beneath every time-to-event plot. The module consumes an ADTTE-shaped analysis dataset (one row per participant per endpoint: id, group, endpoint, time in days, censor flag) and computes the product-limit estimator itself; which events qualify and how censoring dates are chosen stay upstream in the analysis dataset, exactly as in regulated practice.
The organizing principle is a single derivation: one estimator pass produces the curve, the confidence band, the censor marks, and every number in the risk table. The classic KM defect — a risk table that disagrees with its own curve — is made structurally impossible rather than tested away.
Phase 1 is one renderer at the standard done-gate (gallery demo, requirement-keyed evidence, API reference) on the standard module lifecycle. No new architecture: the CI ribbon uses qt-explorer's canvas plugin technique, the risk table uses the same in-canvas drawing approach as qt-explorer's threshold labels, and the shell, filters, and selection event follow the house pattern.
The mandate chain: the FDA ST&F static-displays assessment (2026-07-21) found that five of the guide's figures — F1, F4, F11, F13, F14 — collapse onto one Kaplan-Meier engine, the largest figure group in the guidance with no credible open-source implementation. The goal atlas (2026-07-24) carried it forward as candidate C3, size L. Requirement #161 (2026-08-15) is the issue behind both facts.
What is in scope here: the interactive half only. The ST&F plan's Phase 2 verdict for the
static KM family is "wrap, don't build" — ggsurvfit / tern::g_km in
gsm.safety, under #9. That verdict is
about static R figures and does not transfer: no JavaScript KM implementation exists anywhere in the portfolio's
reference stack, so the interactive renderer computes its own estimator. The furniture the ST&F plan says the
existing R templates lack — at-risk and cumulative-event strip tables, pointwise 95% CI, earliest-qualifying-event
semantics — is exactly the furniture this module treats as first-class requirements.
Out of scope for Phase 1 (each needs its own decision before it ships): Aalen–Johansen
cumulative incidence for competing risks, landmark analyses, hazard-ratio or log-rank annotation, median-time
annotation, in-browser derivation of time-to-event from raw AE + disposition domains, and the
gsm.safety widget binding.
This section is the normative spec for src/time-to-event/km.js, the pure estimation module.
Every formula here is asserted by unit tests against hand-computed values and cross-validated against R's
survival::survfit (§7).
Input per group: pairs (tᵢ, cᵢ) with tᵢ > 0 the participant's time in days and cᵢ the censor flag (ADaM semantics: 0 = event, ≥ 1 = censored). At each distinct event time t₍ⱼ₎ with dⱼ events and nⱼ participants at risk:
S(t) = ∏ over event times t₍ⱼ₎ ≤ t of (1 − dⱼ / nⱼ)
At risk at time t means observed time ≥ t — so a participant censored at exactly t₍ⱼ₎ is
still in nⱼ (the standard convention: at tied times, events are processed before censorings, matching
survival::survfit). S(t) = 1 for t before the first event. If the largest observed time is an
event, S reaches its final value there; if it is a censoring, the curve extends flat to that time and the
censor mark shows why it does not drop.
Greenwood's formula for the variance, and the log-log (complementary log-log) transformation
for the pointwise interval — the survfit conf.type = "log-log" default family choice
that keeps bounds inside [0, 1] without truncation:
Var[S(t)] = S(t)² · Σⱼ dⱼ / (nⱼ (nⱼ − dⱼ)) (event times ≤ t) θ(t) = log(−log S(t)), Var[θ] = Var[S] / (S log S)² CI(t) = S(t)^exp(±z₀.₉₇₅ √Var[θ])
Where the transform is undefined the band is not drawn rather than faked: before the first event
(S = 1) there is no interval; where S(t) = 0 the band ends. Where nⱼ − dⱼ = 0 mid-curve the Greenwood sum is
undefined from that point on and the band stops there, matching survfit. The band is labelled
"pointwise 95% CI" in the legend — pointwise, not simultaneous, and the clinical guide says
what the difference means.
At each x-axis tick time t: n at risk = #{observed time ≥ t} and cumulative events = Σ dⱼ over event times ≤ t, per group. Both numbers come from the same sorted arrays the estimator walked — there is no second derivation to disagree with the first. The FDA guide's mandated furniture is exactly these two strips.
Default display is cumulative incidence, 1 − S(t) — the safety convention (curves rise;
the arm with more events sits higher), and the orientation of ST&F figures F1/F4/F11/F13/F14. A
direction setting flips to survival orientation. The y-axis label always names the estimator:
"Cumulative incidence (1 − KM)" or "Event-free probability (KM)" — never a bare percentage.
A group with zero rows is dropped with a console warning. A group that is entirely censored draws a flat line at S = 1 with its censor marks — true, and visibly different from "no data". Zero usable rows overall, or an endpoint value that matches nothing, renders the standard empty-state message rather than an empty confident chart. Rows with non-positive or non-numeric time, or an unparseable censor flag, are excluded and counted, and the exclusion count is surfaced in the notes line beneath the chart (the nep-explorer nothing-dropped-silently pattern).
One tall ADTTE-shaped dataset; every settings key names a column (house pattern, JSON schema in
src/data/schema/time-to-event.json):
| Setting | Default | Meaning |
|---|---|---|
id_col | USUBJID | Participant id — one row per participant per endpoint. |
group_col | ARM | Curve grouping (treatment arm). Optional: absent → one pooled curve. |
param_col | PARAM | Endpoint label, feeds the endpoint picker. |
paramcd_col | PARAMCD | Endpoint short code; param_value selects the initially shown endpoint (default: first by data order). |
time_col | AVAL | Time in days since the analysis time origin (day 1 = first dose, per upstream derivation). |
censor_col | CNSR | ADaM semantics: 0 = event, ≥ 1 = censored. Anything else → row excluded and counted. |
event_desc_col | EVNTDESC | Optional; tooltip detail for event rows. |
censor_desc_col | CNSDTDSC | Optional; tooltip detail for censored rows. |
direction | incidence | incidence (1 − KM, rising) or survival (falling). |
ci | true | Draw the pointwise 95% band. |
time_unit | day | Axis label unit; display only, no rescaling. |
filters | [] | House field-list filters over the analysis rows. |
adae.csv proves the point: it has no follow-up-end for anyone, and inventing
censoring from last observed activity biases curves (participants stop contributing exactly when their data
stops, which is not when their risk stopped).site/data/adtte.csv, derived deterministically in scripts/build-demo-data.mjs
from pharmaverseadam adae + adsl (the house mechanism, obot.roadmap#25), with
provenance in docs/DATA_SOURCES.md. pharmaverseadam ships no safety ADTTE
(adtte_onco.csv is OS/PFS on a simulated oncology arm set — the wrong story for a safety demo),
so the derivation lives in the build script, mirroring what admiral's own ADTTE template does with this same
study.
adsl rows with SAFFL = 'Y' and a non-missing
TRTSDT — measured: 254 participants (Placebo 86, Xanomeline Low Dose 96,
Xanomeline High Dose 72). All 254 carry complete EOSDT.TRTSDT (the ASTDY convention the source file
already uses).TRTEMFL = 'Y')
AE rows with usable ASTDY (measured: all 1,122 such rows have ASTDY ≥ 1 — no
missing, no pre-dose):
| PARAMCD | PARAM | Basket | Measured events / censored |
|---|---|---|---|
TTDE | Time to First Dermatologic Event | AEBODSYS = 'SKIN AND
SUBCUTANEOUS TISSUE DISORDERS' or AEDECOD starts with 'APPLICATION SITE '
(the study drug is a transdermal patch; application-site reactions code to the General Disorders SOC) |
156 / 98 — events by arm: Placebo 30/86, Low 65/96, High 61/72. The arms genuinely separate. |
TTSAE | Time to First Serious Adverse Event | AESER = 'Y' |
3 / 251 — deliberately kept: a sparse-event endpoint whose wide, early-terminating band is the honest display this module must produce, not a failure case to hide. |
TTAE | Time to First Treatment-Emergent AE | any TEAE | 217 / 37. |
ASTDY; censored rows:
EOSDT − TRTSDT + 1 (measured range 1–213 days, median 140.5). Measured: no qualifying AE starts
after its participant's end-of-study day, so AVAL is always ≤ the censor horizon.EVNTDESC carries the first
qualifying AEDECOD; CNSDTDSC = "END OF STUDY".USUBJID, ARM, PARAMCD, PARAM, AVAL, CNSR, EVNTDESC,
CNSDTDSC. Roughly 762 rows (254 × 3), ~40 KB.adsl.DTHDT), and this derivation censors non-event participants at end of study regardless of
reason — which is precisely the situation §3.4's caveat describes. The demo page's clinical guide uses this
fact as its worked example of why 1 − KM is an upper bound on absolute risk here.
src/time-to-event.js entrypoint: class + default-export factory (hep/nep orchestrator shape)
src/time-to-event/configure.js DEFAULT_SETTINGS + syncSettings
src/time-to-event/checkInputs.js schema-driven required-column check (ae-explorer pattern)
src/time-to-event/km.js PURE estimation: kmEstimate(times, censors) → steps, Greenwood, log-log CI,
riskTableAt(ticks) — no Chart.js, no DOM; the normative implementation of §3
src/time-to-event/structureData.js rows → per-group (tᵢ, cᵢ) arrays → km.js → Chart.js datasets
src/time-to-event/getScales.js linear x (days), linear y ([0,1] or percent), tick generation
src/time-to-event/getPlugins.js ciBandPlugin (qt-explorer ribbon technique), riskTablePlugin (in-canvas,
tick-aligned via x.getPixelForValue), tooltip callbacks
src/data/schema/time-to-event.json data contract schema
Registered in src/main.js as timeToEvent; site entry in
site/config.json (title "Time-to-Event Explorer", matrix
time-to-event.md); demo page site/demo/time-to-event.js.
stepped: 'after' (the value
holds until the next event — the only correct KM interpolation), points hidden except at event steps.ciBandPlugin fills between the lo/hi step arrays per group
(qt-explorer's ribbon: beforeDatasetsDraw, α ≈ 0.14 of the group color), drawn only where §3.2
defines the interval.riskTablePlugin reserves bottom layout padding and draws
"n at risk" and "cum. events" rows per group under the axis, each number centered on
x.getPixelForValue(tick) — so the table columns are aligned to the ticks by construction, and
the numbers come from km.js's riskTableAt() on the same pass that drew the curve.EVNTDESC values when singular; on a censor mark: time, count censored, CNSDTDSC.participantsSelected
event with the ids whose event occurred at that time (structureData retains ids per event time) — the same
profile-drill-down seam every explorer keeps open.dataviz design pass runs before chart code is written.House shell: endpoint picker (from param_col, when > 1), direction toggle
(incidence / survival), CI band toggle, configured filters, and the standard notes line (population count,
exclusion count, estimator name). Group show/hide via the Chart.js legend.
survival::survfit conf.type="log-log"; bounds respect [0,1] structurally; band gaps where the
transform is undefined instead of extrapolating.
participantsSelected. The ids at that event
time, nothing more — keeps the profile seam open without inventing per-participant marks on an aggregate
display.
TDD per the house skill; the estimator is the clinically loaded part and gets three independent checks:
survival::survfit.
scripts/build-tte-fixture.R runs survfit(Surv(AVAL, 1 - CNSR) ~ ARM, conf.type =
"log-log") per endpoint over the derived demo dataset and writes
tests/unit/time-to-event/fixtures/survfit-reference.json (times, n.risk, n.event, surv, lower,
upper — full precision). A unit test replays the same inputs through km.js and asserts agreement
to 1e-12. The fixture is committed with a provenance header (R version, survival version, generation date);
the R script is regeneration-only, like the demo-data fetch. Verified available locally: R 4.x +
survival 3.5.8.tests/unit/demo-data/ against the measured facts in §5.1 (254 / 156 / 3 / 217, arm splits,
censor-day range) so a silent upstream data change breaks loudly.Then the house layers: unit suites per module file; Playwright e2e
(tests/e2e/time-to-event.spec.js) for shell controls, endpoint switching, direction toggle,
tooltip content, empty states, selection dispatch, and the evidence screenshots; requirement-keyed evidence
via npm run evidence with the canonical-Linux-baseline bootstrap cycle from the done-gate
convention.
requirements/time-to-event.md, condensed TTE-* scheme, base-ID form for the
evidence-page resolver:
| Area | Covers | ≈ rows |
|---|---|---|
TTE-CFG-* | Default mapping, settings sync, direction/ci/param_value validation | 5–7 |
TTE-DATA-* | Contract checks, CNSR semantics, exclusion counting, group drop warnings | 4–6 |
TTE-STAT-* | §3 line by line: estimator, ties, Greenwood, log-log CI, band-undefined rules, risk-table numbers, single-derivation guarantee, survfit cross-validation | 8–10 |
TTE-CURV-* | Step rendering, censor marks, CI ribbon, orientation, axis labelling naming the estimator | 5–7 |
TTE-RISK-* | Strip table content, tick alignment, group rows, resize behavior | 3–4 |
TTE-USER-* | Endpoint picker, toggles, filters, legend show/hide, tooltip, selection event, empty states | 6–8 |
TTE-GUIDE-* | Clinical guide: estimator naming, pointwise-vs-simultaneous, competing-risks limitation with the demo's death example, censoring assumptions | 3–4 |
TTE-DEMO-* | adtte derivation facts (§5.1), determinism, provenance doc | 4–5 |
Increments, sized so one session can take one and stop; one PR to dev per the
single-PR-per-session convention:
adtte.csv derivation + tests + DATA_SOURCES.mdkm.js with hand-computed tests + the survfit fixture cross-validationvNEXT (Upcoming); PR to devThe prod gate is @jwildfire's. Merging to dev on the standard lane ends this
work; no RC, no tag, no release from this session. The RC that eventually ships this must carry: his review of
D1–D6, the TTE-GUIDE language, and the matrix sign-off (rows land as ai-reviewed).
Like nep-explorer, the module ships marked Experimental until that review happens.