Skip to main content

Building a Traceability Matrix for Statistical Programming (Without Maintaining It by Hand)

clinical
validation
r-programming
What a traceability matrix must link in a clinical programming project (SAP, specifications, programs, outputs, QC records), how to generate it from metadata in R instead of editing a spreadsheet, and how it supports a SAS-to-R equivalence report.
Author

Rverse Analytics

Published

October 8, 2026

  • 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):

  1. SAP reference. Section or table number in the statistical analysis plan.
  2. Specification item. The mock shell or dataset specification with its version.
  3. Program or function. File path and version-control commit, or package name and version.
  4. Output. File name, run date, checksum, environment (lockfile hash).
  5. 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:

#' 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) { ... }

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.