Design #35 — nepExplorer migration: the KDIGO kidney-safety explorer

Requirement jwildfire/obot.roadmap#35 · follows the migration assessment (2026-07-15) · sibling of #30 (hep-explorer) and #36 (qt-explorer) · demo data folds into #25 · D1–D9 all decided (D4–D9 on 2026-07-28)

Source of truth: the SafetyGraphics/nepExplorer Shiny app, v1.0.0 (master). The deprecated-js branch is a UX reference only — it is the Webcharts stack safety.viz replaces.

1. Summary

nepExplorer screens trial lab data for acute declines in renal function using the KDIGO acute-kidney-injury staging criteria. Its main object is a scatter — one point per participant, maximum fold change in serum creatinine on x against maximum absolute change on y, over coloured L-shaped stage zones — paired with a stage summary table and a click-through patient profile. That is the same shape as hep-explorer's eDISH quadrant plot, one organ over, so the architecture question was settled by the assessment: it is a canvas-first safety.viz module on the proven lifecycle, and the stage zones are a Chart.js background plugin like quadrantPlugin.

This design does four things:

Every number in §5 was computed from the files themselvessafety.viz/site/data/adbds.csv at dev, and RhoInc/data-library's data/clinical-trials/renderer-specific/adbds.csv — with baseline taken as the participant's baseline-flagged visit (Baseline / Screening), maximum fold and maximum delta taken over that participant's post-baseline records, and µmol/L converted at 88.4. It is a scoping calculation done outside the module, not module output; the implementation will reproduce it under test.

2. What is already decided (D1–D3)

D1 — Phase-2 demo data · DECIDED 2026-07-19. Derive creatinine-based eGFR (CKD-EPI) and retain the RhoInc renderer-specific dataset as a nep-specific demo. Folds into #25.

D2 — Phasing · DECIDED 2026-07-19. Ship Phase 1 (the KDIGO creatinine scatter) as a standalone renderer at the standard done gate; Phase 2 (the patient profile) starts once D1's data lands.

D3 — Visit animation · DECIDED 2026-07-19. The scatter's plotly time animation is deferred to a follow-up enhancement; not part of v1.

D8–D7 below are new and open. D8 and D9 are refinements of D1 in light of §5's measurements; D4–D7 are the clinical-logic calls the port has to make and the source does not answer cleanly.

3. The clinical logic, re-derived

3.1 What KDIGO actually says about creatinine

The KDIGO AKI criteria stage a participant on serum creatinine three ways:

StageFold change (value ÷ baseline)Absolute criteria
11.5 – 1.9×or an increase of ≥ 0.3 mg/dL
22.0 – 2.9×
3≥ 3.0×or an increase to ≥ 4.0 mg/dL (or initiation of renal replacement therapy — out of scope, it is not a lab value)

Two consequences the port has to respect. First, the absolute-change axis carries exactly one KDIGO cut-point — 0.3 mg/dL, and it only ever produces Stage 1. There is no KDIGO Stage 2 or Stage 3 defined on absolute change. Second, the ≥ 4.0 mg/dL rule is about the absolute value reached, not a change, so it is not a region of the (fold, delta) plane at all.

3.2 Where the R source disagrees with itself

Reading R/creatinine_data_fcn.R and R/creatinine_scatter_charts.R side by side:

The table stages absolute change with

DELTA_STAGE = case_when(
  DELTA_C > .3  ~ "Stage 1",
  DELTA_C > 1.5 ~ "Stage 2",
  DELTA_C > 2.5 ~ "Stage 3",
  TRUE ~ "Did not trigger Delta Creatinine Stage"
)

and because case_when returns the first match, every delta above 0.3 is labelled Stage 1 — the Stage 2 and Stage 3 branches are unreachable. That much was already flagged on #35. The sharper finding is what the chart does with the same numbers: draw_creatinine_scatter paints nested rectangles, largest first, so the visible zone boundaries are descending — Stage 3 fills the panel, Stage 2 is overpainted at x<3 ∧ y<2.5, Stage 1 at x<2 ∧ y<1.5, and white at x<1.5 ∧ y<0.3.

So in the shipped app, a participant with a 2.0 mg/dL rise and no fold change plots inside the orange Stage-2 zone and is labelled "Stage 1" in the table beside it. The chart geometry is right and the table is wrong; they were written from the same cut-points in opposite order. Porting either one alone would carry the disagreement forward.

Two smaller fidelity notes from the same file, offered as observations rather than defects to fix upstream:

D4 — What the stage zones encode · DECIDED 2026-07-28. Faithful-to-source (the four nested rectangles above, with the table's ordering corrected to match the chart) · KDIGO-proper (a fold-change ladder at 1.5/2/3 plus a single 0.3 mg/dL Stage-1 trigger on the delta axis) · parameterized cut-points with KDIGO-proper defaults.

DECIDED 2026-07-28 — parameterized, KDIGO-proper defaults. The 1.5 and 2.5 mg/dL absolute cut-points in the source are not KDIGO criteria and are not in nepExplorer's own outline.md either (which proposed 0.7/1.2 alongside eGFR percent declines) — three different ladders across two files and a spec. Rather than pick one of the three and inherit the argument, the module takes the KDIGO ladder as its default and exposes the cut-points as a stages setting, exactly as hep-explorer exposes cuts. A sponsor who wants the original rectangles back sets three numbers.

Under the default, the zone under a point is the worse of the two axes, which keeps the L-shaped silhouette a nepExplorer reader recognises:

Δ (mg/dL) │ Stage 1 │ Stage 2 │ Stage 3 │ │ │ 0.3├───────────┤ │ │ no stage │ │ 0└───────────┴─────────┴────────── fold × 0 1.5 2 3

Stage 1 is everything at or above 0.3 mg/dL up to 2× fold, plus the 1.5–2× band at any delta; the no-stage box is the one region where both criteria are clear.

D5 — The ≥ 4.0 mg/dL Stage-3 rule · DECIDED 2026-07-28. It is a property of the participant's maximum value, not a region of the plane. Draw it as a mark property (distinct symbol + tooltip line, counted as Stage 3 in the table) · drop the rule · give the y-axis a second mode showing absolute value.

DECIDED 2026-07-28 — mark property. A ringed point plus an explicit tooltip row keeps the rule visible without distorting the axes, and the summary table's Stage-3 count stays clinically complete. The rule fires on zero participants in either demo dataset (the RhoInc maximum creatinine is 1.93 mg/dL), so its evidence has to be a unit test on synthetic input — noted in §8 so it does not get quietly skipped for want of a demo case.

D6 — Participants whose creatinine only fell · DECIDED 2026-07-28. Faithful (y-axis floors at 0, those participants leave the chart) · clamp them to the floor · extend the domain below zero when the data has them.

DECIDED 2026-07-28 — extend the domain, and never drop silently. 58 of 110 stageable participants in the RhoInc data sit below the 0.3 mg/dL trigger and 21 of them are negative — a fifth of the cohort would leave the plot under the source's y-limits, unannounced. They are the reference cloud that makes the flagged corner readable. Where a record genuinely cannot be plotted it is counted and exportable, following hep-explorer's HEP-DROP-* pattern — a note giving the count plus a CSV download of exactly which records left. Nine modules already carry some form of this; hep-explorer's is the richest and the one to copy.

D7 — How baseline is identified · DECIDED 2026-07-28. An explicit baseline-flag setting with a fallback · earliest study day · lowest visit number.

DECIDED 2026-07-28 — baseline_col / baseline_value, falling back to the earliest post-sort record. hep-explorer already carries exactly this pair (baseline_col: null, baseline_value: 'Y'), so nep-explorer inherits a vocabulary reviewers know. When no baseline column is configured or a participant has no flagged record, fall back to the earliest record by study day, then visit number, then input order — and count participants resolved by fallback so the number is visible rather than assumed. Neither demo dataset ships a baseline flag (the RhoInc set's is derived downstream, in nepExplorer's own adlb), so the fallback is the path the demo actually exercises.

4. Units: the mg/dL contract

The fold-change axis is a ratio and therefore unit-free. Everything else on this chart is not: 0.3 mg/dL and 4.0 mg/dL are absolute quantities, and creatinine is reported in µmol/L across most of the world — including in both demo datasets. The conversion is 1 mg/dL = 88.4 µmol/L.

Three requirements follow:

units: {
  target: 'mg/dL',
  factors: { 'mg/dl': 1, 'umol/l': 1 / 88.4 }   // keys are normalized before lookup
}

Sponsor-specific factors vary, which nepExplorer itself warns about, so factors is a setting rather than a constant.

5. Data requirement

5.1 The record contract

Phase 1 needs one tall lab domain and nothing else. Phase 2 adds vitals (blood pressure) and the kidney-specialized measures.

SettingDefaultPhaseUsed for
id_colUSUBJID1One point per participant; the selection key
measure_colTEST1Matching the creatinine records via measure_values.CREAT
value_colSTRESN1The numeric result; non-numeric rows drop with a counted note
unit_colSTRESU1Per-record conversion to mg/dL (§4)
baseline_col / baseline_valuenull / 'Y'1Baseline identification, with the D7 fallback
visit_col / visitn_colVISIT / VISITNUM1Ordering; the baseline fallback; the tooltip's "max at visit"
studyday_colDY1 (optional)The tooltip's "max on study day"; ordering preference
measure_values{ CREAT: 'Creatinine' }1The measure map; grows to the profile panel in Phase 2
arm_col, filters, detailsARM, [], null1Shell filters and the listing columns, per the house pattern
age_col / sex_colAGE / SEX2CKD-EPI eGFR derivation

Phase 1 requires only id, measure, value, unit and a way to find baseline. That is a deliberately small contract — it is what makes the KDIGO scatter portable to a study that has nothing but a chemistry panel.

5.2 The finding: pharmaverseadam cannot demonstrate this chart

The assessment recorded Phase 1 as "demoable on today's data" because creatinine is present. It is present, and the chart renders — but it does not demonstrate. Computed over the current site/data/adbds.csv:

pharmaverseadam (current demo)RhoInc renderer-specific
Participants with baseline + post-baseline creatinine208110
Maximum fold change observed1.45×5.60×
Fold-change stages populated (0 / 1 / 2 / 3)208 / 0 / 0 / 073 / 24 / 10 / 3
Participants above the 0.3 mg/dL trigger9 of 20852 of 110
Maximum absolute change0.60 mg/dL1.14 mg/dL

On the current demo data every point lands in the white no-stage box, the three coloured zones stay empty, and the summary table reads 208 / 0 / 0 / 0. The renderer would ship with a gallery hero that shows a blob in a corner and an evidence page that cannot exercise the staging at all — the same "the demo has no signal" problem already filed for the hepatic data as safety.viz#89, but total rather than partial.

D8 — Which dataset backs the Phase-1 demo · DECIDED 2026-07-28. Keep pharmaverseadam adbds.csv as the assessment assumed · build a nep-specific site/data/adnep.csv from the RhoInc source · inject a deterministic synthetic AKI cohort into the shared adbds.csv, the way build-hep-composite-cohort.mjs injects the CLD-* chronic-liver-disease cohort.

DECIDED 2026-07-28 — the synthetic AKI cohort, injected into the shared adbds.csv, over the recommended adnep.csv. This is the house mechanism, it is exactly what safety.viz#89's DEMO-3 asks for across every under-fed demo, and it keeps DEMO-4's one versioned extract intact instead of making the nep renderer the fourth exception to it.

The recommendation was adnep.csv, on three grounds — real data rather than simulated injury, a single answer to Phase 1 and Phase 2's data question, and a PR that does not touch ten other renderers' evidence. Two of those become live consequences of the decision and are planned for in §5.3 and §5.4 rather than discovered later. The third is a genuine gain: a synthetic cohort can be designed to exercise the chart, which real data cannot be. In particular it can carry a participant who trips the ≥ 4.0 mg/dL rule (D5) — something neither real dataset can do, since the RhoInc maximum is 1.93 mg/dL — turning a unit-test-only branch into demo-visible behaviour.

5.3 The Phase-1 demo cohort (D8)

A deterministic AKI cohort injected into site/data/adbds.csv after the main pharmaverseadam build, on the mechanism scripts/build-hep-composite-cohort.mjs already uses: fixed seed, idempotent, clearly-labelled participant IDs (AKI-* beside the existing CLD-*), labelled site and arm, provenance in docs/DATA_SOURCES.md.

What the cohort has to contain, given the decisions above — this is a chart-driven spec, not a plausible-population one:

Plan for the blast radius. adbds.csv is shared by ten renderers, so injecting rows regenerates canonical Linux evidence baselines across all of them inside a PR that is otherwise about the kidney. That is the accepted cost of the CLD-* mechanism and #89's DEMO-6 asks for exactly this regeneration — but it belongs in the PR description up front, not as a surprise in the diff. Two further guards worth having: the injected participants must not perturb the other renderers' displays beyond the new rows (the CLD-* precedent shows how), and the cohort's data-shape assertions (the stage cases exist and land where intended) are their own tests, per DEMO-6.

5.4 Phase-2 data: the RhoInc set, and what is wrong with it

D8 settles Phase 1 and leaves Phase 2 where D1 put it: the RhoInc renderer-specific dataset — RhoInc/data-library, MIT-licensed, and safety.viz's own demo data before the v1.1.0 pharmaverse migration. It is the only source here of the measures pharmaverseadam lacks: cystatin C, eGFR, eGFRcys, urine albumin/creatinine and bicarbonate, alongside creatinine, BUN, the electrolytes and blood pressure. Two things to carry forward when Phase 2 starts:

But it solves the availability gap and exposes a coherence gap. Pairing every measure by participant and visit:

PairnPearson rExpected in a real cohort
Creatinine vs eGFR5170.005strongly negative — eGFR is a function of creatinine
Cystatin C vs eGFRcys582−0.023strongly negative, same reason
Creatinine vs cystatin C577−0.054positive — two markers of the same filtration
eGFR vs eGFRcys523−0.023strongly positive

The measures were simulated independently. The medians are plausible — the shipped eGFR has median 88.8, a CKD-EPI-2021 recomputation from the same file's creatinine gives 85.5 — which is exactly why this needed checking per record rather than by summary. And the unit column confirms the columns were never derived: it labels eGFR μmol/L, which is not a filtration rate at all; the values (median 88.9, range 21–186) are mL/min/1.73m².

The Phase-1 scatter is untouched by this — it reads creatinine and nothing else. The Phase-2 profile is not: as shipped, it would show a participant whose creatinine tripled beside a flat eGFR panel and an unrelated cystatin C trace. That is a demo that teaches the wrong thing.

D9 — eGFR in the nep demo (refines D1) · DECIDED 2026-07-28. Derive eGFR and eGFRcys with CKD-EPI 2021 from the file's own creatinine / cystatin C, age and sex, and drop the incoherent shipped columns · ship the source columns as they are with a caveat on the demo page · regenerate the whole kidney panel synthetically from one underlying filtration trajectory.

DECIDED 2026-07-28 — derive and drop, for Phase 2; nothing needed for Phase 1. Deriving makes eGFR coherent with the creatinine the scatter stages, by construction, and it is what D1 already chose — this only adds "and therefore the shipped columns go". It does not fix creatinine vs cystatin C, which stays near zero and would need the third option to repair; that is a bigger piece of work than Phase 2 should carry, and the honest interim is a line on the demo page saying the cystatin C panel is illustrative. Worth confirming at the Phase-2 gate rather than now, since Phase 1 does not depend on it.

6. Module design — Phase 1

6.1 Shape

src/nep-explorer.js plus src/nep-explorer/, following the delta-delta / hep-explorer layout exactly: checkInputs → configure → structureData → getScales / getPlugins → new Chart, over the shared shell, with the standard lifecycle (init / setData / setSettings / render / resize / destroy) and registration in src/main.js as nepExplorer.

src/nep-explorer.js          public factory + lifecycle + shell wiring
src/nep-explorer/
  configure.js               DEFAULT_SETTINGS, syncSettings, the stages + units settings
  checkInputs.js             required columns, the creatinine measure, unit resolvability
  structureData.js           records → one staged participant per point
  getScales.js               axis domains, the 3.5× / 0.3-visible floors, tick labels
  getPlugins.js              stageZonesPlugin, the ≥4 mg/dL mark, selection borders
src/data/schema/nep-explorer.json   the data schema, per the house convention

6.2 structureData

One reduce per setData, producing one row per participant:

{ id, baseline, baselineVisit, max, maxVisit, maxDay,
  fold, delta,            // delta in mg/dL, or null when the unit is unknown
  foldStage, deltaStage, stage,   // stage = worse of the two, plus the ≥4 rule
  absoluteRule }          // true when max ≥ 4.0 mg/dL

Records drop, counted and exportable, when: the value is non-numeric; the participant has no resolvable baseline; the participant has no post-baseline record. The unit is resolved per record before any comparison (§4).

6.3 The chart

6.4 The summary table

The R app's table is not a cross-tabulation, as the assessment described it — it is two marginal distributions sharing a stage row label: Stage 0–3 down the side, then N and % for the fold-change staging and N and % for the delta staging. Keep that shape, in the shell's listing area, with a third pair for the combined stage (the one the zones show), because with D4's ladder the combined column is what a reviewer reads off the chart.

7. Phase 2 preview — the patient profile

Not in scope for Phase 1 and gated on D9, but two things belong on the record now because they change what Phase 1 should leave behind.

It should be the participant-profile module, not a bespoke panel. #75 shipped the shared profile as a right rail with a labs-over-time chart, a measure table and an AE block, adopted by eight renderers. nepExplorer's profile is five small-multiple panels grouped by measure family (creatinine + cystatin C, eGFR + eGFRcys, related electrolytes, blood pressure, urine ACR) with KDIGO reference lines. The right question at the Phase-2 gate is whether that becomes a measure-group option on the shared profile rather than a second profile implementation. Phase 1 keeps the seam open by dispatching the standard selection event.

A third source trap, for whoever picks Phase 2 up. drawPercentChange plots (value − baseline) / baseline — a percent change, where a 1.5× rise is 0.5 — and then draws its KDIGO reference lines at y = 1.5, 2 and 3, which on that scale are 2.5×, 3× and 4×. The "KDIGO Stage 1" line is drawn where Stage 2 nearly begins. Whichever scale Phase 2 picks, the thresholds have to be expressed on the same one.

8. Delivery and the done gate

Each row is meant to be shippable on its own. The whole of Phase 1 is one safety.viz implementation issue (drafted, awaiting the design gate) and, per the one-PR-per-session convention, likely one PR to dev.

#IncrementDepends on
1Synthetic AKI cohort generator (§5.3) + DATA_SOURCES.md section + data-shape assertions + regenerated baselines for every adbds.csv consumer (D8)
2Module skeleton: settings, checkInputs, schema, shell, registration in main.js
3Unit resolution and the mg/dL contract, including the refuse-to-guess path (§4)2
4structureData: baseline resolution (D7), per-participant maxima, staging (D4, D5), dropped-record accounting (D6)3
5Scales, stageZonesPlugin, marks, tooltip, selection dispatch4
6Summary table in the listing area (§6.4)4
7requirements/nep-explorer.md matrix (§9)
8Done gate: gallery demo + guide page, evidence page with Linux baselines, API reference, coverage docall

One evidence note. The unknown-unit fallback (§4) cannot be reached from a demo whose units are all known, so it needs a unit test on synthetic input — the kind of branch that ships untested precisely because the demo looks complete. The ≥ 4.0 mg/dL rule was the other one, and D8 removes it from that list: the cohort is specified to carry a participant who trips it, so it earns browser evidence like everything else.

9. Requirement matrix plan

safety.viz/requirements/nep-explorer.md, on the NEP-<AREA>-<NNN> base-ID scheme so the evidence pages resolve each row by exact ID, structured like qt-explorer.md — the closest precedent, being the other phased port of a SafetyGraphics Shiny app.

AreaCoversIncrement
NEP-CFG-*Settings, defaults, the stages and units objects, measure_values resolution2, 3
NEP-UNIT-*Per-record conversion, string normalization, the unknown-unit suppression path3
NEP-DATA-*Baseline resolution and fallback, per-participant maxima, dropped records and their export4
NEP-STAGE-*Fold ladder, the 0.3 mg/dL trigger, the ≥ 4.0 mg/dL rule, the combined stage4
NEP-ZONE-*Zone geometry and paint order, labels, the axis floors, colour and labelling rules5
NEP-SCAT-*Marks, tooltip contents, selection and the dispatched event5
NEP-TBL-*Summary table rows, the three N/% pairs, empty-stage rows6

Where a row exists because the port diverges from the R source — the staging ladder, the negative-delta domain, the mark-based ≥ 4 rule — the matrix row says so in its Notes, with §3 as the citation. That is the record that keeps "we fixed their bug" from reading like "we got it wrong" at review time three months from now.

10. Risks and what this design does not settle


Drafted by Claude Code using Opus 5 and reviewed by @jwildfire.