open.csr · data design framework · 2026-08-26

How a number reaches a sentence

open.csr assembles a Clinical Study Report from four kinds of file, and nothing else. This page walks all four, end to end, using one real display from the demo study — the demographics table — and follows a single number from the ADaM dataset it is computed from to the sentence in Section 11.2 that quotes it.

The short version

The one idea

Numbers and words are not kept consistent by checking. They are kept consistent because the words never contain a number in the first place — only a pointer to one. Consistency stops being a QC activity and becomes a property of the file format.

6
displays in the demo library
15
prose blocks, 10 of them approved into the report
15
named values — 13 bound, 2 arithmetic
181
number bindings resolved at build
119
E3 sections modelled, 18 filled by the demo

The picture

The four parts of the open.csr framework and how they connect Study data flows into a display specification, which the R pipeline turns into an ARD of statistics and a rendered table. Statistics in the ARD are addressed by named values and by prose bindings in text blocks. The template combines displays, values and text into the assembled report. ADaM study data pharmaverseadam · CDISCPILOT01 1 · DISPLAY SPEC analysis.yaml — what to compute display.yaml — how to show it iterations.yaml — every rebuild library/tfl/<slug>/ ard.json one row per statistic plus its provenance rendered display table.html · table.rtf manifest.json each row addressed as display:analysis:stat 2 · VALUES a number the report reuses, given a name once source: an ARD address derived: declared arithmetic library/values/values.yaml 3 · TEXT prose blocks keyed to an ICH E3 section numbers arrive as bindings, never as typed digits library/text/TXT-E3-<section>.md cited by name 4 · TEMPLATE OBJECTS sections.yaml — what a document of this kind is: its numbered sections and what each may contain assembly.yaml — what THIS study puts in them: which prose, which display, in which slot Two objects today: ich-e3 (119 sections) and e3-synopsis (25). Numbers are assigned per document at build time. library/templates/<id>/ assembled documents docs/assembled/ · the site CI GATES every binding resolves once every digit in prose traces to a binding
The R pipeline owns the top band, from study data to a rendered table. The Node build owns everything below it, from the first binding to the assembled report. The two halves meet at one file per display, ard.json, and at one address format.

Part 1 · The atoms

What an ARD is here

An Analysis Results Dataset is the table of numbers before it is a table. Instead of a formatted grid, it is a long dataset with one row per computed statistic, and enough columns on each row to say exactly which statistic it is: which analysis produced it, which treatment group it belongs to, which variable and which level of that variable, and what the statistic is called.

open.csr uses the {cards} package to compute them and serializes them to a JSON file it owns, so the shape is fixed and the JavaScript half of the build can rely on it. Every row carries the same thirteen keys. Here are three real rows from the demographics display:

outputs/t-demographics/v002/ard.json — 3 of 236 rows
{"analysis":"age","group1":"TRT01A","group1_level":"Total","group2":null,"group2_level":null,
 "variable":"AGE","variable_level":null,"context":"continuous",
 "stat_name":"N","stat_label":"N","stat":254,"warning":null,"error":null}

{"analysis":"age","group1":"TRT01A","group1_level":"Total", ... "variable":"AGE",
 "stat_name":"mean","stat_label":"Mean","stat":75.0866141732284, ...}

{"analysis":"sex","group1":"TRT01A","group1_level":"Total", ... "variable":"SEX",
 "variable_level":"F","context":"categorical",
 "stat_name":"p","stat_label":"%","stat":0.562992125984252, ...}

Two properties of that file matter more than they look:

Alongside the rows, the same file carries a provenance envelope: the hash of each spec that produced it, the hash and row count of every input dataset with the package version it came from, the R version and package versions of the session, and the git commit — or null where the working tree was dirty and the artifact cannot honestly name one.

The three files that declare a display

A display lives in one directory named for its slug — library/tfl/t-demographics/ — and holds three files. The split is the point: a statistician changes the first, a medical writer or a shell reviewer changes the second, and neither has to read the other to work.

analysis.yaml

what to compute
  • Which prepared dataset and which analysis population
  • Which column defines the treatment columns, and whether a total column is added
  • An ordered list of analyses, each naming a method from a fixed vocabulary

display.yaml

how to show it
  • Title, study, population label, cut-off, footnotes, source line
  • Column order, row order, labels, indentation, section headers
  • Number formats and decimal places
  • Variants: the full Section 14 table, and a reduced in-text one

iterations.yaml

every rebuild, in order
  • One entry per regeneration: version, timestamp, who ran it
  • The change request in plain words
  • Hashes of the two specs and of the ARD produced

The demographics display declares six analyses. This is the whole of what it asks to be computed:

library/tfl/t-demographics/analysis.yaml (abridged)
id: t-demographics
regulatory_id: DMT01
type: table
dataset: adsl
analysis_set: safety
denominator: adsl
group: [TRT01A]
total: true
analyses:
  - { name: age,      method: continuous,  label: "Age (years)",  variables: [AGE] }
  - { name: agegr,    method: categorical, label: "Age group",    variables: [AGEGR1] }
  - { name: sex,      method: categorical, label: "Sex",          variables: [SEX] }
  - { name: race,     method: categorical, label: "Race",         variables: [RACE] }
  - { name: ethnic,   method: categorical, label: "Ethnicity",    variables: [ETHNIC] }
  - { name: baseline, method: continuous,  label: "Baseline vital signs",
      variables: [BLWT, BLHT, BLBMI] }

There are six built-in methods — a continuous summary, a categorical breakdown, a unique-subject count with denominator and percentage, a nested count for system-organ-class over preferred term, a row passthrough for listings, and a figure. Anything a method cannot express is written as an R function in a custom.R file beside the two YAML files, and named from the analysis; it receives the data and the spec and must hand back an ARD like any other analysis. One of the six demo displays uses that escape hatch.

display.yaml then says how those numbers become a page. Its rows block is a printing plan, not a computation:

library/tfl/t-demographics/display.yaml (abridged)
columns:
  order: [Placebo, "Xanomeline Low Dose", "Xanomeline High Dose", Total]
format:
  digits: { p: 1, mean: 1, sd: 2, median: 1, min: 0, max: 0 }
rows:
  - { label: "Age (years)", section: true }
  - { analysis: age, variable: AGE, pattern: "{N}",   label: "n",         indent: 1 }
  - { analysis: age, variable: AGE, pattern: mean_sd, label: "Mean (SD)", indent: 1 }
  - { label: "Sex, n (%)", section: true }
  - { analysis: sex, variable: SEX, levels: all, pattern: n_pct, indent: 1,
      level_order: [F, M] }
variants:
  post_text: {}
  in_text:
    title: "Demographic Characteristics (Summary)"

pattern names a cell template. Eleven are built in — n, N, n_pct, pct, continuous, mean_sd, median, median_range, range, q1_q3 and value — and the display's own format block can add to or override any of them with a template such as "{n} ({p}%)". No closures ever enter a spec — presentation stays declarative, which is what makes a spec reviewable as a diff.

A trap the format inherits from YAML

An unquoted pattern: n parses as the boolean false under YAML 1.1, not as the string “n”. The pipeline rejects a non-string row key with an explicit message rather than producing a silently wrong table, but a spec author needs to know to quote it.

Part 2 · How a display becomes an output

One R function does the whole of it. regenerate("t-demographics") reads the two specs, validates them and checks they agree with each other, prepares only the datasets this display asks for, computes the ARD, allocates the next version directory, and writes eight files into it.

outputs/t-demographics/v002/ — what one regeneration writes
analysis.yaml         a byte copy of the spec that produced this, not a reference to it
display.yaml          likewise
ard.json              236 rows + the provenance envelope
table.html            the Section 14 variant, rendered
table.rtf             the same rendered cells as a submission artifact
table-in-text.html    the reduced variant used inside the narrative
table-in-text.rtf
manifest.json         who, why, when, which hashes, how many rows, which variants

The reason this matters for the rest of the framework: everything downstream binds against the current iteration's ARD. Regenerating a display is therefore the event that can move a number in a sentence, and it is a single, dated, attributed, hashed act.

Part 3 · Values — a number with a name

An address is precise but unfriendly. t-disposition:randomised:n;group=Total is the number of subjects randomised, and a writer who wants that number in four different sentences would otherwise retype the address four times — and, worse, would have to re-derive it if the disposition display were restructured.

library/values/values.yaml gives such a number a name, once, centrally. It is source: a human or an agent edits it, and the pipeline regenerates the store. There are two kinds of entry, and exactly one kind per value.

KindDeclaresReal exampleResolves to
source An ARD address, including the display it comes from randomised-nt-disposition:randomised:n;group=Total 254
derived An operation over other named values ae-any-n-xanomelinesum of the low-dose and high-dose counts 152
library/values/values.yaml (abridged)
values:
  - id: randomised-n
    label: "Subjects randomised"
    source: t-disposition:randomised:n;group=Total
    format: { digits: 0 }
    notes: "The denominator for every disposition percentage in section 10.1."

  - id: completed-pct
    label: "Percentage of randomised subjects who completed"
    source: t-disposition:completed:p;group=Total
    format: { scale: 100, digits: 1 }

  - id: ae-any-n-xanomeline
    label: "Subjects with at least one adverse event, both xanomeline arms"
    derived: { op: sum, inputs: [ae-any-n-low, ae-any-n-high] }
    format: { digits: 0 }

Why the arithmetic is declared rather than written

The derivation vocabulary is deliberately closed: sum, difference, ratio, percent, and nothing else. It would have been easier to allow a free-text expression. The reason it is a fixed list is that two different programs have to agree about the answer without running each other's code:

An arbitrary expression would mean either a shared interpreter or a trusted single implementation. A four-operator vocabulary means both sides can implement it correctly and a disagreement between them is a build failure rather than a silently different number. A derived value's inputs are themselves named values, so the chain back to an ARD row never breaks.

Why the generated store is never edited

outputs/values/values.json is what the store looks like after the pipeline runs it. Each entry carries the resolved number, the formatted string, and the citation: the address, the display, the iteration, the ARD file it was read from and that file's hash.

outputs/values/values.json — one entry
{ "id": "randomised-n", "label": "Subjects randomised", "kind": "ard",
  "value": 254, "formatted": "254", "format": { "scale": 1, "digits": 0 },
  "source": { "address": "t-disposition:randomised:n;group=Total",
              "display": "t-disposition", "analysis": "randomised",
              "iteration": "v002", "ard_file": "outputs/t-disposition/v002/ard.json",
              "ard_hash": "sha256:..." } }
The rule, and the reason for it

Editing the generated store by hand would produce a number that cites an ARD it did not come from — a citation that is false in exactly the way the whole framework exists to prevent. So the build re-derives every value from the same committed ARDs the report is built from, and fails if the address now resolves to zero rows or several, if the ARD row no longer equals the stored value, if the cited hash is not the committed one, if the formatted string does not match its own declared format, or if a derived value no longer equals its own arithmetic.

The same principle covers outputs/, docs/assembled/, docs/evidence/ and the built site: they are all generated, all overwritten on the next run, and the repository's standing instruction to agents is that they may write source and only source. The pipeline is the only thing that regenerates.

Part 4 · Text — a sentence that binds a number

A text block is a markdown file with a YAML header, named for the ICH E3 section it belongs to. library/text/TXT-E3-1102.md is Section 11.2, Demographic and Other Baseline Characteristics.

The header carries what a reviewer needs to know about the block, not about its prose: which E3 section it fills, which displays it depends on, which requirements it satisfies, its approval state and who granted it, whether it was model-generated and with what prompt, and any digits it is explicitly allowed to state in words.

library/text/TXT-E3-1102.md — header and the first sentences
---
id: TXT-E3-1102
e3_section: "11.2"
title: "Demographic and Other Baseline Characteristics"
tier: parameterized
displays: [t-demographics]
allow_digits: []
approval: { state: approved, by: "@jwildfire", at: "2026-07-25" }
provenance: { model: null, prompt: null }
requirements: [TXT-DEMO-001]
---

Demographic and baseline characteristics were comparable across the three treatment
groups in the safety analysis set of
{{ard:t-demographics:age:N;group=Total}} patients.

Women accounted for {{ard:t-demographics:sex:n;variable_level=F;group=Total}} patients
({{ard:t-demographics:sex:p;variable_level=F;group=Total;scale=100;digits=1}}%) overall.

At build time that becomes:

docs/assembled/csr.json — section 11.2, resolved
Demographic and baseline characteristics were comparable across the three treatment
groups in the safety analysis set of 254 patients.

Women accounted for 143 patients (56.3%) overall.

Three kinds of token can appear in prose, and all three are the same idea — the sentence names something the build owns rather than stating it:

TokenNamesFails the build when
{{ard:...}}One row of one display's current ARDIt matches zero rows, or more than one
{{value:...}}An entry in the named-value storeThe name is unknown, or the store disagrees with the ARD
{{xref:...}}A display's assigned table number, or another sectionThe target does not exist in this report

Two qualifiers ride on an address and are worth separating in your head, because only one of them can change which number you get:

The gate that makes it stick

None of this would hold if a writer could simply type a number, so the build checks. After the bindings are substituted, it scans the rendered prose for digits and requires every digit run to have come from a resolved binding, a cross-reference, or a short explicit allow-list in the block's header for things that genuinely are text — an E3 section number, a protocol identifier, a threshold like “30 days” in a block about exposure windows. Digits inside inline code and markdown links are exempt.

The consequence is the thing worth telling a colleague: a typed number does not become a QC finding weeks later, it becomes a red build, in the pull request that typed it.

Tiers and approval

A block declares one of three tiers — boilerplate (standing text), parameterized (prose written once, numbers bound), generated (drafted by a model). A generated block that is not approved is not a failure; it is excluded from the assembled report and reported as pending review. In the current demo, 10 of the 15 blocks are in the report and 5 generated-tier drafts are held out, each named with the reason.

Part 5 · The template — what assembles all four

A template object is a directory under library/templates/ holding two files, and the division between them is the useful part.

Updated the same evening

This page first described a library with one template object in it. A second landed 45 seconds before the companion pull request merged, so the section below is written for the pair. The library holds ich-e3, the full 119-section Clinical Study Report, and e3-synopsis, the 25-section ICH E3 Annex I synopsis of the same study. node scripts/assemble.mjs --template <id> builds one; --all builds every object, which is what CI runs.

Two properties of that pair are the reason a second object exists at all, and both are the framework paying off rather than new machinery:

Gates judge the document being assembled rather than the whole Text Library — an E3 block legitimately cross-references Section 16.2.1 and must not fail against a synopsis model that has no Section 16. A block the library holds but this build did not assemble is reported as a warning, so “not gated” cannot read as “gated and clean”. The rest of this section walks ich-e3.

sections.yaml

what an ICH E3 report is
  • 119 sections: number, title, a stable slug, and what kind of content each may hold
  • Written once for the standard, reusable by any study
  • Encoded from ICH E3 itself — nothing copied from a proprietary template

assembly.yaml

what this report puts in them
  • The study's own facts: title, phase, indication, arms, cut-off, scope notes
  • Narrative slots: which text blocks and which in-text displays fill which section
  • Section 14 slots: which displays sit under which post-text heading, in order

A section in sections.yaml declares its content model from a four-word vocabulary: text for narrative supplied by the Text Library, in_text_display for a reduced display rendered inline, post_text_index for a Section 14 container, and generated_provenance for the appendix the assembler writes itself. An empty list means a structural heading. Across the 119 sections that comes out as 62 text-only, 20 text plus an in-text display, 15 post-text containers, 21 structural headings and one provenance appendix.

library/templates/ich-e3/sections.yaml and assembly.yaml (abridged)
# sections.yaml — the standard
- number: "11.2"
  title: "Demographic and Other Baseline Characteristics"
  slug: demographics
  content: [text, in_text_display]

# assembly.yaml — this report
slots:
  - section: "11.2"
    text: [TXT-E3-1102]
    displays: [t-demographics]     # rendered here as the in_text variant

post_text:
  - section: "14.1"
    displays: [t-disposition, t-demographics]

What a section actually pulls in

Section 11.2 of the demo report, assembled, is:

Numbering is derived, never typed

No file in the repository contains the string “Table 14.1.2”. The display's identity is its slug; the number is assigned from the order of the post_text lists at build time, and any sentence that mentions the table asks for it by slug. Reordering Section 14 is a one-line diff, and every cross-reference in the narrative follows automatically. A number that collides with a real E3 section number is treated as a build failure.

The assembler also writes a provenance appendix into Section 16.1.9 with no human involvement: for each display, its number, the hashes of both specs, the input datasets with their versions and hashes, the R environment, and the commit. It is the audit trail as a generated section of the document rather than a separate exercise.

One command does all of it. node scripts/assemble.mjs --all walks each object's section model, resolves the text library against the current ARDs, places the display variants, assigns the numbering, runs the gates, and writes the assembled JSON and HTML for every template object. It exits non-zero if any gate fails in any document, and CI runs it on every pull request.

What CI enforces, in one list

Six checks stand between a change and a merged report. None of them is a review activity; all of them are a red build.

Alongside those, the R suite is re-run from scratch and compared against its committed results, so the evidence in the repository can never be stale, and the site build fails on a broken internal link or any reference to an external host.

Where the code and the documentation disagree

These came out of reading the code against the written contracts. None is a defect in the framework; all five are things a reviewing colleague would otherwise trip over, and the first is the one worth acting on.

1 · Not one committed artifact names its commit

Provenance is designed to be honest: when the working tree is dirty, the pipeline records git_commit as null rather than guessing. That mechanism is working. The consequence, which is not written down anywhere, is that every artifact currently in the repository was generated from a dirty tree — all 13 ARDs across the six displays record a null commit, all 13 iteration-ledger entries record an empty one, and so does the values store.

The README says each iteration is “reproducible from its commit.” As a design property that is true. As a statement about the files a colleague will open today, it is not yet: the field that would let them do it is empty in every one. A single regeneration from a clean tree, committed, would close it.

A second, smaller half of the same finding: the ARD and the manifest write null for an unknown commit while the iteration ledger writes an empty string. Two spellings of the same absence, and neither is documented in the contracts.

2 · Named values reach the synopsis, not the report

Fifteen values are declared, thirteen bound to ARD addresses and two derived. The store is generated, hashed and gated on every build, and the gate passes.

The Annex I synopsis cites eleven of the fifteen across two blocks, and the two derived values are doing real work there: ae-any-n-xanomeline resolves to 152 and ae-excess-high-vs-placebo to 3, in assembled prose. The full ICH E3 report resolves none — its only {{value:}} citations sit in one model-generated draft that has not been approved and is therefore correctly excluded from assembly. Approving or retiring that block is what would close the report side.

Written earlier the same day as “the named-value store reaches no approved sentence”, which was true of the whole library until the synopsis template object landed that evening. Corrected rather than deleted, because what was observed and when is part of the record.

3 · The framework has four parts; the README and the site describe three

Both open with “the three components” — the TFL library, the text library and the template library — and the architecture cards on the site homepage repeat exactly those three. The values store is a fourth source directory with its own schema, its own R builder, its own JavaScript gate and its own binding token, and it appears in neither. A colleague reading either surface would not learn it exists. The pull request that publishes this page adds it to both.

4 · Four checkable gaps in the interface contracts

The contracts document is unusually good and mostly ahead of its peers, so these are worth naming precisely rather than generally:

  • iterations.yaml is one of the three files that define a display and has no schema section — it appears only as a filename in the layout listing, with its fields described in prose in the design document.
  • The pattern row key is documented as optional but its vocabulary is never listed; there are eleven built-ins, and knowing them is the difference between writing a display spec and guessing at one.
  • Variant configuration is documented as taking a filter only. The renderer also honours title and footnotes, and five of the six displays use title.
  • The assembler's own header comment lists five gates on the assembled document; the code emits six. The values gate is described in the contracts' prose but missing from that shape.

5 · A named-value citation leaves no entry in a block's bindings list

The bindings array the assembler writes for each block indexes ARD bindings only. The synopsis block that resolves six named values reports an empty array, so anything counting citations from the assembled JSON undercounts what the sentence actually cites.

The substitution is span-tracked either way, so the numeric-fidelity gate is unaffected. This is an index gap rather than a gate gap — worth knowing before anyone builds a coverage report on that array.

Where to read next

The demo study is CDISCPILOT01 from {pharmaverseadam} — public, regenerable, and the only data anywhere in the repository.