| Title: | Clinical Trial Data-Cut Change Detection and Impact Assessment |
|---|---|
| Description: | A transparent, rule-based framework for detecting changes between successive data cuts of clinical trial datasets, classifying those changes into clinically meaningful categories, tracing user-defined data lineage, and assessing which downstream analyses and outputs may be affected. The package is designed to complement existing low-level data frame comparison tools by adding clinical-trial-specific classification, lineage and impact-assessment layers on top of deterministic comparison. |
| Authors: | Hirujan Rangaraj [aut, cre] |
| Maintainer: | Hirujan Rangaraj <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.2.0 |
| Built: | 2026-09-16 01:03:30 UTC |
| Source: | https://github.com/Hirujan-R/trialdiff |
Merges edges (from lineage_edge(), td_output(), a registry or any edge
data frame) into an existing td_lineage object and rebuilds the node table.
Existing provenance and review information is preserved.
add_edges(x, ..., source = NULL)add_edges(x, ..., source = NULL)
x |
A |
... |
Edge tables to add. |
source |
Optional value used to tag the source of every added edge. |
A td_lineage object.
Add an analysis/output registry to a lineage graph
add_outputs(x, outputs, ...)add_outputs(x, outputs, ...)
x |
A |
outputs |
A registry from |
... |
Additional registries. |
A td_lineage object.
lineage <- define_lineage( lineage_edge("ADLB.AVAL", "ADLB.CHG", relationship = "derives") ) registry <- output_registry( td_output("MMRM", depends_on = c("ADLB.AVAL", "ADLB.CHG")), td_output("Table_14_2_1", depends_on = "MMRM", type = "output") ) trace_dependencies(add_outputs(lineage, registry), from = "ADLB.AVAL")lineage <- define_lineage( lineage_edge("ADLB.AVAL", "ADLB.CHG", relationship = "derives") ) registry <- output_registry( td_output("MMRM", depends_on = c("ADLB.AVAL", "ADLB.CHG")), td_output("Table_14_2_1", depends_on = "MMRM", type = "output") ) trace_dependencies(add_outputs(lineage, registry), from = "ADLB.AVAL")
Two synthetic laboratory analysis datasets (ADLB) with a long structure
keyed by subject, parameter and visit. The later cut adds records for new
subjects and includes corrected, missing-to-value and value-to-missing
laboratory values.
adlb_cut1 adlb_cut2adlb_cut1 adlb_cut2
A data frame with one row per subject/parameter/visit and variables:
Unique subject identifier.
Parameter code.
Parameter name.
Analysis visit.
Planned treatment for period 01.
Analysis value.
Baseline value.
Change from baseline.
Baseline flag.
An object of class data.frame with 384 rows and 9 columns.
A td_lineage object linking treatment assignment, population flags and
laboratory analysis values to downstream summaries, an MMRM analysis and
outputs. Intended for use in examples and vignettes.
adsl_adlb_lineageadsl_adlb_lineage
A td_lineage object (see define_lineage()).
Two synthetic subject-level analysis datasets (ADSL) representing two data
cuts of the same study. No proprietary or real patient data is used. The
later cut contains new subjects, a withdrawn subject, a treatment-assignment
correction, age corrections, missingness changes, a new variable (REGION)
and a label change for AGE.
adsl_cut1 adsl_cut2adsl_cut1 adsl_cut2
A data frame with one row per subject and variables:
Study identifier.
Unique subject identifier.
Subject identifier within study.
Investigator site identifier.
Age.
Sex.
Race.
Planned treatment for period 01.
Actual treatment for period 01.
Safety population flag.
Intention-to-treat population flag.
Geographic region (later cut only).
An object of class data.frame with 31 rows and 12 columns.
Requires the igraph package.
as_igraph(x, ...)as_igraph(x, ...)
x |
A |
... |
Unused. |
An igraph object.
Machine-readable report data
as_json(x, pretty = TRUE, ...)as_json(x, pretty = TRUE, ...)
x |
A |
pretty |
Pretty-print JSON. |
... |
Unused. |
For as_json(), a JSON string. For as_report_list(), a list.
Flattens a tdiff object into a single long table with one row per change
(added record, removed record, modified cell or schema change). This is the
machine-readable form intended for QC pipelines.
as_register(x, ...)as_register(x, ...)
x |
A |
... |
Unused. |
A tibble.
Combines a classified change set with a lineage graph to identify which
downstream variables, analyses and outputs could be affected. Impact is
graded as "definitely_affected", "potentially_affected" or "unlikely",
with an explicit rationale and the triggering change identifiers.
assess_impact(changes, lineage, policy = impact_policy(), ...)assess_impact(changes, lineage, policy = impact_policy(), ...)
changes |
A classified |
lineage |
A |
policy |
An |
... |
Reserved for future extensions. |
trialdiff never claims statistical impact. Every affected analysis or
output is reported as requiring programmer/statistician review and, where
appropriate, a rerun.
An object of class td_impact with elements impacts, by_subject,
unlinked, summary, policy and meta.
old <- data.frame(USUBJID = c("S1", "S2"), TRT01P = c("Placebo", "Drug A")) new <- data.frame(USUBJID = c("S1", "S2"), TRT01P = c("Drug A", "Drug A")) diff <- compare_cut(old, new, by = "USUBJID", dataset = "ADSL") |> classify_changes() lineage <- define_lineage( lineage_edge("ADSL.TRT01P", "ADLB.TRT01P", relationship = "groups_by"), lineage_edge("ADLB.TRT01P", "MMRM", relationship = "models"), lineage_edge("MMRM", "Table_14_2_1", relationship = "reports") ) assess_impact(diff, lineage)old <- data.frame(USUBJID = c("S1", "S2"), TRT01P = c("Placebo", "Drug A")) new <- data.frame(USUBJID = c("S1", "S2"), TRT01P = c("Drug A", "Drug A")) diff <- compare_cut(old, new, by = "USUBJID", dataset = "ADSL") |> classify_changes() lineage <- define_lineage( lineage_edge("ADSL.TRT01P", "ADLB.TRT01P", relationship = "groups_by"), lineage_edge("ADLB.TRT01P", "MMRM", relationship = "models"), lineage_edge("MMRM", "Table_14_2_1", relationship = "reports") ) assess_impact(diff, lineage)
classify_changes() turns the raw output of compare_cut() into a
clinically meaningful change register. Every change is assigned a
transparent, rule-based category (see td_default_rules()) together with a
human-readable explanation.
classify_changes(x, rules = td_default_rules(), context = list(), ...)classify_changes(x, rules = td_default_rules(), context = list(), ...)
x |
A |
rules |
A list of |
context |
Optional named list of extra context passed to rules, for
example |
... |
Unused. |
When x is a tdiff, the same object with classification columns
added to $added, $removed, $modified and $schema, a $register
element, and class c("tdiff_classified", "tdiff"). When x is a
register (data frame), a classified tibble.
old <- data.frame(USUBJID = c("S1", "S2"), TRT01P = c("Placebo", "Drug A")) new <- data.frame(USUBJID = c("S1", "S2"), TRT01P = c("Drug A", "Drug A")) compare_cut(old, new, by = "USUBJID", dataset = "ADSL") |> classify_changes()old <- data.frame(USUBJID = c("S1", "S2"), TRT01P = c("Placebo", "Drug A")) new <- data.frame(USUBJID = c("S1", "S2"), TRT01P = c("Drug A", "Drug A")) compare_cut(old, new, by = "USUBJID", dataset = "ADSL") |> classify_changes()
compare_cut() performs a deterministic, key-based comparison of two
versions of the same clinical dataset (for example ADSL at two data cuts).
It reports observations added, removed and modified, and separately reports
schema-level changes (variables added/removed, type and label changes).
compare_cut( old, new, by, compare_labels = TRUE, compare_types = TRUE, tolerance = 1e-09, ignore_vars = NULL, name_old = "old", name_new = "new", dataset = NULL, subject_var = NULL, backend = c("trialdiff", "waldo", "diffdf"), ... )compare_cut( old, new, by, compare_labels = TRUE, compare_types = TRUE, tolerance = 1e-09, ignore_vars = NULL, name_old = "old", name_new = "new", dataset = NULL, subject_var = NULL, backend = c("trialdiff", "waldo", "diffdf"), ... )
old, new
|
Data frames (or tibbles) representing the earlier and later
data cut. |
by |
Character vector of key variables that uniquely identify an
observation within each dataset. Common clinical keys include
|
compare_labels |
Logical. If |
compare_types |
Logical. If |
tolerance |
Numeric tolerance used when comparing numeric values. Set to
|
ignore_vars |
Character vector of variables to exclude from the value comparison (for example technical or audit columns). |
name_old, name_new
|
Character labels used in reports. |
dataset |
Optional dataset name (for example |
subject_var |
Optional subject identifier variable. When |
backend |
Low-level comparison backend. |
... |
Reserved for future extensions. |
The function is deliberately a low-level comparison engine. Clinical
meaning is added by classify_changes(), lineage by define_lineage() and
impact by assess_impact().
An object of class tdiff: a list with elements meta, added,
removed, modified, schema, summary and register. See
tdiff-object for details.
old <- data.frame( USUBJID = c("S1", "S2", "S3"), TRT01P = c("Placebo", "Drug A", "Placebo"), AGE = c(54, 61, 47), stringsAsFactors = FALSE ) new <- old new$TRT01P[1] <- "Drug A" new$AGE[2] <- 62 diff <- compare_cut(old, new, by = "USUBJID", dataset = "ADSL") diffold <- data.frame( USUBJID = c("S1", "S2", "S3"), TRT01P = c("Placebo", "Drug A", "Placebo"), AGE = c(54, 61, 47), stringsAsFactors = FALSE ) new <- old new$TRT01P[1] <- "Drug A" new$AGE[2] <- 62 diff <- compare_cut(old, new, by = "USUBJID", dataset = "ADSL") diff
define_lineage() collects lineage edges into an auditable graph that can be
queried with trace_dependencies() and used by assess_impact(). The graph
is stored as a tidy edge list plus a node table; no external graph library is
required, although as_igraph() is provided for users who prefer one.
define_lineage( ..., edges = NULL, nodes = NULL, dataset = NULL, variable = NULL, downstream = NULL, relationship = "derives" )define_lineage( ..., edges = NULL, nodes = NULL, dataset = NULL, variable = NULL, downstream = NULL, relationship = "derives" )
... |
One or more edge tibbles created by |
edges |
Optional data frame of edges with columns |
nodes |
Optional node table with columns |
dataset, variable, downstream
|
Convenience arguments matching the common
workflow: declare that |
relationship |
Relationship used with the convenience arguments. |
An object of class td_lineage.
lineage <- define_lineage( lineage_edge("ADSL.TRT01P", "ADLB.TRT01P", relationship = "derives"), lineage_edge("ADLB.AVAL", "MMRM", relationship = "models"), lineage_edge("MMRM", "Table_14_2_1", relationship = "reports") ) trace_dependencies(lineage, from = "ADSL.TRT01P")lineage <- define_lineage( lineage_edge("ADSL.TRT01P", "ADLB.TRT01P", relationship = "derives"), lineage_edge("ADLB.AVAL", "MMRM", relationship = "models"), lineage_edge("MMRM", "Table_14_2_1", relationship = "reports") ) trace_dependencies(lineage, from = "ADSL.TRT01P")
Controls how aggressively changes are mapped onto downstream analyses. The policy is explicit and auditable: no statistical significance is ever claimed. Analyses and outputs are flagged as requiring review/rerun, never as "statistically affected".
impact_policy( value_direct = "definitely_affected", value_indirect = "potentially_affected", record = "potentially_affected", schema = "potentially_affected", label = "unlikely", max_depth = Inf )impact_policy( value_direct = "definitely_affected", value_indirect = "potentially_affected", record = "potentially_affected", schema = "potentially_affected", label = "unlikely", max_depth = Inf )
value_direct |
Level for a variable derived directly from a changed variable. |
value_indirect |
Level for more distant value changes. |
record |
Level for added/removed observations. |
schema |
Level for schema changes other than label changes. |
label |
Level for label-only changes. |
max_depth |
Maximum lineage depth to traverse. |
An object of class td_policy.
A lineage edge states that to depends on from. Node identifiers use a
simple convention:
lineage_edge( from, to, from_type = NULL, to_type = NULL, relationship = "derives", condition = NA_character_, label = NA_character_ )lineage_edge( from, to, from_type = NULL, to_type = NULL, relationship = "derives", condition = NA_character_, label = NA_character_ )
from, to
|
Character node identifiers. |
from_type, to_type
|
Optional node types ( |
relationship |
Edge semantics. One of |
condition |
Optional condition restricting when the edge applies (for
example |
label |
Optional human-readable label. |
a dataset is written as "ADSL";
a dataset variable is written as "ADSL.TRT01P";
an analysis is written as "MMRM";
an output (TLF) is written as "Table_14_2_1".
A tibble with one row and class td_edge.
lineage_from_metadata() builds a define_lineage() graph from a
metacore object (or any list with the same tables). Dataset and
variable nodes come from ds_vars/ds_spec, and dependency edges are parsed
from the derivations and where columns of value_spec.
lineage_from_metadata( metadata, include_where = TRUE, include_sdtm = TRUE, aliases = NULL, overrides = NULL, ... )lineage_from_metadata( metadata, include_where = TRUE, include_sdtm = TRUE, aliases = NULL, overrides = NULL, ... )
metadata |
A |
include_where |
Logical. If |
include_sdtm |
Logical. If |
aliases |
Optional data frame with columns |
overrides |
Optional edge table (for example from |
... |
Reserved for future extensions. |
Parsing is deterministic and conservative: only tokens that resolve to known
variables (or explicit DATASET.VARIABLE references) become edges. Every
edge records its provenance, and references that cannot be resolved uniquely
are returned in a review table rather than guessed. See lineage_review().
A td_lineage object with additional elements review (references
needing manual attention) and provenance.
metadata <- list( ds_spec = data.frame(dataset = c("ADSL", "ADLB"), stringsAsFactors = FALSE), ds_vars = data.frame( dataset = c("ADSL", "ADSL", "ADLB", "ADLB", "ADLB"), variable = c("USUBJID", "TRT01P", "USUBJID", "AVAL", "CHG"), stringsAsFactors = FALSE ), value_spec = data.frame( dataset = c("ADSL", "ADLB", "ADLB"), variable = c("TRT01P", "AVAL", "CHG"), derivation_id = c("MT.ADSL.TRT01P", "MT.ADLB.AVAL", "MT.ADLB.CHG"), where = c(NA, "PARAMCD == 'ALT'", NA), stringsAsFactors = FALSE ), derivations = data.frame( derivation_id = c("MT.ADSL.TRT01P", "MT.ADLB.AVAL", "MT.ADLB.CHG"), derivation = c("DM.ARM", "ADSL.TRT01P", "AVAL - BASE"), stringsAsFactors = FALSE ) ) lineage_from_metadata(metadata)metadata <- list( ds_spec = data.frame(dataset = c("ADSL", "ADLB"), stringsAsFactors = FALSE), ds_vars = data.frame( dataset = c("ADSL", "ADSL", "ADLB", "ADLB", "ADLB"), variable = c("USUBJID", "TRT01P", "USUBJID", "AVAL", "CHG"), stringsAsFactors = FALSE ), value_spec = data.frame( dataset = c("ADSL", "ADLB", "ADLB"), variable = c("TRT01P", "AVAL", "CHG"), derivation_id = c("MT.ADSL.TRT01P", "MT.ADLB.AVAL", "MT.ADLB.CHG"), where = c(NA, "PARAMCD == 'ALT'", NA), stringsAsFactors = FALSE ), derivations = data.frame( derivation_id = c("MT.ADSL.TRT01P", "MT.ADLB.AVAL", "MT.ADLB.CHG"), derivation = c("DM.ARM", "ADSL.TRT01P", "AVAL - BASE"), stringsAsFactors = FALSE ) ) lineage_from_metadata(metadata)
Provenance of metadata-driven lineage edges
lineage_provenance(x)lineage_provenance(x)
x |
A |
A tibble with the metadata source of each edge.
References needing review after metadata-driven lineage
lineage_review(x)lineage_review(x)
x |
A |
A tibble of unresolved or undetected references.
Collects one or more td_output() declarations into a single registry table
that can be grafted onto a lineage graph with add_outputs().
output_registry(...)output_registry(...)
... |
One or more |
A tibble of registry edges.
registry <- output_registry( td_output("MMRM", depends_on = c("ADLB.AVAL", "ADLB.CHG")), td_output("Table_14_2_1", depends_on = "MMRM", type = "output") ) registryregistry <- output_registry( td_output("MMRM", depends_on = c("ADLB.AVAL", "ADLB.CHG")), td_output("Table_14_2_1", depends_on = "MMRM", type = "output") ) registry
Removes edges matching any supplied filter. Useful for overriding or replacing automatically generated lineage.
remove_edges(x, from = NULL, to = NULL, relationship = NULL, source = NULL)remove_edges(x, from = NULL, to = NULL, relationship = NULL, source = NULL)
x |
A |
from, to, relationship, source
|
Optional character vectors of values to
match. |
A td_lineage object.
report_diff() renders a structured review report from a comparison, its
classification and (optionally) an impact assessment. Human-readable HTML or
Quarto output is supported alongside machine-readable JSON and list output
for automated QC pipelines.
report_diff( diff, impact = NULL, classified = NULL, output = "html", format = c("html", "quarto", "json", "list"), title = NULL, max_rows = 25L, include_disclaimer = TRUE, ... )report_diff( diff, impact = NULL, classified = NULL, output = "html", format = c("html", "quarto", "json", "list"), title = NULL, max_rows = 25L, include_disclaimer = TRUE, ... )
diff |
A |
impact |
Optional |
classified |
Optional classified |
output |
Either a format ( |
format |
Report format. Ignored when |
title |
Report title. |
max_rows |
Maximum number of rows shown per table in HTML output. |
include_disclaimer |
Include the regulatory/statistical disclaimer. |
... |
Reserved for future extensions. |
An object of class td_report. When a file path is supplied the
report is written and the path is stored in $path.
Category label lookup
td_categories()td_categories()
A tibble with category and label columns.
Default rule set
td_default_rules()td_default_rules()
A list of td_rule() objects ordered by priority.
td_output() is the building block of an analysis/output registry. It states
that an analysis or output depends on one or more upstream nodes (typically
dataset.variable identifiers, but any node is allowed).
td_output( id, depends_on, type = c("analysis", "output"), relationship = NULL, condition = NA_character_, label = id )td_output( id, depends_on, type = c("analysis", "output"), relationship = NULL, condition = NA_character_, label = id )
id |
Node identifier for the analysis or output (for example |
depends_on |
Character vector of upstream node identifiers. |
type |
Either |
relationship |
Edge relationship. Defaults to |
condition |
Optional applicability condition (recorded, not evaluated). |
label |
Human-readable label. |
A tibble of registry edges.
Classification in trialdiff is transparent and rule-based. A rule is a
named predicate evaluated against the long change register produced by
as_register(). Rules are applied in priority order; the first matching rule
determines the primary category, while every matched rule is retained in the
category_all column so that nothing is hidden.
td_rule(name, label, test, priority = 100L, reason = NULL, description = NULL)td_rule(name, label, test, priority = 100L, reason = NULL, description = NULL)
name |
Stable machine-readable category name (snake_case). |
label |
Human-readable category label. |
test |
A function |
priority |
Integer priority; lower values are evaluated first. |
reason |
Optional function |
description |
Optional longer description. |
td_rule() returns an object of class td_rule.
Performs a breadth-first traversal of the lineage graph starting from from
and returns every reachable node, its depth and the path taken.
trace_dependencies( x, from, direction = c("downstream", "upstream"), max_depth = Inf, include_self = FALSE, ... )trace_dependencies( x, from, direction = c("downstream", "upstream"), max_depth = Inf, include_self = FALSE, ... )
x |
A |
from |
Character vector of starting node identifiers. |
direction |
|
max_depth |
Maximum traversal depth. |
include_self |
Include the starting node(s) in the result. |
... |
Unused. |
A tibble with node, node_type, depth, path and
edge_relationship.