Design · Requirement #9 · Project P005

FDA ST&F static safety displays — phased implementation

Build the figures the open-source ecosystem is missing, borrow the table logic that already exists, and share the FDA's normative rules between the static and interactive renderers rather than duplicating them.

2026-07-21 · Companion to the FDA ST&F static display strategy assessment · Requirement #9

1Context and goals

The FDA Standard Safety Tables and Figures: Integrated Guide v2.0 (April 2025, governed by MAPP 6025.9) specifies 60 tables and 22 figures across three delivery tiers. This requirement implements a deliberately scoped subset in R, as static displays that can be reviewed and generated alongside the safety.viz interactive graphics.

The full inventory — every display with its tier, section, chart engine, ADaM domains and the guide's cross-cutting rules — is published as fda_stf_inventory.json alongside the assessment. It is the specification input to this design.

Why the scope is what it is

Open-source coverage of the guide is sharply lopsided. pharmaverse cardinal has templates for 26 of 60 tables but only 4 of 22 figures. Fifteen figures have no static-R implementation in any open-source package, and twelve of those already exist as shipped safety.viz interactive renderers, collapsing into four reusable chart engines. The gap in the ecosystem and the assets already on our shelf are very nearly the same set — so the plan builds figures and borrows tables.

2Architecture — three layers

The framing question was whether to run parallel R and JS libraries or integrate them. Both pure options are rejected:

Instead, three layers, with the sharing in the middle. This mirrors gsm.kri, which pairs Visualize_Scatter (ggplot2) with Widget_ScatterPlot (htmlwidget) over one shared data interface.

LayerLanguageContents
L1 · DerivationR sharedAppendix Tables 56–60 as package datasets, plus Derive_* functions applying the guide's normative rules: TEAE flagging, OCMQ narrow/broad assignment, abnormality grading, extreme-value exclusion, ULN multiples, DILI quadrant assignment, last-on-treatment windows, risk difference with CI, incidence rates per 100 person-years.
L2a · StaticRVisualize_* returning ggplot objects; Table_* / Listing_* returning gt or gtsummary objects. Exports PNG, PDF, SVG.
L2b · InteractiveJSExisting safety.viz Chart.js modules and Widget_* bindings, unchanged in kind, reading L1-enriched data.
The load-bearing design choice

L1 emits enriched participant-level data — the input frame plus derived columns — not a pre-aggregated result set. safety.viz's value is client-side interactivity (brushing, filtering, drill-down), which needs participant-level data in the browser. Adding derived columns single-sources the FDA's rules while leaving the interactive renderers fully capable. Presentational shaping — binning, sorting, layout — stays in each renderer.

The one real integration cost

safety.viz currently derives flags in JS inside each module's structureData.js. Moving normative derivations upstream to R is a genuine change to its data contract, not a free alignment. Only rules the FDA specifies move to L1. Prove it on the DILI pair — hep-explorer already computes ULN multiples and quadrant membership client-side — before committing the other three engines.

3Package layout and API surface

All of it lands in gsm.safety, alongside the seven Widget_* bindings delivered by v1.0.0. This mirrors gsm.kri, which already carries ggplot2, gt, DT, htmlwidgets and rmarkdown in one package, and it is what makes "review the static and interactive versions side by side" possible in a single pkgdown gallery.

gsm.safety/
  R/
    Derive_*.R          # L1 — FDA normative rules (shared)
    Visualize_*.R       # L2a — ggplot2 static figures
    Table_*.R           # L2a — gt / gtsummary tables
    Listing_*.R         # L2a — subject-level listings
    Widget_*.R          # L2b — existing htmlwidget bindings (v1.0.0)
  data/
    FDA_AbnormalityLevels    # Appendix Tables 56–57
    FDA_ExtremeValues        # Appendix Tables 58–60
  inst/workflow/3_reports/
    fda_*.yaml          # workr workflows, one per display

Naming follows the guide's own identifiers so traceability is mechanical: Visualize_HepatocellularDILI() implements Figure 7, Table_DILIQuadrants() implements Tables 21 and 22. Each carries the guide version it was written against.

4Workflow integration, and the cardinal-template question

The question raised was whether we can skip porting table logic entirely and instead add YAML workflows that render the cardinal templates directly. It is the right instinct — cardinal is Apache-2, pharmaverse-hosted, and covers 26 of the guide's tables — so it was investigated against the actual sources rather than assumed.

What the templates actually are

Cardinal's catalog is 27 entries at quarto/catalog/fda-table_NN/index.qmd (26 FDA tables plus one Roche display). Inspecting fda-table_07, the FDA Overview of Adverse Events:

Verdict on direct rendering

Rendering the templates as-is does not work for production analysis. With no parameters and a hard-coded demo dataset, a workflow that rendered them would faithfully reproduce cardinal's catalog page — against pharmaverseadam, with a fabricated AEACN column — not analyse a study. The blocker is structural, not cosmetic.

It also fits the workflow model poorly. gsm.safety's workr steps name R functions and pass R objects:

steps:
  - output: lWidget
    name: gsm.safety::Widget_AeTimelines
    params:
      dfResults: dfResults

A Quarto render is a document build, not a step returning a chainable object, and it would add a Quarto CLI dependency to the runtime.

Three options, and the recommendation

OptionWhat it meansAssessment
A · Render as-isYAML workflow shells out to quarto render on the vendored templateRejected No params, hard-coded demo data, fabricated columns. Renders a catalog page, not an analysis.
B · Fork and parameteriseVendor the .qmd files, add a params: block, replace hard-coded loadsViable, not preferred Cheaper than porting, but adds a Quarto CLI dependency, sits awkwardly beside workr's R-function steps, and the upstream diffability it buys is worth little while cardinal's development is dormant.
C · Port the logicExtract the table logic into Table_* functions over gtsummary and cards; YAML workflows call thoseRecommended Matches the existing step model exactly, accepts gsm-mapped column parameters, and is testable with testthat.

The porting cost is smaller than it first appears. Stripping the demo scaffolding from Table 07 leaves roughly forty lines of genuine logic — a tbl_hierarchical() composition stacked with tbl_stack() and indented with modify_indent(). Cardinal remains extremely valuable as the reference implementation for that logic, and Apache-2 permits reuse with attribution.

Worth adopting from cardinal regardless

Each template ends by emitting an Analysis Results Dataset via gather_ard(tbl). An ARD is a machine-readable representation of the computed results, it aligns with CDISC's Analysis Results Standard, and it would slot naturally into both the gsm pipeline and our requirement-traceable evidence practice — a table that ships its own numbers in structured form is far easier to test and to audit than one that only renders. Recommend adopting the ARD-first pattern in Table_* outputs.

5Phased implementation

Phases 0 and 1 begin after gsm.safety v1.0.0 is released — PR #39 merged and tagged. Until then main is the stale v0.1.0 safetyCharts bridge, and static work would be built on a moving foundation.

Phase 0 · gated on v1.0.0

Specification, reference data, and the shared layer

  • Convert the display inventory into an obot.agent requirement matrix, keyed so one requirement ID can span a static and an interactive rendering of the same display.
  • Ship Appendix Tables 56–60 as package datasets (FDA_AbnormalityLevels, FDA_ExtremeValues) with unit tests. Small, self-contained, unavailable in any R package today, and used by every later phase.
  • First Derive_* functions: abnormality grading, extreme-value exclusion, ULN multiples.

Phase 1 · gated on v1.0.0

The differentiated twelve — four engines, twelve FDA figures

Every one has a shipped safety.viz twin and no static-R implementation anywhere else. In order of leverage:

EngineFDA figuresCompanion tablessafety.viz twin
DILI quadrant scatterF7, F8T21, T22hep-explorer
Shift scatterF15, F22shift-plot
Box plot over timeF10, F16–F20T20 familyoutlier-explorer
Dot + risk-difference forestF2, F3T14, T17ae-explorer

Start with the DILI pair as a vertical slice — requirement, L1 derivation, ggplot2 renderer, vdiffr evidence, published gallery entry beside the existing hep-explorer demo. It exercises the whole chain and is where our asset is most clearly differentiated: nothing in cardinal or tern implements an eDISH plot.

Phase 1b · cheap completions

The three unserved figures with no twin

Paired-bar retention plots (F5, F21) and the incidence-rate point-range plot (F12). Simple engines, no interactive counterpart to reconcile; completes the "exists nowhere else" set at fifteen.

Phase 2 · borrow

Kaplan-Meier family

F1, F4, F11, F13, F14. Wrap ggsurvfit or tern::g_km and adapt cardinal's fig_01 rather than writing new code — but supply the guide's mandated furniture, which existing templates do not fully provide: at-risk and cumulative-event strip tables beneath every plot, pointwise 95% CI bars, and earliest-qualifying-event semantics.

Phase 3 · tables and listings

Selective, ARD-first, ported from cardinal

Per option C above. Prioritise tables that pair with Phase 1 figures — T21/T22 with the DILI plots, T52/T53 with the lab thresholds, the T20 family with abnormality grading — rather than working through the guide in numerical order. Each Table_* emits both a rendered object and its ARD.

6Evidence and QC

Static displays use the more traditional testthat path that gsm.safety #27 already anticipates, rather than the Playwright browser evidence the JS renderers need:

Displays reach "done" the same way renderers do — visible on the published site with a demo, evidence, and requirement traceability — extended to a static gallery in the gsm.safety pkgdown site so the two renderings sit side by side.

7Decisions

D1Package placement

A new Visualize_* / Table_* family inside gsm.safety, or a separate ST&F package. Single-package mirrors gsm.kri and keeps one requirement matrix and one gallery; separate keeps gsm.safety lean but fragments the review surface.

Recommend: single package. The review-alongside goal argues directly against splitting; file-level namespacing is enough separation. Revisit if Phase 3 dependencies prove heavy.
D2Depth of the shared layer

Full L1 — safety.viz stops deriving normative flags and consumes R-enriched data — versus loose coupling sharing only the requirement matrix and demo data.

Recommend: full L1 for rules the FDA specifies, loose coupling for everything presentational. Prove on the DILI pair before committing the other three engines.
D3Scope commitment

The differentiated twelve first, or tier-complete coverage of everything in Core (28 tables and 12 figures, including the KM family and the whole open-label-extension section).

Recommend: differentiated twelve first. It is the only work no one else is doing and it reuses assets we already own.
D4Output formats

PNG and vector PDF/SVG from ggplot2 is trivial. Submission-grade RTF and DOCX is a larger commitment, reachable via gt::as_rtf() or by adopting junco on rtables.

Recommend: PNG plus vector now; defer RTF and DOCX to Phase 3 and decide the stack then, based on whether a real submission consumer exists.
D5Cardinal integration approach

Option A (render templates directly via YAML), B (fork and parameterise the .qmd), or C (port the logic into Table_* functions). See section 4 for the evidence.

Recommend: C, plus adopt cardinal's ARD-first output pattern. A is not workable as-is; B is viable but adds a Quarto dependency for little benefit while upstream is dormant.
D6Gate confirmation

This design reads the "after it goes live" gate as gsm.safety v1.0.0 — PR #39 merged and tagged — which is the dependency the assessment flagged, since that release is what makes the Widget_* bindings and the vendored safety.viz bundle real on main.

Open: confirm the intended gate. If a gsm.viz / rbm-viz release was meant instead, the Phase 0/1 start condition needs adjusting.

8Out of scope

9Risks

10Acceptance criteria

11Candidate decomposition

Sub-issues to file in gsm.safety once this design is signed off and the v1.0.0 gate clears — via the requirement-tasks skill, as cross-repo sub-issues of #9.

#Sub-issuePhase
1FDA reference criteria as package data (Appendix Tables 56–60)0
2Requirement matrix for FDA ST&F displays (obot.agent)0
3Derive_* — abnormality grading, extreme-value exclusion, ULN multiples0
4ADaM ↔ Mapped_* alignment for the guide's domains0
5DILI quadrant scatter — F7, F8 + T21, T22 (vertical slice, proves L1)1
6Shift scatter — F15, F221
7Box plot over time — F10, F16–F201
8Dot + risk-difference forest — F2, F31
9Static gallery in the gsm.safety pkgdown site1
10Retention and incidence-rate figures — F5, F21, F121b