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:
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.
| Figure | Tool | Status 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 build — safety.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 build — safety.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 population | Recommended 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×ULN | Either; the two overlap in this band. |
| ALT ≥ 3×ULN but TB ≤ 1.5×ULN | Modified waterfall. |
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.
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.
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.
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 | Assessment |
|---|---|
| Public API | None. Class name, factory, settings and the
participantsSelected event are unchanged; src/main.js is untouched by the
refactor. |
| Test files touched | Zero. 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 baselines | Zero 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 IDs | No renumbering. IDs live on tests. Only the “test file” column of the coverage document changes, and only where a unit test file was added. |
| Effort | About 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.
Three options were considered and two rejected on capability before taste.
chartjs-chart-sankey (rejected). It is a left-to-right layered DAG
renderer: nodes are placed by graph depth and flow direction is uniform. Figure 3 is
mirrored about a pinned centre column — the same baseline node emits ribbons from
both faces simultaneously, sized by the union of two arms. Expressing that would mean
fabricating duplicate left/right node identities, overriding automatic placement to force three
fixed columns and a severity sort, and hand-drawing the concern colouring anyway. We would write
the layout algorithm regardless and pay a runtime dependency for a fill loop — a dependency that
also enters the vendored dist/ bundle and the downstream gsm.safety
htmlwidget.scales.x/scales.y and bail out without them; the pattern does not transfer
to a scale-free diagram. It would require mounting dummy scales purely to obtain a chart area, then
hand-rolling Path2D hit-testing with its device-pixel-ratio trap, our own hover state
and tooltip DOM — and it would deliver zero accessibility, in a module whose stated purpose is
regulatory case review.ae-explorer.js,
delta-delta/listing.js), with evidenced screenshot baselines already shipping over SVG.
The object is a diagram — twelve nodes, at most thirty-two ribbons, no scales, no zoom. Hover,
:focus-visible, tabindex and DOM events come free, and because we round
every coordinate to integers the output is more pixel-stable than canvas antialiasing.
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.
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.
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:
| Concern | Transitions | Ribbon direction | Paper |
|---|---|---|---|
| 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.
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:
Hy's Law 5 / 9), so geometry never carries the
message alone.$hepSankey, following the existing convention, so
browser tests assert numbers rather than pixels.
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.
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.
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.
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:
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.
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.
unit_col
(currently unread by hep-explorer), taken as the modal unit for the plotted measure,
falling back to U/L, with a warning when units are mixed.#1f78b4), active bronze
(#b5651d), and new-onset jaundice
green (#2e8b3d) overriding the arm
colour in either arm, exactly as the caption describes. The legend states the precedence explicitly,
because a reader who does not know green wins will miscount arms. A vertical divider marks the arm
boundary, each half labelled with its arm name and count.boxWhiskerPlugin already exists in results-over-time — quartile box,
median rule, 5th/95th whiskers, two-tone mean marker — so it is promoted to a shared
src/box-whisker.js and the donor reduced to a two-line delegation, leaving its own
pixels and evidence baselines untouched (guarded by its own regression requirement). Each flanking
canvas pins its vertical domain to the main chart's, so all three panels are vertically registered
and the boxes are directly comparable with the bars. Each panel shows a baseline box and a
maximum-on-treatment box for its arm, which makes the panel summarise the shift; see
O2.
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.
hep-explorer settings| Setting | Default | Purpose |
|---|---|---|
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_arm | null |
The left side. Resolution order: an explicit value present in the data, then a
/placebo|control/i auto-detect, then none. |
active_arms | null |
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_col | null |
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_uln | 2 |
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_unchanged | false |
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.
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:
| Setting | Default | Purpose |
|---|---|---|
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_arms | null / null | Blue left half and bronze right half; same resolution rules as above. |
jaundice_uln | 2 | New-onset jaundice event threshold, in ×ULN of total bilirubin. |
baseline_tb_max | 1 | The Table 1 cohort rule: exclude participants with baseline bilirubin above this. Deliberately separate from the event threshold. |
apply_tb_cohort | true | Turn the exclusion off, with a prominent note, for exploratory use. |
baseline_col / baseline_value | null / '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%' / 480 | Carried for the R widget bindings. |
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.
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 migrate | 18 (56%) | 23 (72%) |
| Distinct transition types | 8 | 10 |
| Upward (unfavourable, pink) | 12 | 10 |
| Downward (favourable, green) | 6 | 13 |
| Diagonal (no migration) | 14 | 9 |
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.
Three verified findings, any one of which is fatal:
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.
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.
| Property | Value | Why |
|---|---|---|
| Size and labelling | ≈80 participants, identifier prefix ABL-, site “Hepatology ABN-BL Unit (synthetic)” | Same self-naming contract as the shipped CLD- cohort. |
| Arms | Exactly two: ABL: Placebo (40) and ABL: Study Drug (40) | The paper's one-placebo, one-active layout. |
| Visits | A real day-0 baseline plus four on-treatment visits for every participant | The cohort must not reproduce the baseline-fallback defect it exists to exercise. |
| Analytes | ALT, AST, TB and ALP at every visit | The reduction drops any participant missing ALT or bilirubin. |
| ALT reference range | A single value, 40 U/L, cohort-wide; units uniformly U/L | Makes 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) smoothly | A 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×ULN | So the Table 1 exclusion note has something to report — making the applicability rule demonstrable evidence rather than a claim. |
| Direction skew | Active 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 jaundice | 6 active, 1 placebo, each with a large ALT rise and peak bilirubin 2.5–4.0×ULN | The 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 baseline | The 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.
| PR | Contents | Gate |
|---|---|---|
safety.viz#91hep-core-splitbase 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#92hep-migration-viewbase 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#93hep-waterfallbase 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.
| Risk | Mitigation 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. |
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.
hep-waterfall guide is written — ideally checked against the published figure.
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.
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).
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.