the user · one command
Scaffold the project, drop the files in
● ● ● zsh
$ og init ~/hep-201
✔ project created at ~/hep-201
config/ 3 files
workflows/ 44 workflow YAMLs snapshotted from the installed gsm packages
input/ empty
Next: put your data in input/, then og scan ~/hep-201
$ cp ~/deliveries/2026-08-26/* ~/hep-201/input/
$ git -C ~/hep-201 init -q && git -C ~/hep-201 add -A && git -C ~/hep-201 commit -qm "delivery 2026-08-26"
Nothing has been renamed, converted or moved. The files sit under the names the CRO gave them, which is the only state a user can be relied on to produce. The commit is the baseline every later diff is read against — and it is the user's own repository, not the product's.
the product · writes the mapping
og scan — the generator
● ● ● zsh
$ og scan ~/hep-201
reading 7 files in input/ (headers and 5,000-row samples; 0 files read in full)
routing by header fingerprint, not filename
adsl.sas7bdat → Raw_SUBJ, Raw_ENROLL, Raw_STUDCOMP 3 domains, one file
adae.sas7bdat → Raw_AE
adlb.sas7bdat → Raw_LB
dv.xpt → Raw_PD
queries_…csv → Raw_QUERY
audit_trail.csv → Raw_DATACHG, Raw_DATAENT
site_list.csv → Raw_SITE
unrouted Raw_EG, Raw_IE, Raw_SDRGCOMP, Raw_STUDY nothing delivered
columns 126 required across 14 domains
73 settled 27 by name · 11 from safetyCharts standards · 19 from the
CDISC index · 16 from EDC-vendor conventions
53 open of which 12 are named by a metric workflow
vocabularies
Mapped_LB.lbtstnam 61 distinct values, 4 literals demanded → config/vocab/lbtstnam.csv
Mapped_LB.lbstresu 9 distinct values → config/vocab/lbstresu.csv
wrote
workflows/1_mappings/ 9 files · 73 source_col: lines · 2 join steps
config/data-config.yaml
config/vocab/ 2 files
Next: git -C ~/hep-201 diff then og doctor ~/hep-201
Two things worth noticing. One file can feed three domains — adsl supplies subject, enrolment and completion — because routing is by content, not by name. And the report ends by sending the user to git diff, not to a summary: what the generator wrote is the deliverable, and the user reads it in the tool they already use to read changes.
the user · reads a diff
What the generator wrote
git diff — workflows/1_mappings/LB.yaml
spec:
Raw_LB:
studyid:
type: character
+ source_col: STUDYID # og-scan exact-ci
subjid:
type: character
+ source_col: USUBJID # og-scan safetyCharts::meta_labs/adam · alt: SUBJID
lbtstnam:
type: character
+ source_col: PARAM # og-scan safetyCharts::meta_labs/adam · alt: PARAMCD
lbstresn:
type: numeric
+ source_col: AVAL # og-scan safetyCharts::meta_labs/adam
lbstnrlo:
type: numeric
+ # source_col: ??? # og-scan UNSETTLED · candidates: ANRLO, BASE, CRIT1
toxgrg_nsv:
type: character
+ # source_col: ??? # og-scan UNSETTLED · candidates: ATOXGR, ATOXGRN, LBNRIND
Every generated line carries how it was matched and what it beat. Unsettled columns are written as commented-out lines with their candidates, so the file names its own open items and grep -rn 'og-scan UNSETTLED' workflows/ is the work list. Nothing else in the file moved: the generator edits YAML as text and re-parses before saving, so a study's own comments and ordering survive.
⚠ Already visible, and already wrong
Look at subjid. The generator chose USUBJID here and — as the next frame shows — chose SUBJID in SUBJ.yaml, because on adsl the exact case-insensitive name match beats the alias. Two files, two identifiers, both annotated confidently. A human reading this diff would have to hold both files in their head to catch it. This is weakness W2 on the design page, in the flesh.
the product · the whole interface
og doctor — first run, and it refuses
● ● ● zsh
$ og doctor ~/hep-201
open.gismo doctor — HEP-201
~/hep-201 14 domains · 126 columns
✖ 1 blocking the run will not start
⚠ 4 costed the run will start; here is what each one costs you
✔ 9 domains ready
────────────────────────────────────────────────────────────────
✖ BLOCKING · identifiers do not join
Raw_LB.subjid and Raw_SUBJ.subjid share 0 of 500 sampled values.
Raw_AE.subjid and Raw_SUBJ.subjid share 0 of 500 sampled values.
Both pairs are joined by an inner join:
workflows/1_mappings/LB.yaml:76 dplyr::inner_join by: subjid
workflows/1_mappings/AE.yaml:71 dplyr::inner_join by: subjid
Raw_SUBJ.subjid ← SUBJID "036" 1,000 distinct
Raw_LB.subjid ← USUBJID "0301-S036-036" 1,000 distinct
Every Raw_SUBJ value is a suffix of a Raw_LB value, so these are the
same participants written two ways.
Cost if you run anyway
Mapped_LB and Mapped_AE become empty. 8 of 29 metric workflows then
report zero at every site — cou0001 cou0002 cou0005 kri0001 kri0002
kri0005 saf0001 saf0003 — and srs0001, which declares no spec,
reweights its site risk score over the 21 that did run.
Nothing errors. The study will look exemplary.
Fix — adsl.sas7bdat carries USUBJID as well as SUBJID, and using it
gives 500 of 500. One line:
workflows/1_mappings/SUBJ.yaml:23
subjid:
type: character
+ source_col: USUBJID
If the two files genuinely disagree — a lab vendor prefixing the site —
write the affix rule instead: config/vocab/subjid.rules.csv
pattern,replacement,domains
^[0-9]+-[A-Z0-9]+-,,Raw_LB;Raw_AE
────────────────────────────────────────────────────────────────
4 costed findings follow. og doctor --costed to see them now,
or fix the blocker first.
og_run() is refused while a blocking finding is open.
Override with og_run(force = TRUE); forced runs are stamped as forced.
This frame is the whole argument for the direction. The finding is not “a column is missing” — every column is present and correctly typed, and today's og_validate() would have nothing to say about it. The finding is that two files disagree about who a participant is, stated with both shapes side by side, priced in named metrics, and closed by one line at a named file and line number.
the user · one line · the product verifies
The fix is verified, not asserted
git diff — workflows/1_mappings/SUBJ.yaml
subjid:
type: character
- source_col: SUBJID # og-scan exact-ci · alt: USUBJID
+ source_col: USUBJID
● ● ● zsh
$ og doctor ~/hep-201 --only keys
✔ key overlap
Raw_LB.subjid ↔ Raw_SUBJ.subjid 500/500 was 0/500
Raw_AE.subjid ↔ Raw_SUBJ.subjid 500/500 was 0/500
Raw_PD.subjid ↔ Raw_SUBJ.subjid 500/500
Raw_QUERY.subject_nsv ↔ Raw_SUBJ.subject_nsv unresolved — see costed
✔ no blocking findings
The overlap is measured before and after, so the user sees the fix land rather than being told it should have. The generator's comment is gone from the line because the human touched it — that is how og scan knows never to overwrite it on a re-run, and it needs no lockfile to remember.
the product · costed finding 1
Three central labs, one axis
● ● ● zsh
$ og doctor ~/hep-201 --costed
⚠ COSTED · a vocabulary is not settled
workflows/4_modules/hep_explorer.yaml pins four literal values in
Mapped_LB.lbtstnam: ALT AST TB ALP
Your study writes 61 distinct values in that column. 0 match as delivered.
og scan filled 9 of 61 rows from safetyCharts::meta_hepExplorer, which
carries per-standard defaults for exactly these four measures.
config/vocab/lbtstnam.csv
from,to,n,note
"Alanine Aminotransferase (U/L)",ALT,11842,og-scan meta_hepExplorer/adam
"Alanine Aminotransferase",ALT,4310,og-scan meta_hepExplorer/sdtm
"SGPT",,3902, ← unsettled
"ALT (SGPT)",,1204, ← unsettled
"Aspartate Aminotransferase (U/L)",AST,11840,og-scan meta_hepExplorer/adam
"SGOT",,3902, ← unsettled
"Bilirubin (umol/L)",TB,7891,og-scan meta_hepExplorer/adam
"Total Bilirubin",,2140, ← unsettled
…
52 rows have a blank to and pass through unchanged. List them:
awk -F, '$2==""' config/vocab/lbtstnam.csv
Cost if you leave it
hep-explorer plots 4 of 4 measures but on 42% of the records it
should have. The Hy's Law quadrant needs ALT and TB on the same
participant and will drop every participant whose panel came from
the two laboratories that write SGPT and Total Bilirubin.
kri0005 / cou0005 are unaffected — they count the grade, not the name.
This is the seam that does not exist anywhere in gsm today. The value-level mapping currently lives as literal strings inside hep_explorer.yaml's lSettings.measure_values, which means a study either happens to write “Alanine Aminotransferase” or the chart is empty and says nothing about why. Four rows of a CSV close it, and the file is generated with the study's own values already counted so the user is ranking their own data rather than recalling it.
The 9 pre-filled rows are real: safetyCharts::meta_hepExplorer has five type == "field" rows carrying ADaM and SDTM defaults per measure. That is a value-level metadata layer safetyGraphics already has and gsm.mapping does not — see page 2.
the product · costed finding 2
One measure, two units — the thing no naming surface can repair
● ● ● zsh
⚠ COSTED · one measure arrives in two units
After the vocabulary above, ALT arrives as:
U/L 13,884 records median 24 range 4 – 812
µkat/L 2,268 records median 0.41 range 0.07 – 13.5
The two differ by a factor of about 60. Plotted on one axis the µkat/L
records become 2,268 apparent normals, and every reference-range
comparison against lbstnrhi is meaningless for them.
This is not a naming problem and no source_col: line repairs it.
Fix — one row, in the units vocabulary:
config/vocab/lbstresu.csv
from,to,factor
U/L,U/L,1
µkat/L,U/L,60
…
factor multiplies the numeric columns paired with this unit column by
convention: lbstresn, lbstnrlo, lbstnrhi. It is applied once, when the
file is read, before any mapping SQL sees the frame — so every metric
and every chart downstream sees one scale.
og doctor re-checks after the edit: a measure whose values still span
more than one unit is reported again, so a wrong factor does not pass.
Where this direction stops, on purpose
A multiplier is arithmetic, and arithmetic in a config file is a slope this design refuses to slide down. One constant per unit is the whole extension; anything needing a formula, a lookup by age and sex, or a per-visit adjustment is out of scope and og doctor says so rather than inventing a syntax. The user writes an R script, points data-config.yaml at its output, and that script is in their repository like everything else.
the product · costed findings 3 and 4
The column that cannot exist, and the domain nobody sent
● ● ● zsh
⚠ COSTED · required, and possibly not obtainable
Raw_LB.toxgrg_nsv CTCAE toxicity grade
No source variable in SDTM. None in ADaM unless somebody derived one.
Your adlb.sas7bdat has ATOXGR — 23% populated, values "" "0" "1"
"2" "3" "4". og scan did not use it: a toxicity grade on a lab
record is a derivation, and whether it is this grade is your call,
not a matcher's.
Cost cou0005 and kri0005 (Grade 3+ Lab Abnormality Rate) do not
run. srs0001's site risk score then averages 11 kri inputs
instead of 12 — its denominator changes, its scale does not.
Fix — one of:
workflows/1_mappings/LB.yaml:47 source_col: ATOXGR
config/data-config.yaml decline: Raw_LB.toxgrg_nsv: "<reason>"
Raw_STUDY 15 columns, 0 delivered
No CTMS extract in input/. This domain is study metadata, not
participant data: no metric workflow reads it.
Cost the study masthead falls back to config/study-config.yaml.
No metric, no KRI, no chart is affected.
Fix decline it, or type the four fields the masthead uses into
config/study-config.yaml.
Same for Raw_EG (0 delivered — no ECG dataset; costs qt-explorer and
cou0012/kri0012) and Raw_SDRGCOMP (costs kri0006/cou0006).
config/data-config.yaml — what the user writes
decline:
Raw_LB.toxgrg_nsv: "our ADaM derives ATOXGR from a local grading table,
not CTCAE v5 — not comparable across studies"
Raw_STUDY: "no CTMS extract; masthead fields typed by hand"
Raw_EG: "no central ECG collection on this protocol"
This is the moment the direction earns its keep. A declined column is not an absent one: the reason travels into the snapshot's provenance record, which open.gismo already renders as the provenance chip, so the study site can say “Grade 3+ Lab Abnormality Rate: declined — our ADaM derives ATOXGR from a local grading table” rather than quietly not showing a KRI. A checkbox in a wizard cannot carry that sentence. A line in the study's own config, in git, with an author and a date, can.
the product · the run is allowed
Green, and what green means
● ● ● zsh
$ og doctor ~/hep-201
open.gismo doctor — HEP-201
~/hep-201 14 domains · 126 columns
✔ no blocking findings
✔ 10 domains ready Raw_AE Raw_LB Raw_PD Raw_SUBJ Raw_ENROLL
Raw_STUDCOMP Raw_SITE Raw_QUERY Raw_DATACHG Raw_DATAENT
◦ 4 declined Raw_STUDY Raw_EG Raw_SDRGCOMP Raw_LB.toxgrg_nsv
✔ key overlap 6 joined pairs, all ≥ 99.4%
✔ vocabularies lbtstnam 61/61 · lbstresu 9/9
This run will produce 24 of 30 metric workflows
srs0001 over 10 kri inputs (of 12; 2 declined)
7 of 9 safety charts
qt-explorer: not built (Raw_EG declined)
$ og run ~/hep-201
… 4 phases, 38 workflows …
✔ snapshot 2026-08-26 written to output/ · og view ~/hep-201
✔ Green never means “complete”
It means: nothing is silently broken, and everything absent is absent on the record with a reason. The site risk score prints its denominator, two KRIs print as declined with the user's sentence, and the qt-explorer tab is not there rather than being there and empty. Compare that with today's alternative, in the next frame.
today · what a competent user actually does
The wrong turn, which is available right now
Today there is no scan and no doctor, and og_validate() cannot see source_col at all — so it stays red no matter how correct the mapping is. The rational response is to stop using it and write a preparation script, which is exactly what a competent statistician would do:
~/hep-201/prep.R — the thing a good user writes today
subj <- haven::read_sas("adsl.sas7bdat") |>
dplyr::rename(subjid = SUBJID, invid = SITEID, agerep = AGE) |>
readr::write_csv("input/Raw_SUBJ.csv")
lb <- haven::read_sas("adlb.sas7bdat") |>
dplyr::rename(subjid = USUBJID, lbtstnam = PARAM, lbstresn = AVAL) |>
readr::write_csv("input/Raw_LB.csv")
● ● ● zsh — open.gismo v0.2.0, unchanged
$ Rscript -e 'og_validate("~/hep-201")'
open.gismo validation — HEP-201
✔ Raw_AE input/Raw_AE.csv 3,412 x 16
✔ Raw_LB input/Raw_LB.csv 61,004 x 14
✔ Raw_SUBJ input/Raw_SUBJ.csv 1,000 x 15
…
✔ 10 ready
All input domains are ready. Run og_run("~/hep-201").
$ Rscript -e 'og_run("~/hep-201")'
✔ snapshot written. No error. No warning. Two minutes.
✖ And the study site is a lie
Both columns are named subjid and both are populated, so validation is genuinely, correctly green. The inner joins in LB.yaml and AE.yaml return zero rows. Enrolment, query, data-entry and deviation KRIs render normally. Adverse-event rate and lab-abnormality rate read 0.00 at all 42 sites. The site risk score averages the metrics that survived and ranks every site as low risk. A medical monitor opens this and sees a clean study.
Two rename() calls, six words apart, written by somebody doing everything right. That is the failure this direction is built around, and it is the reason the diagnostic has to be the interface rather than a report the user is free to skip.
the tally
What the user had to do, and what they never saw
| The user's whole contribution | where | lines |
| Change one identifier | workflows/1_mappings/SUBJ.yaml | 1 |
| Settle four lab-measure names | config/vocab/lbtstnam.csv | 4 |
| One unit factor | config/vocab/lbstresu.csv | 1 |
| Three declines, each with a reason | config/data-config.yaml | 3 |
| Eight remaining unsettled columns, none read by a metric | various | 8 |
| Total | | 17 |
Seventeen lines of text against a 126-column contract, in files the study's own git history already tracks, produced through five commands and one git diff. The 73 the generator settled and the 47 metric-to-column dependencies it priced were never surfaced as questions, because they were never decisions.
And the honest counterweight
Seventeen is the number when the ranking works. The same run leaves 53 columns unsettled in total; the claim that only 12 matter rests entirely on the metric dependency graph being right, and the claim that a user finds the one identifier line rests on them reading a diff carefully at the exact moment they are least motivated to. Both are on the design page as W2 and W4, and neither is solved here.