open.gismo · data-loading design · page 3 of 3

The three-laboratory liver panel

The hardest starting point in the brief, run end to end against the installed packages rather than reasoned about. 765 participants delivered; 417 reach the flagship chart; nothing anywhere says so.

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 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.

14,300rows delivered
765participants delivered
12spellings for 4 analytes

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 missingWhat the pipeline doesWhere
A column named in the specDropped from the query. No message.ApplySpec.R:47
A domain's input filewarning() and continue.og_run.R:397
A join partner for a rowRow dropped. inner_join is silent by construction.LB.yaml, AE.yaml
A value the chart is looking forRow 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

StageRowsParticipantsTold to anyone?
Delivered by the CRO14,300765
After the participant inner_join10,076548✖ No
After measure_values filtering7,532417✖ 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.

  1. Assign the file to the domain. The bench proposes LAB_LIVER_ALL.csv for Raw_LB at 93% name coverage — and also proposes it for Raw_DATACHG at 75%, which is nonsense. It says the number and does not act on it. The person assigns the file.
  2. Accept the identical names. Thirteen of fourteen Raw_LB columns land on the first pass, because gsm's findings vocabulary is lower-case SDTM. toxgrg_nsv does not, because CTCAE grade has no source in either standard.
  3. The blank cells are on the row before anything runs. 4,224 empty LBSTNRHI values, printed beside the binding, not thrown from frame 29 of a stack trace.
  4. 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 own LB.yaml as a gsm.core::RunQuery step, exactly like the CAST() that file already carries. Re-run: ✔ 14,300 of 14,300.
  5. 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 WHEN in the same query step.
  6. 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:

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.