gsm.safety release review 2026-09-11

What v1.5.0 puts in the package, annotated

The FDA's Standard Safety Tables and Figures guide is mostly rules rather than drawings: three levels of laboratory abnormality, values so extreme they are treated as errors, results read as multiples of the upper limit of normal. Nothing in R carried those rules. This release is the foundation the static figures will stand on: the guide's appendix tables as package data, the first three functions that apply them to a lab table and hand the table back with the answers added, a requirement matrix that keys every one of the guide's 22 figures, and a column-by-column check of what the demo data can actually draw. Every number on this page was produced from the release branch on 2026-09-11.

reference rows 46 + 80 derivations 3, one rule each requirement rows 38, figures 22 of 22 columns aligned 51, none unmarked candidate gsm.safety v1.5.0-RC1, dev → main

Superseded on 2026-09-12

The work on this page now ships inside one consolidated release, gsm.safety v1.2.0, on @jwildfire's decision of 12 September. The review surface for that release is the merged demo page, which carries this page's content as one of its four parts. This page stays as the record of when and how these figures were measured.

the reference criteria · gs#77

The guide's appendix tables, as package data

Appendix Tables 56 and 57 give the level 1, 2 and 3 abnormality thresholds the guide counts outliers against; Tables 58 to 60 give the values beyond which a result is treated as a laboratory or recording error and left out of the mean-change figures. They existed only as a PDF. They are now two data frames, transcribed row for row, with the printed criterion kept beside the parsed number and the guide page on every row.

Rows in the guide against rows in the datasets. Both were counted for this page; the suite asserts them and 24 spot values read from the PDF by page.
Guide tablePagePrinted rowsDataset rows
Table 56, abnormality levels, chemistry119 to 1203333
Table 57, abnormality levels, hematology1211313
FDA_AbnormalityLevels4646
Table 58, extreme values, chemistry122 to 1232652
Table 59, extreme values, hematology123918
Table 60, extreme values, vital signs124510
FDA_ExtremeValues40 parameters80

The extreme-value table is long: every parameter appears twice, once in US-conventional units and once in SI, because the guide prints both and the data decide which one applies. The example data are mostly SI with bilirubin in both unit systems, which is exactly the case that would go wrong with one column.

> subset(FDA_AbnormalityLevels, Panel == "Liver Biochemistry",
+        select = c(Parameter, Direction, Basis, Operator, Level1, Level2, Level3, GuidePage))
                  Parameter Direction        Basis Operator Level1 Level2 Level3 GuidePage
       Alkaline phosphatase      high uln_multiple        >    1.5    2.0      3       120
   Alanine Aminotransferase      high uln_multiple        >    3.0    5.0     10       120
 Aspartate Aminotransferase      high uln_multiple        >    3.0    5.0     10       120
            Total Bilirubin      high uln_multiple        >    1.5    2.0      3       120

> subset(FDA_ExtremeValues, Parameter == "Glucose", select = c(Specimen, UnitSystem, Unit, Low, High))
 Specimen UnitSystem   Unit  Low High
   plasma         US  mg/dL 10.0 2700
   plasma         SI mmol/L  0.6  150

Where the transcription had to read, it says so on the row

The hemoglobin level 1 criteria are printed as ranges (12.5 to 13.5 g/dL for men); because the levels are cumulative the row stores the upper bound and keeps the printed text beside it. The platelet unit is kept as the guide prints it although the thresholds read as cells per microlitre. Three hematology thresholds printed without a comparator are read as greater-than like the rest of their table. Each of these is a Note on its row, and the hand-checked CSVs and the build script are in data-raw/, so a correction is one edit in one place.

Every later phase reads these two tables: the DILI plots for their 2x and 3x cuts, the outlier tables for their three levels, the mean-change figures for what to exclude. Transcribing them once, with tests, is what stops each figure carrying its own copy of the FDA's numbers.

Try it

  1. Install the candidate: pak::pak("jwildfire/gsm.safety@dev").
  2. data(FDA_AbnormalityLevels); data(FDA_ExtremeValues).
  3. Open page 121 of the guide PDF and compare any Table 57 row with subset(FDA_AbnormalityLevels, Table == 57).
  4. ?FDA_ExtremeValues for the column contract and the reading rules.
the first derivations · gs#78

Three of the guide's rules, applied once and handed back

Each function takes the long lab table the example data already use, one row per participant per test per visit, and returns the same table with its answer added as new columns. Nothing is dropped and nothing is summarised, so a static figure and its interactive twin can read the same derived values. Derive_ULNMultiple() puts each result on the multiple-of-ULN scale. Derive_AbnormalityLevel() grades it against Tables 56 and 57 and says which direction and which printed criterion fired. Derive_ExtremeValueFlag() marks the results Tables 58 to 60 would exclude.

Four histograms, one per liver test, of every result in the example data on the multiple-of-ULN scale, coloured by the abnormality level it was graded, with the Table 56 level 1, 2 and 3 cut lines drawn on each.

Every liver result in the example data, graded. The dashed lines are the Table 56 cuts read from the dataset above, not typed into the plot; the colours are the grade the function assigned. Bilirubin carries 30 level 3 results and alkaline phosphatase 12, which is what the eDISH quadrants will be drawn from in phase 1.

> dfLabs <- ExampleData("adbds")
> dfLabs <- Derive_AbnormalityLevel(dfLabs)
Derive_AbnormalityLevel left NA for records it could not evaluate:
  unit differs from the criteria's (mg/dL): Blood urea nitrogen in mmol/L, Calcium in mmol/L, Cholesterol (total) in mmol/L, Glucose in mmol/L, Phosphate in mmol/L
  qualifier 'Fasting' needs a column this function does not take: Glucose
  unit differs from the criteria's (g/dL): Albumin in g/L, Protein (total) in g/L
  basis 'baseline_multiple' needs a baseline column this function does not take: Creatinine
  needs a sex column (strSexCol) for the sex-qualified rows: Hemoglobin
  ... (the full list names every skipped parameter and why)

> dfGraded <- dfLabs[!is.na(dfLabs$AbnormalityLevel) & dfLabs$AbnormalityLevel > 0, ]
> table(dfGraded$TEST, dfGraded$AbnormalityLevel)
                               1   2   3
  Alanine Aminotransferase   102  10   0
  Alkaline Phosphatase       121  14  12
  Aspartate Aminotransferase  93   0   0
  Bilirubin                   15  71  30
  Chloride                   243  27   0
  Leukocytes                  35   9   0
  Potassium                   40   9   0
  Sodium                       5   2   0

A threshold is compared only in its own unit, and the function says what it skipped

Table 56 prints glucose in mg/dL; the example data carry it in mmol/L. Dividing or multiplying silently would be a second, unstated rule. The function grades an absolute threshold only when the record's unit matches, folds spellings of the same unit (mEq/L and mmol/L for the electrolytes, GI/L and the guide's x 10^9 cells/L), converts nothing, and prints the parameters it left ungraded and the reason. The ULN-multiple rows, the whole liver panel among them, grade in any unit because the multiple has none.

The same discipline holds for the extreme values: hemoglobin in mmol/L matches neither the g/dL nor the g/L threshold row, so it is left unflagged and named, while sodium, bilirubin, the transaminases and the vital signs match in the data's own units. On the pilot data nothing is extreme, which is the expected answer for a clean trial dataset and is asserted in the suite.

Each function against the requirement row it proves, and what the suite checks for it.
FunctionRequirement rowsWhat the tests assert
Derive_ULNMultiple()FDA-RULE-001value over its own ULN; NA for a missing or non-positive ULN; the peak per participant reproduces what Input_HysLaw() computes for the same people
Derive_AbnormalityLevel()FDA-RULE-002, -003, -016one probe beyond each defined level of every absolute and ULN-multiple row in Tables 56 and 57, in both directions; the on-threshold case for strict and non-strict operators; the highest level wins; a substitute criteria table; the sex-qualified rows with and without a sex column
Derive_ExtremeValueFlag()FDA-RULE-004, -005a value beyond a threshold in either unit system is flagged; a missing threshold is no bound; the printed value itself is not extreme; an unmatched unit is NA with a message

The design calls this the L1 contract: normative rules live in R once, and the renderers read enriched participant-level data. Phase 1 will prove it on the DILI pair, where safety.viz's hep-explorer today computes its own ULN multiples in the browser.

Try it

  1. dfLabs <- Derive_ULNMultiple(ExampleData("adbds")) and look at ULNMultiple for any transaminase row.
  2. Run Derive_AbnormalityLevel(dfLabs) and read the message: it is the list of what the demo data cannot be graded on, and why.
  3. Pass strSexCol = "SEX" and watch the hemoglobin rows change.
  4. Give Derive_ExtremeValueFlag() a row with STRESN = 3000 and STRESU = "mg/dL" for Glucose and see it flagged high.
the requirement matrix · gs#79

Every figure in the guide now has a key

The safety.viz renderers each carry a requirement matrix, and a test cites a row by its ID. The FDA figures now have one too, in the same shape: FDA-FIG-001 to FDA-FIG-022 for the 22 figures, numbered as the guide numbers them, and FDA-RULE-001 to FDA-RULE-016 for the rules the guide states once and several displays read. A static rendering here and an interactive one in safety.viz cite the same ID, so their evidence meets on one row.

$ grep -c '^| FDA-FIG-' requirements/fda-stf.md
22
$ grep -cE '^\| FDA-[A-Z]+-[0-9]+' requirements/fda-stf.md
38
$ grep -rho 'FDA-RULE-[0-9]*' tests/testthat/ | sort | uniq -c
      4 FDA-RULE-001
      6 FDA-RULE-002
      1 FDA-RULE-003
      3 FDA-RULE-004
      2 FDA-RULE-005
      1 FDA-RULE-008
      1 FDA-RULE-016
What every row carries. Figure rows add the chart engine, the ADaM domains, the safety.viz twin and the phase; rule rows name the function or convention that implements them.
PhaseFiguresEngineRequirement
0, this releasenone; rules 001, 002, 004 and 005 ship as the datasets and the three functionsobot.roadmap#9
1F7, F8, F15, F22, F10, F16 to F20, F2, F3DILI quadrant scatter, shift scatter, box plot over time, dot and risk-difference forestobot.roadmap#323
1bF5, F21, F12, and the Kaplan-Meier family F1, F4, F11, F13, F14paired retention bars, incidence-rate point-range, wrapped Kaplan-Meierobot.roadmap#324
not scheduledF6, F9mean change line with CI

A test in this package ends with the issue it proves, and now also names the row: test_that("Derive_ULNMultiple divides the result by the record's own ULN (FDA-RULE-001) (#78)", ...). The matrix test checks that every ID any test cites exists in the file, so a row cannot be renamed out from under its evidence.

Try it

  1. Open requirements/fda-stf.md on dev and find FDA-FIG-007: the eDISH plot, its engine, its twin and its phase.
  2. Read FDA-RULE-010, the 30-day pairing window, and its note on why the interactive twin does not apply it yet.
  3. requirements/README.md says how a new row is keyed.
the alignment · gs#80

What the demo data can draw, column by column

The guide is written against ADaM. gsm.safety holds two other shapes: the vendored example data and the Mapped_* domains gsm.mapping produces. The design assumed they aligned and said phase 0 should check. The note lists, for ADSL, ADAE, ADLB and ADVS, every column the 22 figures and the three derivations need, marks each as found, mapped, derivable or missing, and records a decision for every gap.

The note's summary. Fifty-one columns, each in exactly one state; every gap carries a decision and none needed a question.
DomainNeededFoundMappedDerivedMissingDecisions
ADSL95310vendor treatment dates, death and safety flags, sex and age from pharmaverseadam; arm is an engine argument
ADAE138113derive the treatment-emergent flag from start day; vendor outcome and relatedness; OCMQ and action taken are out of scope on the demo data
ADLB1710043derive baseline, change and the baseline flag once; vendor study day, without which there is no 30-day DILI window; absent analytes are out of scope
ADVS127032the same derivations on the vital-sign rows; respiratory rate is absent; pulse stands in for heart rate
Total5130498two vendoring passes, one derivation helper, three declared exclusions

Three things the check found that the design did not know

No Mapped_* domain carries a treatment arm, so the engines take an arm column as an argument and render a single arm without one. gsm.mapping's standard lab mapping names the result rptresn and has no upper limit of normal, unit or visit, while this package's own metric workflows declare lbstresn and lbstnrhi; that split is recorded for the phase 1 requirement. And pharmaverseadam shares the pilot study's participants exactly, all 254 of them with the same arm assignments, which makes vendoring the missing columns a join rather than a reconstruction.

The note ends with the column contract every lab and vital-sign engine codes against, with the default names in the example data's spelling and their ADaM and Mapped_* equivalents, so phase 1 starts from an agreed frame rather than rediscovering it per figure.

Try it

  1. Read design/fda-adam-alignment.md on dev.
  2. Find the ADLB row for study day and its decision, then the DILI rule FDA-RULE-010 in the matrix that depends on it.
  3. The last section is the contract; every default there is a column ExampleData("adbds") already has, or says how it will get one.

What this release lets someone do

  • Load the FDA guide's laboratory thresholds in R, traceable to their table and page, and grade any long lab table against them with the level, direction and criterion added as columns.
  • Flag the results the guide would exclude from a mean-change figure, in the unit system the data carry, and see what could not be matched instead of a silent conversion.
  • Cite an FDA figure or rule by a stable ID from a test, a design note or a safety.viz evidence page.
  • Read, before phase 1 is coded, which of the 22 figures the demo data can render in full and which need a vendored column.

What comes next

  • Phase 1 (obot.roadmap#323): four static engines and twelve figures, starting with the DILI pair, which proves the L1 contract before the other engines commit to it.
  • Phase 1b (obot.roadmap#324): the retention bars, the incidence-rate plot and the Kaplan-Meier family, for coverage 22 of 22.
  • The baseline-relative rows of Table 56 and 57 (creatinine, eGFR, hemoglobin change) grade once the shared baseline helper from the alignment note exists.