Design: abnormal-baseline DILI tools — migration Sankey and ALT waterfall

Requirement jwildfire/obot.roadmap#43 · 2026-07-22 · Draft — seven open judgement calls (§11) await review
Source: Amirzadegan J, Tesfaldet B, Pei YV, Navarro Almario E, Avigan MI, Hayashi PH. Emerging Tools to Support DILI Assessment in Clinical Trials with Abnormal Baseline Serum Liver Tests or Pre-existing Liver Diseases. Drug Safety 2025;48(5):443–453 (doi:10.1007/s40264-024-01511-8; PMID 39932652; PMC11982145).
Implementation: safety.viz#91 · #92 · #93. Baseline for all work: origin/dev at v1.4.1.

1. Context and goals

The eDISH plot — peak alanine aminotransferase against peak total bilirubin, both in multiples of the upper limit of normal, divided into four quadrants by cut-lines at ALT 3× and bilirubin 2× ULN — is the standard population-level screen for drug-induced liver injury in a clinical trial. safety.viz ships it as hep-explorer. Its entire interpretive power rests on one assumption: that participants started normal. Only then does a point in the Hy's-Law quadrant mean that something happened on study.

In a trial that enrols people who are already sick, that assumption fails on day one. In steatohepatitis, chronic viral hepatitis, cirrhosis, alcohol-associated hepatitis, or advanced oncology, a large share of the population — sometimes half — sits in the Hy's-Law quadrant at screening, for reasons that have nothing to do with the investigational product. The plot fills with participants whose position was established before the first dose. Genuine drug-induced injuries are plotted in the same crowd as baseline disease and become indistinguishable from it, and the reviewer is left with no usable population-level view of the trial at exactly the moment hepatotoxicity assessment matters most. These are not edge-case trials; populations with pre-existing liver disease are a large and growing share of development programmes.

It is worth being precise about what is lost, because the remedy is organised around it. eDISH does three jobs: (a) it compares the drug arm against the control arm, (b) it ranks severity, and (c) it identifies the individual participants a reviewer must read case by case. An abnormal-baseline population breaks all three at once. FDA's Division of Hepatology and Nutrition published the problem and its proposed remedies in Drug Safety in 2025 (Amirzadegan et al., cited above), stating plainly that the generalisable utility of eDISH pertains only to trials enrolling patients with normal or near-normal baseline liver tests, and that reviewers currently lack tools for the rest.

Requirement #43 implements the two figures from that paper which safety.viz does not yet cover. Goals, in priority order:

2. What the paper proposes, and what safety.viz already has

The paper presents five figures. Three of them are already in the library, one of them deliberately as a demonstration of what does not work.

FigureToolStatus in safety.viz
Figure 1 eDISH scatter, peak ALT vs peak TB in ×ULN, four named quadrants Shipped hep-explorer scatter view, display: 'relative_uln'.
Figure 2 mDISH — the same plot in multiples of each participant's own baseline Shipped display: 'relative_baseline'. The paper presents this as a negative result: its hypothetical Subjects A and B land on the same point despite B having jaundice and an ALT of 1000 U/L. Dividing by baseline visualises the shift but destroys severity ranking and cannot prioritise individual cases — eDISH functions (b) and (c) are lost. That is precisely why Figures 3 and 4 exist.
Figure 3 Bidirectional Sankey of baseline → on-treatment quadrant shifts, with per-arm cross tables To buildsafety.viz#92
Figure 4 Composite mDISH plot, marks carrying the baseline quadrant of origin Shipped hep-explorer composite view, delivered under safety.viz#67 from Tesfaldet et al., Drug Safety 2024;47:699–710 — the immediate predecessor paper, sharing an author with this one.
Figure 5 Modified waterfall — per-participant baseline vs maximum on-treatment ALT in absolute U/L To buildsafety.viz#93

The paper's Table 1 is not a menu; it is an applicability rule keyed to the enrolled population's baseline liver chemistry, and it is what makes the two missing figures different products rather than two views of one thing:

Baseline populationRecommended tool
ALT ≥ 3×ULN and TB > 2×ULN (for example alcohol-associated hepatitis) Sankey plus composite analysis. The waterfall is marked inapplicable.
ALT ≥ 3×ULN with TB 1.5–2×ULNEither; the two overlap in this band.
ALT ≥ 3×ULN but TB ≤ 1.5×ULNModified waterfall.

3. Topology — one new view, one new module, one shared core

Decision. Figure 3 becomes hep-explorer's third view (view: 'migration', label “Migration (Sankey)”), ordered between the scatter and the composite. Figure 5 becomes a new sibling module hep-waterfall. A new non-renderer directory src/hep-core/ holds the single per-participant hepatic reduction that feeds Figures 3, 4 and 5.

Why Figure 3 is a view and not a module. The paper is explicit that the Sankey and the composite plot are a deliberate two-step replacement for the single eDISH plot. The Sankey answers “which arm is shifting worse, and how severely” — eDISH functions (a) and (b) — but by construction it aggregates, so it cannot name individuals. Function (c) is supplied by the composite plot. The two steps operate on the same trial, the same cohort, the same quadrant definitions and the same per-participant reduction. Keeping them in one module means a reviewer can click a pink ribbon and land in the composite plot with exactly those participants carried across and highlighted. That hand-off is the paper's argument made executable, and it is not purchasable across a module boundary: two modules can exchange a hyperlink, not a selection.

Why Figure 5 is a module and not a fourth view. Table 1 assigns it to a different population. It excludes participants with elevated baseline bilirubin — precisely the participants the migration and composite views exist to serve — and it plots absolute U/L rather than a ratio to ULN or to baseline. Different population, different units, different question. It gets its own gallery card, guide, evidence page and API reference.

The house doctrine in docs/view-selector-inventory.md defines a view as a mutually exclusive switch of the primary display of the same dataset, but it was written retrospectively to decide whether existing single-view renderers should gain a selector; it never ruled on where new capability lands. Rather than diverge from it silently, the migration PR amends it with an explicit promotion test:

View or module? A new representation becomes its own module when it changes the required data contract or the eligible cohort. Otherwise it is a view of the renderer that already owns that cohort.

Under that test Figure 3 is a view — same cohort, same quadrants, same reduction; arm_col is required for that view, enforced by a view-aware guard rather than by the module's global contract — and Figure 5 is a module. The accepted cost of this topology is that Figure 3 has no gallery card of its own; see O5.

4. Splitting hep-explorer

Verdict: split it, and first. src/hep-explorer.js is 2,149 lines on dev — more than twice the next-largest entry file (ae-explorer.js, 944). Size alone would not justify a refactor mid-feature. The blocking problem is structural: the participant selection layer is genuinely bi-view, and three of its members branch on state.view internally (the select control's change handler, its Clear handler, and the active-selection getter). Adding a third view turns three binary branches into three-way branches inside the selection-carry protocol that HEP-SELECT-006 pins — and the migration view needs more from that layer than either existing view: ribbon-to-identifier-set selection, cross-table-cell selection, and an explicit programmatic hand-off into the composite view. The selection layer has to be redesigned before the Sankey can be built at all. This is a prerequisite, not polish.

The five-method view contract

One interface, three implementors. After the split, no shared widget contains a state.view comparison; the only dispatch is in the orchestrator, and a unit test reads the sources to forbid one view file from importing another.

// every src/hep-explorer/views/*.js default-exports this shape
export default {
  id: 'migration',
  label: 'Migration (Sankey)',
  slots: ['sankeyWrap', 'crossTableWrap'],   // which shell slots this view shows
  contributeControls(host, builders),         // replaces the per-view control gate
  render(host, { carriedIds }),               // unifies renderComposite / restoreSelection
  onParticipantsChanged(host, ids),           // replaces the state.view branch in the select widget
  clearSelection(host),
  highlight(host),
  teardown(host)
};

Alongside it, src/hep-explorer/selection.js becomes the view-agnostic selection layer — mount, sync, set, clear, carried, updateTraceHeader, annotationText, dispatch — and is the sole writer of the participantsSelected event.

Internal files, not separate npm modules

SafetyViz.hepExplorer({view}) is a released, versioned surface, and it is the surface the planned Widget_HepExplorer.R binding in gsm.safety consumes by module name. Views are an implementation detail. Publishing them as separate modules would fabricate a public boundary the paper does not have, multiply the one-entry-per-renderer model across site/config.json, evidence routing, the API generator and the requirements extractor, and destroy the selection-carry behaviour outright.

A hard constraint reinforces this. scripts/api/extract.mjs hardcodes the API source files for a module as src/<module>.js and src/<module>/configure.js. Therefore no public (non-@private) method may leave src/hep-explorer.js — only internals move — or npm run docs:api fails and the site build breaks with it.

Cost, and the merge gate

CostAssessment
Public APINone. Class name, factory, settings and the participantsSelected event are unchanged; src/main.js is untouched by the refactor.
Test files touchedZero. src/hep-explorer/composite.js becomes a @deprecated pure re-export shim, so its 332-line unit test keeps its import path and every assertion; structureData.js re-exports the moved statistics the same way. The shims are deleted in a final cleanup PR once nothing imports them.
Evidence baselinesZero drift is the acceptance criterion, not a hope. Evidence freshness is keyed by test title and screenshots by pixels; a moves-only refactor changes neither.
Requirement IDsNo renumbering. IDs live on tests. Only the “test file” column of the coverage document changes, and only where a unit test file was added.
EffortAbout 2.5 days, and it is on the critical path for both figures.
# PR merge gate — run it literally
git diff --stat docs/evidence/          # MUST be empty
git diff --name-only origin/dev -- tests/ \
  | grep -v '^tests/unit/hep-explorer/\(quadrants\|subjects\|arms\|migration\|selection\)\.test\.js$'
                                        # MUST be empty

If a pixel moved, the refactor is wrong: find the semantic difference rather than rebaselining. The one deliberate exception is the baseline-identity correctness fix described in §6, which is landed as its own isolated commit precisely so its evidence churn stays attributable.

5. Figure 3 — the migration Sankey

5.1 Technology: hand-rolled inline SVG

Three options were considered and two rejected on capability before taste.

The shared shell contract requires a visible canvas or table in the main column; the migration view satisfies it twice over, because the per-arm cross tables are real <table> elements (the same route qt-explorer's Categorical view already takes). An explicit browser test pins that the view renders both an svg and a table, so a later refactor cannot drop the table and break the contract invisibly. Geometry is computed in a pure, DOM-free sankeyLayout.js and unit-tested as numbers.

5.2 Severity ordering — the one real design decision

The Sankey's vertical axis has to mean something: the paper's whole visual claim is that unfavourable shifts travel up. That requires ranking the four quadrants by severity, and the existing COMPOSITE_QUADRANTS constant is factor order, not severity order. Worse, the shipped CONCERN_MATRIX deliberately declines to rank Cholestasis against Temple's Corollary: both directions between them are coded neutral. If they were stacked as distinct tiers, a Cholestasis to Temple's Corollary ribbon would travel visually upward while being painted neutral — a chart that contradicts itself about its own core mapping.

Resolution: three tiers, four nodes. Hy's Law occupies the top tier and Normal & Near-Normal the bottom. Cholestasis and Temple's Corollary are adjacent sub-nodes inside one shared tier, under a single tier label “Single-analyte elevation”, so the diagram never asserts an ordering between them.

The governing rule follows: ribbon colour is derived from concernOf(baseline, onTreatment) and never from the sign of the vertical movement. CONCERN_MATRIX is the clinical source of truth and is reused unmodified. Colour comes from the clinician; geometry comes from the tiers; and because of how the tiers were chosen, the two provably agree across all sixteen quadrant pairs:

ConcernTransitionsRibbon directionPaper
Red (5) NN → CH, NN → TC, NN → HL, CH → HL, TC → HL all up Unfavourable / pink. Five is exactly the caption's count of upward shift types.
Green (5) CH → NN, TC → NN, HL → NN, HL → CH, HL → TC all down Favourable / green.
Yellow (2) CH → TC, TC → CH lateral Neutral — and the reason the two share a tier.
Grey (4) the diagonal — NN → NN, CH → CH, TC → TC, HL → HL lateral (self-flow) No migration.

HL = possible Hy's Law · CH = cholestasis / isolated hyperbilirubinaemia · TC = Temple's Corollary (isolated ALT) · NN = normal & near-normal. The correspondence is pinned as a unit test (HEP-MIG-009), so geometry and clinical semantics cannot drift apart later.

5.3 Layout

layoutSankey({counts, cells, width, height, hideDiagonal}) returns nodes, ribbon paths and the scale, with no DOM access. The drawing surface is a fixed viewBox of 980×540 scaled to width: 100%, so the internal coordinate system never depends on the element's measured width and screenshots are stable across viewport jitter. Bands run left to right: placebo on-treatment nodes, placebo ribbons, the centre column of baseline nodes, active ribbons, active on-treatment nodes, with labels outside each edge. Six properties carry the design:

  1. One shared scale, never rescaled per column or per arm. “Does the drug arm have more upward shifts?” is the entire clinical question, so a pixel must mean the same number of participants everywhere. A single pixels-per-participant unit is derived from the larger of the centre and side totals and applied to every node and every ribbon.
  2. Severity stacking top to bottom in every column, with a full-tier gap between tiers and a smaller gap between the two sub-nodes of the shared tier. Faint full-width tier rules sit behind everything, so “up is worse” reads without consulting the labels.
  3. Centre nodes are sized by the larger of their two arms' counts, and each face allocates only its own arm's count, centred vertically. The shortfall shows as symmetric padding on that face — truthfully “this arm has fewer participants starting here”. Node labels carry the literal per-arm counts (for example Hy's Law 5 / 9), so geometry never carries the message alone.
  4. Deterministic anchor allocation. Each centre node keeps two independent cursors, one per face; each side node keeps one. Every face walks the opposite endpoint's quadrants in severity order, advancing by count times the scale. Contiguous slices, no sorting by size, no randomness — repeated renders are byte-identical.
  5. One ribbon formula for both directions. The cubic control offset is half the horizontal span, which is negative on the left side, so the mirror comes for free rather than from a second code path. Self-flows are straight bands, not loops, and because the tiers align they read as horizontal by construction.
  6. Stability details that make evidence trustworthy. Zero-count nodes render as a one-pixel stub with a dimmed label so the four-row grid stays geometrically stable as filters change; ribbons paint thickest-first so thin ones remain on top and clickable; node fills reuse the same quadrant hexes as the composite view, which is what makes the two-step hand-off legible; and every coordinate is integer-rounded before serialisation. The computed geometry is stashed on the root element as $hepSankey, following the existing convention, so browser tests assert numbers rather than pixels.

5.4 Interaction, accessibility, and the hand-off

Every ribbon is a single <path> carrying data-side, data-pre, data-post and data-count, plus tabindex, role="button" and an accessible name of the form “4 participants shifted from Normal & NN to Hy's Law on active drug — unfavourable”. Hovering applies a class to the ribbon and its two endpoint nodes and shows an HTML tooltip (not a native <title>, which would never appear in a screenshot). Clicking, or pressing Enter or Space, selects that flow's participants and dispatches participantsSelected. Cross-table cells use the same arm-and-quadrant-pair key, so ribbon and cell selection are parity-tested rather than coincidentally equal. At least one browser test clicks a centroid computed from $hepSankey, so a geometry-correct but pointer-wrong bug cannot pass.

The hand-off is the point. After a selection the footnote reads “4 participants shifted Normal & NN → Hy's Law on active drug”, with a button labelled “Review these 4 in the composite plot”. The button switches the view and the existing carried-selection mechanism restores exactly those participants, highlighted, in the composite panels. That is eDISH function (c), delivered by Figure 4 at the moment Figure 3 raises the question.

The paper's own blind spot is made visible. A grey Hy's-Law-to-Hy's-Law self-flow looks reassuring, but it is exactly where the acknowledged limitation lives: a shift view cannot detect worsening within a category. When that cell is non-empty the view renders a caution note stating the count and offering to select those participants for individual review.

5.5 Cross tables

The existing migrationMatrix already produces the paper's cross table — rows are baseline quadrant, columns are on-treatment quadrant, sixteen dense cells with row, column and grand totals — and the shipped renderer already shades each cell by concern. It needs no modification. The only change is to partition participants by arm and call it once per arm. The migration view renders its tables in severity order so they read in the same direction as the plot; the composite view keeps its existing pooled table in its existing order, so its requirement and its baseline are untouched. A test asserts that every cross-table cell count equals the participant count of the corresponding ribbon.

6. Figure 5 — the modified ALT waterfall

Each participant is one floating bar spanning from their baseline ALT to their maximum on-treatment ALT, so a bar points up for a rise and down for a fall. A black line traces every participant's baseline across the ordered cohort. Three canvases sit in a CSS grid — a per-arm box-and-whisker panel, the main chart, a second box panel — matching the paper's flanking layout. This is native Chart.js: floating [base, peak] bars are a v4 feature, and the baseline trace is an ordinary line dataset drawn above the bars so it stays visible where bars cross it.

6.1 Maximum on-treatment, excluding baseline by identity

This is the one real correctness blocker, and it is a defect in shipped code rather than a gap. Two traps have to be avoided, both documented in the source:

  1. The absolute maximum must be taken over the raw value directly, never by reading the raw value off the record that maximised the ×ULN ratio. The existing reduction takes each peak as an independent maximum precisely because they can fall on different visits when the reference range varies — and it does vary: the demo data carries five distinct ALT upper limits (32, 34, 35, 40 and 43 U/L). The ratio-maximising record is not necessarily the value-maximising record.
  2. The baseline record must be excluded by identity, not by study day. The shipped code excludes it with a day > 0 test. Verified: 24 of 318 participants in the demo data have no day-0 ALT record and fall back to an unscheduled visit at visit number 1.1 to 1.3, so that test counts their own baseline as an on-treatment value. Their computed peak can then never fall below their baseline — which silently erases exactly the “bars dropping below baseline” signal Figure 5 is built around. The 24 break down as 10 placebo, 7 Xanomeline high dose and 7 Xanomeline low dose; zero are in the CLD cohort, so the composite view's demo story is unchanged even though its results change for those 24.

Both are fixed in the shared reduction, alongside an explicit baseline_col / baseline_value setting so real ADaM data can name its baseline through ABLFL instead of inheriting a heuristic. The reduction then surfaces, per participant: baseline and peak ALT and bilirubin in both absolute and ×ULN units, the peak's study day, the arm and its designated side, and the baseline-jaundice and new-onset-jaundice flags — one reduction, one cohort, one baseline definition, shared by Figures 3, 4 and 5.

6.2 Cohort, ordering and the unimodal invariant

The plotted cohort applies the paper's Table 1 rule: participants with baseline total bilirubin above the configured maximum are excluded, as are participants whose arm is designated neither placebo nor active. Both counts are reported separately in the notes — “12 participants excluded: abnormal baseline bilirubin (paper Table 1)” — so the applicability rule is demonstrable evidence rather than a claim.

Ordering: placebo participants ascend left to right by baseline ALT, then active participants descend, so the highest baselines of both arms meet at the seam. Ties break on participant identifier so repeated renders are pixel-identical. The caption's two clauses contradict each other read literally; this design takes “the highest baselines meet in the centre” as the controlling one and pins it as a testable invariant — the baseline series is non-decreasing across the placebo span and non-increasing across the active span, so the black trace is a single mountain with its apex at the arm boundary. See O3.

6.3 Axes, colour and the flanking panels

7. Data contract and settings

The record contract is unchanged for both figures: long format, one row per participant per measure per visit, as hep-explorer already requires. There is no pipeline change. Every addition is a setting.

7.1 New hep-explorer settings

SettingDefaultPurpose
arm_col'ARM' Treatment arm. Structural for the migration view, since it decides left from right. Deliberately not added to the schema's global required list — that would break the shipped scatter and composite views for arm-less data — and enforced instead by a view-aware guard. Auto-detected across ARM, ACTARM, TRT01A and TREATMENT when the named column is absent.
placebo_armnull The left side. Resolution order: an explicit value present in the data, then a /placebo|control/i auto-detect, then none.
active_armsnull The right side. When null, all non-placebo arms pool right and the note names them. A sidebar control lets a reviewer isolate a single dose level.
baseline_colnull Optional explicit baseline flag, for example ADaM ABLFL. Takes precedence over the day-0-else-earliest heuristic.
baseline_value'Y'The flag value marking the baseline record.
jaundice_uln2 New-onset jaundice threshold in ×ULN of total bilirubin; defaults to the same constant as the quadrant cut so the flag and the quadrants stay mutually consistent.
hide_unchangedfalse Migration view: suppress the diagonal, no-migration ribbons. The hidden count stays in the note and in the cross table.
view'scatter' The enum gains 'migration'. Selector order is scatter, migration, composite — the paper's order, and the direction of the hand-off.

Cutpoints are not configurable in the migration view. Classification uses the fixed constants ALT 3×ULN and bilirubin 2×ULN, identical to the shipped composite view and faithful to the paper, rather than the scatter view's user-overridable cuts. This is a deliberate divergence, documented in the coverage document and the guide; see O6.

7.2 hep-waterfall settings

The lab-mapping block is byte-identical to hep-explorer's — identifier, measure, value, unit, reference-range, study-day and visit columns, measure value mapping, filters and groups — so a single settings list can drive both R widget bindings. Beyond it:

SettingDefaultPurpose
measure'ALT'Plotted analyte. The paper uses ALT; AST and ALP are available.
arm_col'ARM'In this schema's required list — a one-armed waterfall is meaningless. Matches the qt-explorer precedent.
placebo_arm / active_armsnull / nullBlue left half and bronze right half; same resolution rules as above.
jaundice_uln2New-onset jaundice event threshold, in ×ULN of total bilirubin.
baseline_tb_max1The Table 1 cohort rule: exclude participants with baseline bilirubin above this. Deliberately separate from the event threshold.
apply_tb_cohorttrueTurn the exclusion off, with a prominent note, for exploratory use.
baseline_col / baseline_valuenull / 'Y'As above.
uln_display'band'One of band, per-participant or none.
summary'baseline_peak'Two boxes per flanking panel, or one (O2).
width / height'100%' / 480Carried for the R widget bindings.

7.3 Definitions and degradation

An arm resolves to placebo, active or undesignated; undesignated participants are excluded from the migration view and the waterfall, with a counted note, and are unaffected in the scatter and composite views. New-onset jaundice requires baseline bilirubin at or below the threshold and a maximum on-treatment bilirubin above it; both clauses stay explicit even though the first is implied inside the waterfall's post-exclusion cohort, so the predicate stays correct when the exclusion is switched off. One verified plumbing gap is closed at the same time: the arm column joins the retained metadata columns unconditionally, because today the arm value reaches a participant record only if it happens to be registered as a group or a filter, and otherwise every participant collapses into a single “(missing)” arm.

Failure modes degrade rather than throw. When arm_col is unmapped the Migration option is disabled with an explanatory tooltip rather than letting a reviewer click into an error; when fewer than two sides resolve, the Sankey renders unidirectionally with a warning. The failure mode being designed against is the current silent one, where everything collapses into one bucket and the chart renders confidently wrong.

8. Demo data

8.1 Figure 3 runs on the existing demo data

site/data/adbds.csv is the demo dataset for six available renderers (histogram, outlier-explorer, results-over-time, shift-plot, delta-delta and hep-explorer), so regenerating it would churn evidence baselines across five renderers unrelated to this work. It is not touched. It does not need to be: the synthetic chronic-liver-disease cohort already inside it — added for the composite view — produces exactly the paper's Study-2 narrative shape. Verified counts, with quadrant thresholds at ALT 3×ULN and bilirubin 2×ULN (strict and non-strict comparisons give an identical matrix):

CLD: Placebo (n = 32)CLD: Study Drug (n = 32)
Participants who migrate18 (56%)23 (72%)
Distinct transition types810
Upward (unfavourable, pink)1210
Downward (favourable, green)613
Diagonal (no migration)149

The drug arm shows fewer unfavourable and more than twice the favourable shifts, including Hy's-Law-to-normal, -to-cholestasis and -to-Temple's flows the placebo arm lacks. No new filtering machinery is needed — the demo simply designates the pair through the new arm settings. The 231 undesignated pilot participants fall out as a counted note, which is itself the demonstrable evidence for the exclusion requirement, and the active-arm control lets a reviewer switch to a pilot dose arm and see what an honestly near-empty Sankey looks like.

Scoping the demo to this cohort is mandatory rather than cosmetic: pooling all 295 eligible participants puts 235 (80%) on the no-migration diagonal with four hairline threads and zero green ribbons. An unscoped demo would look broken. And all 24 participants affected by the baseline-identity defect are pilot-arm; zero are in the CLD cohort, so the migration demo is entirely clean of it.

8.2 Figure 5 demonstrably cannot run on it

Three verified findings, any one of which is fatal:

  1. Two green bars exist in the entire file. CLD-9026 (study drug, bilirubin 1.00 → 2.92×ULN) and CLD-9056 (placebo, 1.00 → 2.83×ULN). One per arm, which actively contradicts the paper's “several developed jaundice” on active drug. At a peak bilirubin above 3×ULN from a normal baseline, the count is zero.
  2. The target population is empty. After the mandated baseline-bilirubin exclusion, 61 of 295 participants are dropped, and of the 234 survivors zero have a baseline ALT at or above 3×ULN. Only 19 exceed 1×ULN and only 4 exceed 2×ULN. The figure exists for a population this dataset does not contain.
  3. The baseline mountain is flat and discontinuous. 215 of the 234 survivors sit between 6 and 40 U/L, then 11 CLD stragglers jump to 62–85 U/L. And the arm contrast runs backwards relative to the paper: placebo has the most ALT decreases (12, against 3 for Xanomeline low dose).

Therefore Figure 5 ships with a purpose-built synthetic cohort in a new file, site/data/adbds-abnbl.csv, generated by a committed script following exactly the discipline of the existing composite-cohort generator: a fixed-seed PRNG so output is byte-identical on every run, idempotent stripping by identifier prefix so it can be re-run after the source data is rebuilt, explicit labelling as synthetic, and provenance recorded in docs/DATA_SOURCES.md. The header is the verified adbds.csv header, unchanged.

PropertyValueWhy
Size and labelling≈80 participants, identifier prefix ABL-, site “Hepatology ABN-BL Unit (synthetic)”Same self-naming contract as the shipped CLD- cohort.
ArmsExactly two: ABL: Placebo (40) and ABL: Study Drug (40)The paper's one-placebo, one-active layout.
VisitsA real day-0 baseline plus four on-treatment visits for every participantThe cohort must not reproduce the baseline-fallback defect it exists to exercise.
AnalytesALT, AST, TB and ALP at every visitThe reduction drops any participant missing ALT or bilirubin.
ALT reference rangeA single value, 40 U/L, cohort-wide; units uniformly U/LMakes the absolute reference line well defined; the band collapses to a line.
Waterfall sub-population (≈58)Baseline bilirubin ≤ 0.8×ULN, baseline ALT spanning 1.0–8.0×ULN (40–320 U/L) smoothlyA real monotonic mountain rather than a step. The margin below 1.0 keeps PRNG jitter from tripping the exclusion.
Excluded sub-population (≈22)Baseline bilirubin 1.5–4.0×ULNSo the Table 1 exclusion note has something to report — making the applicability rule demonstrable evidence rather than a claim.
Direction skewActive arm skewed to ALT decreases (≈24 of 40, against ≈10 of 40 on placebo)The caption's large number of bronze bars dropping below baseline.
New-onset jaundice6 active, 1 placebo, each with a large ALT rise and peak bilirubin 2.5–4.0×ULNThe asymmetric, drug-attributable green-bar signature; the margin above 2.0 keeps the flag unambiguous.
Rise tail≈8 active participants with peak ALT 3–6× their own baselineThe substantial-increase cases the paper prioritises for review.

The cohort's invariants are themselves unit-tested — two arms, a day-0 baseline for all four analytes on every participant, a single ALT reference range, the sub-population sizes, the jaundice counts, and the arm direction skew — so a regenerated file that drifts from this specification fails CI rather than quietly changing the figure. Whether shipping on synthetic data is the right call at all is O4.

9. Delivery — three PRs

PRContentsGate
safety.viz#91
hep-core-split
base dev · ≈4 days
The baseline-identity correctness fix (§6.1) as an isolated first commit with per-participant proof. Then src/hep-core/ extracted by moving code out of the composite and data-structuring files; composite.js and the moved half of structureData.js become @deprecated re-export shims; boxWhiskerPlugin promoted to src/box-whisker.js with results-over-time reduced to a delegation; the entry file split into views/{scatter,composite}.js, selection.js and styles.js against the five-method contract; arm plumbing, severity tiers, absolute-unit peaks and box statistics added to hep-core. Empty docs/evidence/ diff apart from the fix commit's documented churn; no pre-existing test file modified; npm run docs:api green (no public method leaves the entry file).
safety.viz#92
hep-migration-view
base hep-core-split · ≈5.5 days
The pure sankeyLayout.js and its unit tests, then the migration view: SVG painting, per-arm cross tables, ribbon and cell selection with parity tests, the two-step hand-off, the Hy's-Law self-flow caution, the hide-unchanged and active-arm controls, accessibility, the three-by-three view-carry matrix, the demo's arm settings, the guide section, the shared hepatic-tools decision table, and the “View or module?” amendment to the view-selector inventory. Every hep-explorer page screenshot shifts by one control row (§10, R1) — stated in the PR description as the only expected visual change and verified on one diff.
safety.viz#93
hep-waterfall
base hep-core-split · ≈5.5 days, parallel with #92
The synthetic cohort generator and its invariant tests, then the module end to end: schema, configuration, input checks, data structuring, scales, plugins, the flanking panels, the demo page, the gallery card, the hero, the guide, the evidence page and the API reference. Also the related-tools strip that renders Table 1 as an applicability chooser on both hepatic demo pages. The full renderer done-gate (below). File ownership is disjoint from #92 by construction — src/main.js, site/config.json and the shell strip belong to this PR alone.

A short cleanup PR follows once both have landed: re-point the unit tests at src/hep-core/ and delete the shims. It changes no behaviour, so its gate is again an empty evidence diff. Total effort is about 14.5 working days sequential, or roughly 10–11 days of wall-clock with the two parallel lanes.

Development is test-first throughout, from an authored requirement matrix rather than from the code: 85 new requirement rows (47 across HEP-CORE, HEP-MIG, HEP-XTAB, HEP-STEP, HEP-ARM and HEP-A11Y for the migration work; 38 HWF-* rows for the waterfall) are filed in obot.agent/docs/requirements/ before the canonical evidence run, so the published evidence pages carry real requirement text rather than bare identifiers.

CI order is unchanged: format check, build, committed-bundle check, unit tests, site build (which is also the API-completeness and link-validation gate), then Playwright including the shell contract and visual regression, then the evidence and requirements checks. For a brand-new module the evidence baselines have a bootstrap cycle — the module must be marked available before the site will build, but the site build is what an evidence run needs — resolved as qt-explorer's history already shows: seed local previews, build, capture the hero, then run the Linux evidence-update workflow against the feature branch to rewrite every screenshot as a canonical baseline. Budget two rounds per figure. The done-gate for hep-waterfall is the standard six: gallery card, live demo on committed data, shared shell chrome, evidence page, API reference, and a clean site build deployed before the requirement moves to Released.

10. Risks

RiskMitigation and residual
R1 — A third View option reflows the sidebar and churns every hep-explorer page screenshot. Evidence capture takes a page screenshot and the View list sits in the controls, so a third option shifts everything below it by about one row across some 36–40 baselines. Confined to the migration PR, where new captures are expected anyway; stated in the PR description as the only expected visual change and verified on one diff; the split PR's zero-drift gate keeps refactor churn separate from feature churn. Residual: accepted. This is the honest cost of the view topology, and the two-step hand-off is not purchasable any other way.
R2 — SVG text renders differently on macOS and Linux CI, and the Sankey is label-dense; font metrics differ across platforms more than shape rasterisation does. Integer-rounded geometry and a fixed viewBox; an explicit font family in the module stylesheet rather than inheritance; short node labels; the Linux evidence-update workflow, budgeted at two rounds. Most importantly, every Sankey behaviour is asserted through data attributes and the stashed geometry object, so a screenshot is documentary evidence and never the correctness gate. Residual: bounded, not eliminated.
R3 — The demo Sankey is diagonal-dominant and may read as one fat ribbon: even scoped to the CLD cohort, 14 of 32 placebo and 9 of 32 drug participants sit on the diagonal, and self-flows are the thickest ribbons. The hide-unchanged toggle, off by default and one click away, with the hidden count kept in the note and the table; the per-arm cross tables carry the numbers regardless, so the clinical claim never depends on ribbon legibility. Residual: the hero must show the diagonal to be honest, and will look diagonal-dominant. Accepted.
R4 — The baseline fix changes shipped results for a released renderer, and may be treating a symptom: the 24 affected participants have no baseline-visit ALT row at all, so “earliest record” lands on an unscheduled visit, and excluding it from the peak does not establish that it was ever the right baseline. Ship both halves — the predicate fix and the explicit baseline-flag setting, so real ADaM names its own baseline rather than inheriting a heuristic; document the fallback semantics in the guide; land it as an isolated commit with per-participant proof.
R5 — The waterfall demos on fabricated data. Verified unavoidable (§8.2). Self-naming identifiers and arm strings, a demo note, a deterministic re-runnable generator, provenance in DATA_SOURCES.md, and framing the cohort in the guide as a specification of the population the paper targets rather than as a trial. The ≈22 excluded-by-bilirubin participants mean the chart is continuously exercised on data designed to fail its own inclusion rule. Honest assessment: this is mitigation, not a fix. Authoring the cohort and then demoing on it is close to circular; the real fix is a genuine abnormal-baseline dataset. See O4.
R6 — Two hepatic products; a reviewer may never find the second, or may not know which to use. Population-first gallery blurbs that answer “is this for my trial?” before naming a chart type; adjacent ordering in the gallery; a related-tools strip rendering Table 1 as an applicability chooser on both demo pages; a shared decision-table guide. Accepted cost, stated plainly: selection does not carry between the two modules — a link is all you get. That is defensible because the paper assigns them to different trials.
R7 — Three views sharing one selection layer enlarges the selection-carry regression surface. The split is moves-only with the existing browser suite as its oracle; the five-method contract means no shared widget branches on the active view again; a source-reading test forbids cross-imports between view files; the migration PR adds the three-by-three carry matrix so a third view cannot silently break the first two.
R8 — Hero capture picks the wrong element for SVG-first renderers. The hero locator matches a canvas or a table and takes the first. hep-explorer's demo default view stays composite, so its hero is unaffected today; the migration PR adds an svg branch to the locator with a comment naming the reason. One line of defensive work.
R9 — Requiring an arm column makes the two new figures reject data hep-explorer accepts. Auto-detection across four column names; an input-check message that names the setting and lists the columns actually present; the Migration option disabled with a tooltip rather than throwing; single-arm degradation to a unidirectional Sankey with a warning. The failure mode being prevented is the current silent one.

11. Open judgement calls

Seven decisions in this design are judgement calls rather than derivations, and each is a place where a reviewer's answer would change the code. They are ordered by how expensive they become if answered late.

Review decisions (2026-07-22, @jwildfire). On safety.viz#97: O1 — resolved, keep the shared severity tier as designed (Cholestasis and Temple's Corollary remain adjacent sub-nodes of one tier). O4 — resolved, ship the waterfall on the synthetic ABL- cohort; the search for a genuine abnormal-baseline dataset is filed as a follow-up. Both new charts — the migration Sankey view and the hep-waterfall module — ship as prototypes in the upcoming v1.5 release: each carries a prototype banner in the app and a "Prototype" badge on its site pages.

O1 [RESOLVED — keep the shared tier] — Should Cholestasis and Temple's Corollary really share one severity tier, or does Cholestasis belong strictly above it? The paper does not publish its vertical node order, and the shipped concern matrix deliberately marks movement between these two as neutral in both directions. This design makes them sub-nodes of one tier so that geometry and colour cannot contradict each other. A hepatologist may hold that high bilirubin is prognostically worse than isolated ALT elevation and should sit strictly above it. Recommendation: keep the shared tier. Needed before the layout work in safety.viz#92 begins — it is a clinical-interpretation call, and a late change is markedly more expensive than the question. If they are ranked, the node order becomes a setting, and the “neutral ribbon travelling visually upward” contradiction returns and needs its own visual treatment.
O2 — Two boxes per flanking panel (baseline and peak), or one? The caption says only “summary box-and-whisker plots, one per arm” without stating the quantity. This design reads the panel's job as summarising the shift, since that is what the bars show, and exposes a setting for the single-box alternative. Recommendation: two boxes by default. Needed before the hep-waterfall guide is written — ideally checked against the published figure.
O3 — Which clause of the waterfall caption controls the ordering? “The highest baselines meet in the centre” and “active drug subjects run right-to-centre highest-to-lowest” contradict each other read literally. This design takes the former as the controlling invariant, which makes the black trace a single mountain peaking at the arm boundary, and pins it as a test. Recommendation: confirm against the published figure before safety.viz#93 implements the ordering. Reversing it later inverts half the chart and every baseline screenshot.
O4 [RESOLVED — ship the synthetic cohort] — Ship the waterfall on a synthetic cohort, or on the honest, nearly empty real result? This design chooses the synthetic ABL- cohort so the figure demonstrates what it is for. The alternative — ship the module against the real demo data, showing a nearly empty result with a caption explaining why — is worse marketing and better science. Authoring a cohort and then demoing on it is close to circular reasoning, and calling that out is more honest than mitigating it quietly. Recommendation: ship the synthetic cohort, framed in the guide as a specification of the target population, and file the search for a genuine abnormal-baseline dataset as an explicit follow-up rather than pretending it away. Worth an explicit decision rather than an assumption.
O5 — Is it acceptable that Figure 3 gets no gallery card of its own? As a view rather than a module, the migration Sankey has no gallery entry, no hero, no evidence page of its own, and no dedicated R widget: it is reached as hepExplorer(data, settings = list(view = "migration")). If product surface matters more than the hand-off, this is the one decision in the design to reverse — and reversing it means giving up the two-step hand-off requirements permanently. Recommendation: keep it as a view; mitigate discoverability through the gallery blurb, the related-tools strip and the shared decision-table guide (R6).
O6 — Hard-coded cutpoints in the migration view: document the divergence, or resolve it? The migration view uses the fixed FDA constants rather than the scatter view's user-overridable cuts, to stay faithful to the paper and identical to the composite view. Doing so makes visible a latent inconsistency that is already in the shipped code: a user who overrides the bilirubin cut today moves the scatter's quadrant lines but not the composite view's quadrant assignments. Recommendation: document the divergence now and resolve the shipped inconsistency as its own requirement. This is a deliberate scope decision, and worth confirming rather than inheriting.
O7 — Ship hep-waterfall flagged experimental? Following the qt-explorer precedent, the gallery entry is marked experimental so the settings surface is not frozen while O1 to O4 remain open, and the flag is dropped once the guide lands and the clinical review passes. Recommendation: yes, experimental at first release.