Traceability is a core regulatory
requirement: reviewers must be able to follow every analysis variable
from its source SDTM domain through any derivation to the final ADaM
dataset. The r4subtrace package builds a directed trace
model and evaluates coverage.
cfg <- trace_config_default()
str(cfg)
#> List of 4
#> $ severity_by_level : Named chr [1:4] "high" "medium" "low" "info"
#> ..- attr(*, "names")= chr [1:4] "L0" "L1" "L2" "L3"
#> $ result_by_level : Named chr [1:4] "fail" "warn" "warn" "pass"
#> ..- attr(*, "names")= chr [1:4] "L0" "L1" "L2" "L3"
#> $ confidence_threshold_L3: num 0.8
#> $ uppercase_datasets : logi TRUE
#> - attr(*, "class")= chr "trace_config"build_trace_model() takes ADaM metadata, SDTM metadata,
and an optional mapping sheet. It returns a trace_model
object with nodes, edges, and diagnostic information.
adam_meta <- data.frame(
dataset = rep("ADSL", 5),
variable = c("STUDYID", "USUBJID", "AGE", "SEX", "TRT01P"),
label = c("Study ID", "Unique Subject ID", "Age",
"Sex", "Planned Treatment"),
stringsAsFactors = FALSE
)
sdtm_meta <- data.frame(
dataset = c(rep("DM", 4), "EX"),
variable = c("STUDYID", "USUBJID", "AGE", "SEX", "EXTRT"),
label = c("Study ID", "Unique Subject ID", "Age",
"Sex", "Treatment Name"),
stringsAsFactors = FALSE
)
mapping <- data.frame(
adam_dataset = rep("ADSL", 5),
adam_var = c("STUDYID", "USUBJID", "AGE", "SEX", "TRT01P"),
sdtm_domain = c("DM", "DM", "DM", "DM", "EX"),
sdtm_var = c("STUDYID", "USUBJID", "AGE", "SEX", "EXTRT"),
stringsAsFactors = FALSE
)
tm <- build_trace_model(adam_meta, sdtm_meta, mapping = mapping)
print(tm)
#> ℹ Trace Model: 5 ADaM vars, 5 SDTM vars
#> ℹ Edges: 5, Orphans: 0, Ambiguities: 0The nodes tibble lists all assets; edges
describes the relationships:
head(tm$nodes)
#> # A tibble: 6 × 6
#> node_id node_type dataset variable label role
#> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 N-9abc20438c6cc0f617e83c62e34902db variable ADSL STUDYID Study ID adam
#> 2 N-eb6b237955dda7f6d6398c61d4f92eea variable ADSL USUBJID Unique Su… adam
#> 3 N-61004a41aba4053d8a388d673f0af1ed variable ADSL AGE Age adam
#> 4 N-05ff9c7794a452f7a2e571197bfc00b8 variable ADSL SEX Sex adam
#> 5 N-921039f11a132416e1358dbbf77c47b1 variable ADSL TRT01P Planned T… adam
#> 6 N-ab72ae71f9c5cbafb45fe6f1b1381c35 variable DM STUDYID Study ID sdtm
head(tm$edges)
#> # A tibble: 5 × 5
#> from_id to_id edge_type confidence source
#> <chr> <chr> <chr> <dbl> <chr>
#> 1 N-9abc20438c6cc0f617e83c62e34902db N-ab72ae71f9c5… derived_… NA mappi…
#> 2 N-eb6b237955dda7f6d6398c61d4f92eea N-ece07ff633f6… derived_… NA mappi…
#> 3 N-61004a41aba4053d8a388d673f0af1ed N-2da1b29c4b59… derived_… NA mappi…
#> 4 N-05ff9c7794a452f7a2e571197bfc00b8 N-41c3ec10e8af… derived_… NA mappi…
#> 5 N-921039f11a132416e1358dbbf77c47b1 N-748b4078dc56… derived_… NA mappi…Diagnostic information flags any orphan variables (unmapped ADaM variables):
compute_trace_levels() summarises coverage per ADaM
dataset:
tl <- compute_trace_levels(tm)
tl
#> # A tibble: 5 × 7
#> adam_dataset adam_var trace_level has_mapping has_derivation_text n_candidates
#> <chr> <chr> <int> <lgl> <lgl> <int>
#> 1 ADSL STUDYID 3 TRUE TRUE 1
#> 2 ADSL USUBJID 3 TRUE TRUE 1
#> 3 ADSL AGE 3 TRUE TRUE 1
#> 4 ADSL SEX 3 TRUE TRUE 1
#> 5 ADSL TRT01P 3 TRUE TRUE 1
#> # ℹ 1 more variable: max_confidence <dbl>If you have an evidence table with
indicator_domain == "trace",
trace_indicator_scores() computes per-indicator
aggregates:
ev_trace <- data.frame(
indicator_id = c("T-001", "T-001", "T-002"),
indicator_domain = "trace",
result = c("pass", "warn", "fail"),
metric_value = c(1.0, 0.8, 0.0),
metric_unit = "proportion",
severity = c("info", "medium", "high"),
stringsAsFactors = FALSE
)
trace_indicator_scores(ev_trace)
#> # A tibble: 5 × 3
#> indicator value description
#> <chr> <dbl> <chr>
#> 1 TRACE_VAR_COVERAGE_L2PLUS NA Proportion of ADaM variables with trace l…
#> 2 TRACE_VAR_COVERAGE_L3PLUS NA Proportion of ADaM variables with trace l…
#> 3 TRACE_ORPHAN_VAR_COUNT 0 Number of orphan ADaM variables with no S…
#> 4 TRACE_AMBIGUOUS_MAPPING_COUNT 0 Number of ADaM variables mapped to multip…
#> 5 TRACE_MEAN_TRACE_LEVEL NA Mean trace level across all ADaM variablesWhen a variable has no mapping entry, it appears as an orphan:
adam_partial <- data.frame(
dataset = rep("ADSL", 3),
variable = c("USUBJID", "AGE", "DERIVED_VAR"),
label = c("Unique Subject ID", "Age", "Derived Variable"),
stringsAsFactors = FALSE
)
mapping_partial <- data.frame(
adam_dataset = c("ADSL", "ADSL"),
adam_var = c("USUBJID", "AGE"),
sdtm_domain = c("DM", "DM"),
sdtm_var = c("USUBJID", "AGE"),
stringsAsFactors = FALSE
)
tm2 <- build_trace_model(adam_partial, sdtm_meta, mapping = mapping_partial)
tm2$diagnostics$orphans
#> # A tibble: 1 × 2
#> adam_dataset adam_var
#> <chr> <chr>
#> 1 ADSL DERIVED_VAROrphaned variables (like DERIVED_VAR above) should have
derivation text documented to satisfy U-002 and T-002 requirements.
Once a trace model is built,
export_traceability_report() produces the coverage table an
ADRG appendix needs: how much of each ADaM dataset traces back to SDTM,
which variables do not, and an overall summary row.
report <- export_traceability_report(tm)
report
#> # A tibble: 2 × 6
#> adam_dataset n_variables n_traced n_untraced pct_traced untraced_vars
#> <chr> <int> <int> <int> <dbl> <chr>
#> 1 ADSL 5 5 0 100 ""
#> 2 OVERALL 5 5 0 100 ""A variable counts as traced when its trace level reaches
traced_min_level (the default of 2 treats an existing SDTM
mapping as traced). Raise it to 3 to require a high-confidence mapping.
Pass format = "csv" or format = "xlsx" with a
path to write the table straight into a submission
package:
A direct ADaM-to-SDTM mapping is only the simplest case. Many
variables are derived from other ADaM variables that are themselves
derived from SDTM. A single hop misses those links.
trace_chains() follows the full path back to source, using
an adam_derivations table for the ADaM-to-ADaM steps.
adam <- data.frame(
dataset = c("ADSL", "ADAE", "ADTTE"),
variable = c("AGE", "AAGE", "TAGE"),
label = c("Age", "Analysis Age", "Time-to-event Age")
)
sdtm <- data.frame(dataset = "DM", variable = "AGE", label = "Age")
map <- data.frame(
adam_dataset = "ADSL", adam_var = "AGE",
sdtm_domain = "DM", sdtm_var = "AGE", confidence = 1
)
tmc <- build_trace_model(adam, sdtm, mapping = map)
deriv <- data.frame(
from_dataset = c("ADAE", "ADTTE"),
from_var = c("AAGE", "TAGE"),
to_dataset = c("ADSL", "ADAE"),
to_var = c("AGE", "AAGE")
)
trace_chains(tmc, adam_derivations = deriv)
#> # A tibble: 3 × 9
#> adam_dataset adam_var max_depth grounded terminal_source has_cycle truncated
#> <chr> <chr> <int> <lgl> <chr> <lgl> <lgl>
#> 1 ADSL AGE 1 TRUE DM.AGE FALSE FALSE
#> 2 ADAE AAGE 2 TRUE DM.AGE FALSE FALSE
#> 3 ADTTE TAGE 3 TRUE DM.AGE FALSE FALSE
#> # ℹ 2 more variables: n_paths <int>, longest_chain <chr>ADTTE.TAGE is three hops from source, but it is fully
grounded: the chain reaches DM.AGE. The
trace_depth argument caps how far the walk follows a chain,
and circular derivations are detected and reported rather than looping
forever.
A coverage table lists every variable. On a real submission with
thousands of variables, a team wants only the ones that need attention.
trace_problems() returns just those: variables not traced
to the required level, and, when a derivation table is given, chains
that are circular, cut off, or never reach SDTM.
trace_problems(tm2)
#> # A tibble: 1 × 4
#> adam_dataset adam_var problem detail
#> <chr> <chr> <chr> <chr>
#> 1 ADSL DERIVED_VAR untraced trace level 1 is below 2Passing the derivation table adds chain problems to the same view, so cycles and ungrounded derivations show up alongside untraced variables:
trace_problems(tmc, adam_derivations = deriv)
#> # A tibble: 2 × 4
#> adam_dataset adam_var problem detail
#> <chr> <chr> <chr> <chr>
#> 1 ADAE AAGE untraced trace level 1 is below 2
#> 2 ADTTE TAGE untraced trace level 1 is below 2The view is built from the same coverage and chain functions, so it stays correct as a submission grows; the engine is exercised on a 10,000-variable model in the package tests.