Everything below was executed on 2026-08-27 against gsm.mapping 1.1.3, gsm.core 1.2.0 and workr 1.0.0 as installed on this machine, with demo-301's own LB.yaml spec and Raw_SUBJ.csv. The delivery file is constructed — demo-301's liver analytes re-cut as a three-vendor pooled extract — and it is the only invented thing on this page. Console output is verbatim.
The starting point
A phase 2 study. Liver chemistry arrives as one file from the CRO, LAB_LIVER_ALL.csv, because three laboratories contributed to it and the CRO pooled them:
- The central laboratory — 7,532 rows, 417 participants. Full analyte names, U/L and mg/dL, reference ranges on every row.
- Site-run local laboratories — 4,224 rows, 217 participants. Analytes written
ALT (SGPT),AST (SGOT),Alk Phos,Total Bilirubin. Participant identifiers carry the site number in front of them. No reference ranges at all, because the local labs report against their own and the CRO did not collect them. - A specialty laboratory — 2,544 rows, 131 participants. Analytes as codes:
ALT,AST,ALP,BILI. Bilirubin in µmol/L rather than mg/dL, with its own matching range.
The column names are SDTM-flavoured: USUBJID, SITEID, LBTEST, LBSTRESN, LBSTRESU, LBSTNRLO, LBSTNRHI, LBDY, LBBLFL, LBCAT, VISIT, VISITNUM. That is a perfectly ordinary delivery. Nobody did anything wrong.
Stage 0 — the run does not fail, it aborts
Before any of the interesting failures, the first attempt dies. The local laboratories delivered empty reference-range cells, and read.csv() hands DuckDB the empty string:
Creating a new temporary DuckDB connection. Error in `duckdb_result()`: ! rapi_execute: Failed to run query Error: Conversion Error: Could not convert string '' to DOUBLE when casting from source column LBSTNRLO Backtrace: ▆ 1. ├─gsm.mapping::ApplySpec(src, spec3, "Raw_LB") 2. │ └─gsm.core::RunQuery(...) 3. │ ├─DBI::dbWriteTable(con, temp_table_name, df, append = append_tab) ... 26 more frames ... 29. └─rlang::abort(msg, call = call) Execution halted
Twenty-nine frames. No row number, no file name, no domain, no count of how many cells were blank. The one genuinely useful fact — the source column is LBSTNRLO — is on the third line of a stack trace a data manager has no reason to read.
What the bench does here. Nothing clever: it reads the file itself, so it knows 4,224 of 14,300 cells in that column are empty before anything runs, prints that on the row, and offers the three dispositions that resolve it — bind, derive a default, or decline the column and see what it costs. The failure is a property of the data that was visible the moment the file was opened.
Stage 1 — today's readiness check, on a delivery it can never pass
With the blanks read as missing rather than empty strings, the run proceeds. og_validate() reports:
required columns: 14 present: 0
MISSING: studyid, subjid, visnam, visnum, lb_dy, lb_dt, lbblfl,
battrnam, lbtstnam, lbstresn, lbstresu, lbstnrlo,
lbstnrhi, toxgrg_nsv
Fair enough — the names are different. So write the mapping the way gsm intends, with source_col: on all fourteen, and check again:
og_validate() STILL reports missing: studyid, subjid, visnam, visnum,
lb_dy, lb_dt, lbblfl, battrnam, lbtstnam, lbstresn, lbstresu,
lbstnrlo, lbstnrhi, toxgrg_nsv
...because it compares TARGET names to file columns and never reads source_col.
The same fourteen. open.gismo/R/og_validate.R:201 is setdiff(required, info$cols) — the target names against the file's columns. The string source_col does not appear in the file. Hand the identical spec to the engine and it maps the file without complaint:
✔ SQL Query complete: 14300 rows returned.
ApplySpec() rows: 14300 cols: 13 -> studyid, subjid, visnam, visnum,
lb_dy, lb_dt, lbblfl, battrnam, lbtstnam, lbstresn, lbstresu,
lbstnrlo, lbstnrhi
The readiness screen and the engine disagree about the same project. For any delivery not already named with gsm's internal names, that screen can never reach green — and the person reading it has no way to tell a real problem from this one. This is a small fix, worth making before any screen is designed, and it is the reason this direction refuses to call a mapping ready on a count of filled boxes. Independently found by the companion session the same evening; recorded here because it is the load-bearing fact under this design's readiness rule.
Stage 2 — a one-character typo, and silence
Bind the upper limit of normal to LBULN instead of LBSTNRHI. A plausible slip; the column does not exist.
✔ SQL Query complete: 14300 rows returned. bound lbstnrhi -> 'LBULN' (not in the file). ApplySpec message/warning: none resulting columns: 12 lbstnrhi present? FALSE
gsm.mapping/R/ApplySpec.R:47 is purrr::keep(~ .x$source %in% colnames(dfSource)) — a column whose source is absent is dropped from the query, quietly, at no verbosity level. The run succeeds. The column is gone. Every eDISH point silently loses its denominator.
| What is missing | What the pipeline does | Where |
|---|---|---|
| A column named in the spec | Dropped from the query. No message. | ApplySpec.R:47 |
| A domain's input file | warning() and continue. | og_run.R:397 |
| A join partner for a row | Row dropped. inner_join is silent by construction. | LB.yaml, AE.yaml |
| A value the chart is looking for | Row simply does not match the filter. | hep_explorer.yaml |
Four kinds of absence, four different silences, none of them an error. This is the failure class this direction exists to convert into a visible decision.
Stage 3 — the identifiers that pass every check and join to nobody
Map the columns correctly this time, and run the participant join the study's own LB.yaml performs:
Mapped_LB rows before join: 14300 after inner_join: 10076 participants lost: 217 of 765 example lost ids: SITE4275-S1000, SITE7543-S10245, SITE8768-S10259 warning raised by inner_join: none
Both subjid columns exist. Both are character. Both pass every check og_validate() runs, including the type coercion on a 5,000-row sample. They share not one value, because the local laboratory writes SITE4275-S1000 where demographics writes S1000.
The join is deliberate — demo-301's AE.yaml documents it as the data-cleaning step, and it is right to be: an event against a participant who never enrolled has no denominator to be a rate over. It is the right operation with no instrumentation.
Stage 4 — twelve spellings, four literal strings
workflows/4_modules/hep_explorer.yaml carries this, and it is not a suggestion:
lSettings:
measure_col: lbtstnam
measure_values:
ALT: Alanine Aminotransferase
AST: Aspartate Aminotransferase
TB: Bilirubin
ALP: Alkaline Phosphatase
Four literal strings that must appear in the participant's lbtstnam column. Run the filter over what survived the join:
rows the eDISH renderer will see: 7532 of 10076 joined / 14300 delivered participants the eDISH renderer will see: 417 of 765 delivered unmatched measure names still in the data: ALP, ALT, AST, BILI
What actually reached the chart
| Stage | Rows | Participants | Told to anyone? |
|---|---|---|---|
| Delivered by the CRO | 14,300 | 765 | — |
After the participant inner_join | 10,076 | 548 | ✖ No |
After measure_values filtering | 7,532 | 417 | ✖ No |
Fifty-five per cent of the study reaches the flagship hepatic-safety chart, and the two defects mask each other so cleanly that what arrives looks immaculate. Zero missing reference ranges — because the rows that lacked them are exactly the rows the join already removed. One consistent unit for bilirubin — because the µmol/L laboratory is exactly the one the measure filter already removed. No NAs, no warnings, plausible ratios, a well-formed plot.
The Hy's Law candidate count on this study comes out at 16 candidates among 417 evaluable participants. It is a real number computed correctly over the wrong denominator, and there is no artefact anywhere in the run — not in the console, not in the output, not in the provenance chip — that would let a reviewer discover the other 348.
The same study, on the bench
Every step below is live in the mockup; the figures are the executed ones.
- Assign the file to the domain. The bench proposes
LAB_LIVER_ALL.csvforRaw_LBat 93% name coverage — and also proposes it forRaw_DATACHGat 75%, which is nonsense. It says the number and does not act on it. The person assigns the file. - Accept the identical names. Thirteen of fourteen
Raw_LBcolumns land on the first pass, because gsm's findings vocabulary is lower-case SDTM.toxgrg_nsvdoes not, because CTCAE grade has no source in either standard. - The blank cells are on the row before anything runs. 4,224 empty
LBSTNRHIvalues, printed beside the binding, not thrown from frame 29 of a stack trace. - Dry run measures the key overlap. ✖ 10,076 of 14,300 rows match — 217 participants join to nobody, with the mismatched values printed side by side. One click derives
regexp_replace(subjid, '^SITE[0-9]+-', ''), which is written into the project's ownLB.yamlas agsm.core::RunQuerystep, exactly like theCAST()that file already carries. Re-run: ✔ 14,300 of 14,300. - Twelve spellings, mapped once. The Values pane lists all twelve with their row counts, their laboratory and their units beside the four the chart wants. Mapping compiles to a
CASE WHENin the same query step. - And then it stops at 548 and says why. ⚠ 217 participants (LOCAL lab) — no reference range delivered. The eDISH plot divides by the upper limit of normal; there is nothing to divide by. The bench cannot invent one. What it can do is refuse to let that be invisible — and offer the two honest responses: supply the laboratory's ranges as per-analyte constants, or decline and record that the plot covers 548 of 765.
The whole claim of this direction, in one line. Today the chart draws 417 of 765 participants and nothing says so. On the bench it draws 548, and the screen says the other 217 are missing a reference range. Not a perfect answer. A legible one.
How to reproduce this
The two scripts are short and were run from a clean R session:
build.R— readsdemo-301/input/Raw_LB.csv, keeps the four liver analytes, assigns each participant to one of three laboratories (55/30/15 withset.seed(301)), applies the vendor naming, converts the specialty laboratory's bilirubin to µmol/L with a matching range, blanks the local laboratory's ranges, prefixes the local laboratory's identifiers with the site, and writes the delivery with SDTM-flavoured headers.fail.R— runs the six stages above against the installed packages, using demo-301's ownLB.yamlspec andRaw_SUBJ.csv.
The per-laboratory splits are exact and were checked independently: 7,532 + 4,224 + 2,544 = 14,300 rows, and 417 + 217 + 131 = 765 participants. 14,300 − 4,224 = 10,076 rows after the join; 10,076 − 2,544 = 7,532 after the measure filter. The arithmetic closes at both stages, which is how the two masking defects were separated from each other.
Executed 2026-08-27 against gsm.mapping 1.1.3 / gsm.core 1.2.0 / workr 1.0.0. The delivery file is constructed from demo-301 data and labelled as such throughout; every console block is verbatim. Back to the direction or the bench.