open.gismo · guided setup · evidence
Method, code and verbatim output for everything the design page asserts. All four were executed on 2026-08-27; none is reasoned about.
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.
| File | Shape | Built from | What was changed |
|---|---|---|---|
| adsl.csv | 1,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.csv | 57,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. |
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.
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
meta_hepExplorer publishes value spellings, not just column names. That is the seed table for the hepatic analyte question — and 0 of 4 match here, which is the whole reason the question has to be asked.hasColumn() is exact membership; there is no fuzzy matching, no case-insensitivity and no profiling anywhere in the implementation.
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.
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
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.
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
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
demo-301/workflows/1_mappings/LB.yaml and AE.yaml both end in dplyr::inner_join, so this is not hypothetical — it is what the shipped workflow does.USUBJID, so the product can offer a fix rather than only a complaint.TRTSDT ↔ LBDTC also scores 100% overlap, because two date columns from the same study share values. Ranking on percentage alone would surface it. Ranking on shared count does not. This is the kind of trap the design cannot claim to have exhausted.
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
ApplySpec() filters them with purrr::keep(~ .x$source %in% colnames(dfSource)). A person watching this run has no way to learn that three of the columns they were asked about went missing.toxgrg_nsv is the input to kri0005 / cou0005, the Grade 3+ Lab Abnormality Rate. That is the link from a silent column drop to a metric that stops existing — and to M4.
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
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.
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 domain | Metrics | Modules | Named consumers |
|---|---|---|---|
| Raw_SUBJ | 26 | 10 | 26 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_LB | 3 | 6 | kri0005, cou0005, saf0001; histogram, shift plot, results-over-time, delta-delta, outlier explorer, hepatic explorer |
| Raw_AE | 5 | 2 | kri0001, kri0002, cou0001, cou0002, saf0003; AE explorer, AE timelines |
| Raw_EG | 1 | 1 | saf0002; QT explorer |
| Raw_PD | 4 | 0 | kri0003, kri0004, cou0003, cou0004 |
| Raw_STUDCOMP | 3 | 1 | kri0006, cou0006, qtl0002; QTL report |
| Raw_QUERY | 4 | 0 | kri0008, kri0009, cou0008, cou0009 |
| Raw_DATACHG | 4 | 0 | kri0008, kri0011, cou0008, cou0011 |
| Raw_DATAENT | 2 | 0 | kri0010, cou0010 |
| Raw_SDRGCOMP | 2 | 0 | kri0007, cou0007 |
| Raw_ENROLL | 2 | 0 | kri0012, cou0012 |
| Raw_IE | 1 | 1 | qtl0001; QTL report |
| Raw_SITE | 0 | 0 | Site labels only — nothing computes from it, which is why the wizard should never ask for it early. |
| Raw_STUDY | 0 | 0 | Study header. Same. |
| — | 1 | 2 | srs0001 and the two KRI reports have no spec: they consume whatever ran. This is the M4 mechanism. |
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.