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.
randomised-n instead of re-deriving the address in every sentence.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.
ard.json, and at one address format.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:
{"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:
75.1 in a table and 75.09 in a sentence without either of them being a different number.t-demographics:sex:p;variable_level=F;group=Total selects exactly one of those 236 rows. That address is the only way a number ever leaves this file. If it selects zero rows or more than one, the build fails.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.
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.
The demographics display declares six analyses. This is the whole of what it asks to be computed:
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:
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.
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.
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.
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
v001 stays exactly as it was when v002 is written, and current.json is a one-line pointer at whichever is live. A display's history is a directory listing rather than a git archaeology exercise.iterations.yaml records, in the author's words, why v002 exists.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.
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.
| Kind | Declares | Real example | Resolves to |
|---|---|---|---|
source |
An ARD address, including the display it comes from | randomised-n → t-disposition:randomised:n;group=Total |
254 |
derived |
An operation over other named values | ae-any-n-xanomeline → sum of the low-dose and high-dose counts |
152 |
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 }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.
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.
{ "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:..." } }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.
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.
---
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:
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:
| Token | Names | Fails the build when |
|---|---|---|
{{ard:...}} | One row of one display's current ARD | It matches zero rows, or more than one |
{{value:...}} | An entry in the named-value store | The name is unknown, or the store disagrees with the ARD |
{{xref:...}} | A display's assigned table number, or another section | The 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:
group, group2, variable and variable_level narrow which ARD row is meant. Get these wrong and the build stops.scale multiplies (a proportion of 0.563 becomes 56.3) and digits rounds. They never touch the stored value, so the same row can be quoted to different precision in different sentences without any of them being inconsistent.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.
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.
A template object is a directory under library/templates/ holding two files, and the division between them is the useful part.
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:
t-disposition is Table 14.1.1 in the report and Table 13.1 in the synopsis, from the same unchanged spec, because a display's identity is its slug and its number is assigned at build time.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.
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.
# 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]Section 11.2 of the demo report, assembled, is:
sections.yaml.TXT-E3-1102, with its 16 bindings resolved against the current demographics ARD, rendered from markdown, and carrying its own approval state and fidelity result alongside the prose.in_text variant — the same ARD as Section 14, rendered under the shorter title the display declares for narrative use.t-demographics is the second display listed under the 14.1 slot.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.
Six checks stand between a change and a merged report. None of them is a review activity; all of them are a red build.
{{ard:...}} address matches exactly one row of the display's current ARD.{{xref:...}} resolves to a display or section that exists in this report.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.
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.
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.
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.
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.
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.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.filter only. The renderer also honours title and footnotes, and five of the six displays use title.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.
t-demographics plus TXT-E3-1102.md plus assembly.yaml is about twenty minutes.The demo study is CDISCPILOT01 from {pharmaverseadam} — public, regenerable, and the only data anywhere in the repository.