open.gismo · guided setup · evidence

The four measurements

Method, code and verbatim output for everything the design page asserts. All four were executed on 2026-08-27; none is reasoned about.

00The fixture, and what is invented about it

All four measurements use one constructed delivery. The values are real — every row is demo-301 study data, unchanged. The packaging is invented: column names, file split and identifier construction were chosen to match how a CRO actually ships a study, and that choice is mine.

FileShapeBuilt fromWhat was changed
adsl.csv1,005 × 16 demo-301/input/Raw_SUBJ.csv Renamed into ADaM: USUBJID, SUBJID, SITEID, ARM, ACTARM, SAFFL, RANDFL, TRTSDT, TRTDURD, TR01DURD, AGE, AGEU. USUBJID built as studyid-subjid, which is the standard construction.
lab_central.csv57,200 × 11 demo-301/input/Raw_LB.csv Renamed to a central-lab extract: SUBJECT (bare id, not USUBJID), LBORRES/LBORNRLO/LBORNRHI — the original result and range, not the standardised one — and analytes respelled by vendor: ALT, SGOT, TBILI, Alk Phos. Three columns dropped entirely: toxgrg_nsv, lbblfl, lb_dy.
Two choices worth challenging

The original-versus-standardised result (LBORRES not LBSTRESN) is what makes M1 score badly, and it is a real pattern — central labs ship what they measured — but a delivery that shipped standardised results would score better. And the three dropped columns were dropped by me; a CRO that included a toxicity grade would not hit W2 at all. Both choices make the case harder. Neither is arbitrary, but both are mine.

M1The published name detector

safetyGraphics::detectStandard() is the working implementation of the capability the gap analysis credits to safetyGraphics, tidyCDISC and three commercial platforms in row D1. It compares column names against safetyCharts::meta_*, which publishes an ADaM and an SDTM spelling for every role. Run against the fixture, unmodified:

=== CRO central lab vs meta_labs ===
standard: sdtm  pct: 33 %
        text_key  current valid
          id_col     <NA> FALSE
       value_col     <NA> FALSE
     measure_col   LBTEST  TRUE
  normal_col_low     <NA> FALSE
 normal_col_high     <NA> FALSE
    studyday_col     <NA> FALSE
       visit_col    VISIT  TRUE
      visitn_col VISITNUM  TRUE
        unit_col     <NA> FALSE

=== CRO ADSL vs meta_dm ===
standard: adam  pct: 71 %
                 text_key current valid
                   id_col USUBJID  TRUE
            treatment_col     ARM  TRUE
 treatment_values--group1    <NA> FALSE
 treatment_values--group2    <NA> FALSE
                  sex_col     SEX  TRUE
                 race_col    RACE  TRUE
                  age_col     AGE  TRUE

=== hep measure VALUES present in LBTEST? ===
  Alanine Aminotransferase               FALSE
  Aspartate Aminotransferase             FALSE
  Bilirubin                              FALSE
  Alkaline Phosphatase                   FALSE
  Alanine Aminotransferase (U/L)         FALSE

What this establishes

M2A name-blind value profiler

Written for this session and deliberately never reads a column name. It types each column, counts distinct values, searches for a (value, low, high) triple where low < high always holds and the value usually sits between them, matches units against a token list, and picks the identifier as the highest-cardinality repeating character column.

=== profiler on lab_central.csv (name-blind) ===
  unit_col           -> LBORRESU
  value_col          -> LBORRES
  normal_col_low     -> LBORNRLO
  normal_col_high    -> LBORNRHI
  .range_hit         -> 0.849
  measure_col        -> LBTEST
  .measure_purity    -> 1
  visit_col          -> VISIT
  date_col           -> LBDTC
  id_col             -> SUBJECT

Nine of nine, including every one of the four the name detector missed. This is the result that makes a guided setup viable at all: the evidence is in the values.

M3The same profiler, confidently wrong

Then it was pointed at four more files, including demo-301/input/Raw_LB.csv — the study open.gismo ships and the one that works today.

=== demo-301 Raw_LB (gsm names) ( 14 cols )
   unit_col           -> lbstresu
   value_col          -> visnum
   normal_col_low     -> toxgrg_nsv
   normal_col_high    -> lb_dy
   .range_hit         -> 1
   measure_col        -> visnam
   .measure_purity    -> 0
   visit_col          -> visnam
   date_col           -> lb_dt
   id_col             -> subjid

=== CRO adsl.csv ( 16 cols )
   date_col           -> TRTSDT
   id_col             -> SITEID

=== demo-301 Raw_EG (ECG) ( 10 cols )
   value_col          -> eg_dy
   normal_col_low     -> visnum
   normal_col_high    -> egstresn
   .range_hit         -> 0.927

=== CRO lab, ranges REMOVED ( 9 cols )
   unit_col           -> LBORRESU
   visit_col          -> VISIT
   date_col           -> LBDTC
   id_col             -> SUBJECT
The confidence score points the wrong way

On the file it got right, the profiler scored 0.849. On the file it got wrong, it scored a perfect 1.000 — because visit number genuinely does sit between toxicity grade and study day on every single row. There is no threshold on this score that admits the right answer and rejects the wrong one. Any design that shows this number to a user, or uses it to auto-fill, is worse than useless.

The fix, and its limit

Re-ranking candidate triples on a structural test — does some categorical column give exactly one reference range per level, which is what "each analyte has its own normal range" means — rather than on arithmetic fit:

=== CRO lab_central.csv
   1. value=LBORRES    lo=LBORNRLO    hi=LBORNRHI    measure=LBTEST     purity=1.00 inrange=0.85 <= CORRECT

=== demo-301 Raw_LB
   1. value=lbstresn   lo=lbstnrlo    hi=lbstnrhi    measure=lbtstnam   purity=1.00 inrange=0.85 <= CORRECT
   2. value=visnum     lo=toxgrg_nsv  hi=lb_dy       measure=NA         purity=0.00 inrange=1.00
   3. value=lbstnrlo   lo=toxgrg_nsv  hi=lbstnrhi    measure=NA         purity=0.00 inrange=0.99

=== demo-301 Raw_EG
   1. value=eg_dy      lo=visnum      hi=egstresn    measure=NA         purity=0.00 inrange=0.93

M3bThe join, and what a wrong key costs

Not a separate measurement so much as the reason the join is question two. Every character column in adsl.csv against every character column in lab_central.csv, ranked by shared distinct values:

== what detectStandard would pick (USUBJID) ==
   n_a    n_b shared    pct
  1005    765      0      0

== the column that actually joins (SUBJID) ==
   n_a    n_b shared    pct
  1005    765    765    100

== every character-column pair, ranked by overlap ==
    adsl     lab shared pct
  SUBJID SUBJECT    765 100
  TRTSDT   LBDTC     88 100
 STUDYID   STUDY      1 100

== affix repair: strip a constant prefix from USUBJID ==
constant prefix candidates: 1 -> AA-AA-000-0000
   n_a    n_b shared    pct
  1005    765    765    100

== consequence of the bad join ==
inner_join on USUBJID: 0 rows
inner_join on SUBJID : 57200 rows

M3cThe emitted spec runs

The wizard's output is a mapping spec with source_col: lines. That spec was written by hand exactly as the interview would emit it, and pushed through gsm.mapping::ApplySpec() and the study's own inner join:

Mapped SUBJ: 1005 rows x 12 cols -> studyid,invid,country,subjid,enrollyn,arm,
             agerep,sex,race,timeonstudy,timeontreatment,firstdosedate

Mapped LB  : 57200 rows x 11 cols -> studyid,subjid,visnam,visnum,lb_dt,battrnam,
             lbtstnam,lbstresn,lbstresu,lbstnrlo,lbstnrhi

columns SILENTLY DROPPED from SUBJ spec: subject_nsv, firstparticipantdate, mincreated_dts
columns SILENTLY DROPPED from LB   spec: lb_dy, lbblfl, toxgrg_nsv

Mapped_SUBJ after enrollyn=='Y': 765
Mapped_LB after inner_join     : 57200 rows, 765 participants
types: lbstresn numeric | lb_dt Date | toxgrg_nsv ABSENT

M4A half-mapped study doubles the risk score

srs0001, the Site Risk Score, is the only workflow in the set with no spec at all. It stacks whatever kri* analyses ran and weights them. Its denominator therefore depends on how much of the study got mapped. Reading gsm.kri/R/CalculateRiskScore.R: GlobalDenominator <- sum(dfMaxWeights$max_WeightMax), where dfMaxWeights is derived from dfResults — the analyses that ran, not the analyses that exist.

Run twice on identical site data. Site S1 carries one red flag; site S2 carries none. The only difference between the runs is how many KRIs were mapped:

== 12 KRIs mapped ==
 GroupID Numerator Denominator   Metric
      S1        32         384 8.333333
      S2         0         384 0.000000

== 6 KRIs mapped ==
 GroupID Numerator Denominator   Metric
      S1        32         192 16.66667
      S2         0         192 0.000000
Same site, same data, same single flag

8.3 becomes 16.7. Exactly double, because the numerator held and the denominator halved. Nothing warns. The score carries no indication of how many metrics it was computed over, it is on the same 0–100 scale in both runs, and it is not comparable to any other study's.

This is the strongest argument in the session for treating the data-loading surface as a safety concern rather than a convenience: the door through which a study gets partially mapped is the door through which a site gets a plausible, wrong, doubled risk score.

05The dependency graph behind the staircase

Every metric and module in demo-301/workflows/, resolved through its Mapped_* dependencies to the raw domains it ultimately needs. This is what the Ledger reads to price an absence, and it is already in the project's own files.

Raw domainMetricsModulesNamed consumers
Raw_SUBJ261026 of the 30 metrics and 10 of the 12 modules. The four that do not touch it are cou0012/kri0012 (enrolment), qtl0001 and srs0001. It is the spine.
Raw_LB36kri0005, cou0005, saf0001; histogram, shift plot, results-over-time, delta-delta, outlier explorer, hepatic explorer
Raw_AE52kri0001, kri0002, cou0001, cou0002, saf0003; AE explorer, AE timelines
Raw_EG11saf0002; QT explorer
Raw_PD40kri0003, kri0004, cou0003, cou0004
Raw_STUDCOMP31kri0006, cou0006, qtl0002; QTL report
Raw_QUERY40kri0008, kri0009, cou0008, cou0009
Raw_DATACHG40kri0008, kri0011, cou0008, cou0011
Raw_DATAENT20kri0010, cou0010
Raw_SDRGCOMP20kri0007, cou0007
Raw_ENROLL20kri0012, cou0012
Raw_IE11qtl0001; QTL report
Raw_SITE00Site labels only — nothing computes from it, which is why the wizard should never ask for it early.
Raw_STUDY00Study header. Same.
12srs0001 and the two KRI reports have no spec: they consume whatever ran. This is the M4 mechanism.

06Reproducing this

The probe scripts are checked in beside this page: data/profile.R (the name-blind profiler) and data/profile2.R (the structural ranker). The fixture is generated from demo-301/input/ by the recipe in the README; it is deliberately not committed, because it is 57,200 rows of a study that already lives in this workspace.

Sample rows from the fixture — the ones the walkthrough renders — are committed as data/delivery.js, so the values on that page are the measured ones rather than illustrations.