Design #161 — Kaplan–Meier time-to-event family: the interactive renderer

Requirement jwildfire/obot.roadmap#161 · Goal #78 — keep adding charts · Drafted 2026-08-15 by Claude Code (Fable 5), unattended session · Decisions adopted provisionally — awaiting @jwildfire review

Every measured number in this document was computed 2026-08-15 against the live pharmaverseadam extracts (adsl.csv, adae.csv) and is reproducible from the derivation stated in §5.

Revision — 2026-08-15, sv#131 RC review. @jwildfire's review of the v1.7.0 release candidate overturned decision D1's UI consequence: endpoint selection is now driven by flexible multiselect filters over the event dataset (time to first qualifying event, censored at the population follow-up-end day), not by a pre-derived ADTTE endpoint list — "important items will vary from study to study." The renderer consumes ADAE-shaped event records plus an ADSL-shaped population extract; the fixed derivation rule is stated in the module's clinical guide, and the clinically loaded censoring-date decision stays with the data owner in the population dataset. Configured filter presets are staged to a later release. Implemented in safety.viz#133; the requirement matrix's TTE-FILT-*/TTE-DERIV-*/TTE-ANIM-* rows carry the revision. Sections §4–§6 below describe the original ADTTE design and are retained as drafted.

1. Summary

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.

2. Provenance and scope

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.

3. The statistics, stated exactly

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).

3.1 Estimator

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.

3.2 Variance and confidence band

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.

3.3 Risk table

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.

3.4 Orientation

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.

Competing-risks honesty. 1 − KM answers "what fraction would have had the event by day t if censoring were non-informative and nothing else could intervene." When competing events (death, discontinuation for another reason) are present, 1 − KM overestimates absolute risk; the correct absolute-risk estimator is Aalen–Johansen, which is Phase 2. Phase 1's obligations: the y-axis and tooltip name the estimator, the clinical guide states this limitation plainly with the standard reference points, and nothing in the display language claims "risk of event" as an absolute probability. This is a requirement row (TTE-GUIDE), not a docs nicety.

3.5 Degenerate inputs

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).

4. Data contract

One tall ADTTE-shaped dataset; every settings key names a column (house pattern, JSON schema in src/data/schema/time-to-event.json):

SettingDefaultMeaning
id_colUSUBJIDParticipant id — one row per participant per endpoint.
group_colARMCurve grouping (treatment arm). Optional: absent → one pooled curve.
param_colPARAMEndpoint label, feeds the endpoint picker.
paramcd_colPARAMCDEndpoint short code; param_value selects the initially shown endpoint (default: first by data order).
time_colAVALTime in days since the analysis time origin (day 1 = first dose, per upstream derivation).
censor_colCNSRADaM semantics: 0 = event, ≥ 1 = censored. Anything else → row excluded and counted.
event_desc_colEVNTDESCOptional; tooltip detail for event rows.
censor_desc_colCNSDTDSCOptional; tooltip detail for censored rows.
directionincidenceincidence (1 − KM, rising) or survival (falling).
citrueDraw the pointwise 95% band.
time_unitdayAxis label unit; display only, no rescaling.
filters[]House field-list filters over the analysis rows.
D1 — ADTTE contract, not raw-domain derivation. (Revised 2026-08-15 by the sv#131 review — see the banner at the top.) The alternative — deriving time-to-first-event in the browser from AE + population domains — was rejected for Phase 1.
Adopted provisionally 2026-08-15 (unattended). Overturnable at review before any RC.

5. Demo dataset

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.

5.1 Derivation, exactly

Death is a real competing event in this data (the pilot study has on-study deaths in 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.
D5 — demo endpoints TTDE / TTSAE / TTAE, derived in the build script. TTDE is the study's actual safety concern (a dermal patch trial) and the endpoint admiral's ADTTE vignette derives from this very study; TTSAE exercises the sparse-event display honestly; TTAE exercises a high-event-rate curve. Three params exercise the endpoint picker.
Adopted provisionally 2026-08-15. The basket rule is stated above and lives as a named constant in the build script with a unit test.

6. Module design — Phase 1

6.1 Files and lifecycle

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.

6.2 Chart composition

6.3 Shell controls

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.

D2 — default orientation is cumulative incidence (1 − KM). Safety convention and the ST&F figures' orientation; survival orientation stays one setting away. The y-axis always names the estimator.
Adopted provisionally 2026-08-15.
D3 — pointwise 95% CI via Greenwood + log-log, on by default. Matches survival::survfit conf.type="log-log"; bounds respect [0,1] structurally; band gaps where the transform is undefined instead of extrapolating.
Adopted provisionally 2026-08-15.
D4 — the risk table is drawn in-canvas, tick-aligned, from the same estimator pass. An HTML table below the canvas cannot stay aligned with the axis across resizes; a second derivation could disagree with the curve. Both failure modes are removed by construction.
Adopted provisionally 2026-08-15.
D6 — event-step click dispatches participantsSelected. The ids at that event time, nothing more — keeps the profile seam open without inventing per-participant marks on an aggregate display.
Adopted provisionally 2026-08-15.

7. Verification plan

TDD per the house skill; the estimator is the clinically loaded part and gets three independent checks:

  1. Hand-computed unit tests. A small worked example (≤ 10 participants, ties of every kind: event-with-event, event-with-censor, censor-after-last-event) with S(t), Greenwood SE, log-log CI, n-at-risk and cumulative events computed by hand in the test file's comments, asserted digit-for-digit.
  2. Cross-validation against R 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.
  3. Derivation tests. The adtte build derivation is unit-tested in 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.

8. Requirement matrix plan

requirements/time-to-event.md, condensed TTE-* scheme, base-ID form for the evidence-page resolver:

AreaCovers≈ rows
TTE-CFG-*Default mapping, settings sync, direction/ci/param_value validation5–7
TTE-DATA-*Contract checks, CNSR semantics, exclusion counting, group drop warnings4–6
TTE-STAT-*§3 line by line: estimator, ties, Greenwood, log-log CI, band-undefined rules, risk-table numbers, single-derivation guarantee, survfit cross-validation8–10
TTE-CURV-*Step rendering, censor marks, CI ribbon, orientation, axis labelling naming the estimator5–7
TTE-RISK-*Strip table content, tick alignment, group rows, resize behavior3–4
TTE-USER-*Endpoint picker, toggles, filters, legend show/hide, tooltip, selection event, empty states6–8
TTE-GUIDE-*Clinical guide: estimator naming, pointwise-vs-simultaneous, competing-risks limitation with the demo's death example, censoring assumptions3–4
TTE-DEMO-*adtte derivation facts (§5.1), determinism, provenance doc4–5

9. Delivery and the done gate

Increments, sized so one session can take one and stop; one PR to dev per the single-PR-per-session convention:

  1. adtte.csv derivation + tests + DATA_SOURCES.md
  2. km.js with hand-computed tests + the survfit fixture cross-validation
  3. Schema, configure, checkInputs, structureData (TDD)
  4. Chart: steps, censor marks, CI ribbon, orientation
  5. Risk-table plugin
  6. Shell controls, tooltip, selection, empty states, e2e suite
  7. Matrix + evidence + API reference + gallery entry + demo page
  8. NEWS.md under vNEXT (Upcoming); PR to dev

The 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.

10. Risks and what this design does not settle


This design was drafted by Claude Code using Fable 5 in an unattended session — not yet reviewed by @jwildfire. Decisions D1–D6 are adopted provisionally and are overturnable at review before any release candidate.