#' Demography table 14.1.1
#'
#' @output_id T14-1-1
#' @sap_ref 9.2
#' @spec_version 2.0
#' @export
t_14_1_1 <- function(adsl) { ... }Building a Traceability Matrix for Statistical Programming (Without Maintaining It by Hand)
- A traceability matrix proves that every planned analysis exists, was produced by an identified program, and was checked. It links SAP item → specification → program/function → output → QC record.
- Hand-maintained spreadsheets drift. Generate the matrix from metadata that your pipeline already has: a specification table, roxygen tags or structured comments in programs, and the QC log.
- A dozen lines of R produce the matrix and, more importantly, the gaps: outputs with no QC record, specifications with no program, programs with no specification.
- In a migration, the matrix is what turns a pile of comparison logs into an auditable equivalence report.
The traceability matrix is the document a sponsor’s auditor opens first and the one programming teams least enjoy writing. It does not have to be written. If the specification, the code and the QC log each carry a stable identifier, the matrix is a join.
What must be linked
For each analysis output (table, listing, figure or analysis dataset):
- SAP reference. Section or table number in the statistical analysis plan.
- Specification item. The mock shell or dataset specification with its version.
- Program or function. File path and version-control commit, or package name and version.
- Output. File name, run date, checksum, environment (lockfile hash).
- QC record. Method (dual programming, code review), QC programmer, date, result, link to the comparison log.
Add a status column (not started, produced, QC passed, QC failed, superseded) and you have everything an auditor asks for.
Put identifiers where the work happens
Specification table. One row per output, maintained by the statistician, with an output_id such as T14-1-1.
Programs. A structured header or, for packaged functions, a roxygen tag:
For scripts rather than functions, the same fields go in a fixed comment block that a regex can read.
QC log. Written by the comparison code itself, one row per comparison run, with the output_id, the result and the tolerance rule set used. See numerical tolerance rules.
Generating the matrix
library(dplyr)
spec <- tibble::tribble(
~output_id, ~sap_ref, ~title, ~spec_version,
"T14-1-1", "9.2", "Demographics", "2.0",
"T14-3-1", "9.4", "Adverse events by SOC and PT", "2.0",
"F14-2-1", "9.3", "Kaplan-Meier, primary endpoint", "1.1",
"L16-2-7", "10.1", "Listing of deaths", "1.0"
)
programs <- tibble::tribble(
~output_id, ~program, ~commit,
"T14-1-1", "R/t_14_1_1.R", "a1c3e9f",
"T14-3-1", "R/t_14_3_1.R", "a1c3e9f",
"F14-2-1", "R/f_14_2_1.R", "b77d210"
)
outputs <- tibble::tribble(
~output_id, ~file, ~run_date, ~lock_hash,
"T14-1-1", "t_14_1_1.rtf", "2026-10-06", "9f2a…",
"T14-3-1", "t_14_3_1.rtf", "2026-10-06", "9f2a…",
"F14-2-1", "f_14_2_1.pdf", "2026-10-07", "9f2a…"
)
qc <- tibble::tribble(
~output_id, ~method, ~qc_by, ~qc_date, ~result,
"T14-1-1", "dual programming", "QC2", "2026-10-07", "pass",
"F14-2-1", "dual programming", "QC2", "2026-10-07", "fail: 1 deviation open"
)
matrix <- spec |>
left_join(programs, by = "output_id") |>
left_join(outputs, by = "output_id") |>
left_join(qc, by = "output_id") |>
mutate(status = case_when(
is.na(program) ~ "no program",
is.na(file) ~ "not produced",
is.na(result) ~ "awaiting QC",
startsWith(result, "pass") ~ "QC passed",
TRUE ~ "QC failed"
))
matrix |> select(output_id, sap_ref, program, file, method, result, status)# A tibble: 4 × 7
output_id sap_ref program file method result status
<chr> <chr> <chr> <chr> <chr> <chr> <chr>
1 T14-1-1 9.2 R/t_14_1_1.R t_14_1_1.rtf dual programming pass QC pa…
2 T14-3-1 9.4 R/t_14_3_1.R t_14_3_1.rtf <NA> <NA> await…
3 F14-2-1 9.3 R/f_14_2_1.R f_14_2_1.pdf dual programming fail: 1 d… QC fa…
4 L16-2-7 10.1 <NA> <NA> <NA> <NA> no pr…
The join is the matrix. The status column is the project dashboard. And the gaps are visible at once:
matrix |> filter(status != "QC passed") |> select(output_id, title, status)# A tibble: 3 × 3
output_id title status
<chr> <chr> <chr>
1 T14-3-1 Adverse events by SOC and PT awaiting QC
2 F14-2-1 Kaplan-Meier, primary endpoint QC failed
3 L16-2-7 Listing of deaths no program
A listing with no program, a table awaiting QC and a figure with an open deviation. That is the morning stand-up list, generated rather than remembered.
Catching the reverse gaps
Traceability runs both ways. Programs with no specification are as much a finding as specifications with no program:
anti_join(programs, spec, by = "output_id") # orphan programs (none here)# A tibble: 0 × 3
# ℹ 3 variables: output_id <chr>, program <chr>, commit <chr>
anti_join(outputs, qc, by = "output_id") # outputs never QC'd# A tibble: 1 × 4
output_id file run_date lock_hash
<chr> <chr> <chr> <chr>
1 T14-3-1 t_14_3_1.rtf 2026-10-06 9f2a…
Add these two checks to CI and the matrix can never silently drift from the codebase.
Rendering it
The same data frame renders to the sponsor-facing document with gt, flextable or rtables, and to an Excel export for QA with openxlsx. Keep the rendered copies as artefacts of a tagged release, never as the source of truth. When a reviewer asks “which commit produced table 14.3.1 and who checked it”, the answer is one filter away.
In a SAS-to-R migration
The matrix gets two extra columns during the parallel-run phase: the SAS reference output and the comparison log. The equivalence report is then a summary of the matrix: outputs compared, outputs identical, explained differences, deviations and their resolution. This is the artefact that closes Phase 3 of the five-phase roadmap and feeds the validation summary.
Related: qualifying packages with riskmetric and renv, and our services for CROs and sponsors.