diff --git a/.Rbuildignore b/.Rbuildignore index 0d39674..6305600 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -1,6 +1,8 @@ ^renv$ ^renv\.lock$ ^.*\.Rproj$ +^.*\.Rcheck$ +^.*\.tar\.gz$ ^\.Rproj\.user$ ^\.DS_Store$ ^\.git$ @@ -23,7 +25,13 @@ ^PR_DESCRIPTION_ADDENDUM\.md$ ^REVIEWER_QUICKSTART\.md$ ^LOCAL_TESTING_GUIDE\.md$ +^cran-comments\.md$ ^docs$ ^_pkgdown\.yml$ +^pkgdown$ ^development$ ^\.claude$ +^vignettes/\.quarto$ +^vignettes/.*_files$ +^vignettes/.*\.html$ +^\.superpowers$ diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml new file mode 100644 index 0000000..0858a8d --- /dev/null +++ b/.github/workflows/R-CMD-check.yaml @@ -0,0 +1,53 @@ +# R CMD check on every push to main/dev and every PR into them. +# Complements pkgdown.yaml, which only builds the site. Dependencies come +# from DESCRIPTION via setup-r-dependencies (Suggests included, so the +# optional simstudy backend is exercised here even when it is absent from +# a developer's local library). See .claude/AI.md for the CI lessons that +# shaped this file. +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + workflow_dispatch: + +name: R-CMD-check + +permissions: + contents: read + +jobs: + R-CMD-check: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 + + - name: Install Quarto + uses: quarto-dev/quarto-actions/setup@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck, any::roxygen2 + needs: check + + - name: Generate documentation + run: Rscript -e "roxygen2::roxygenize()" + + - uses: r-lib/actions/check-r-package@v2 + with: + upload-snapshots: true + error-on: '"warning"' + build_args: 'c("--no-manual")' + args: 'c("--no-manual", "--as-cran")' diff --git a/.gitignore b/.gitignore index 34bc269..350df57 100644 --- a/.gitignore +++ b/.gitignore @@ -16,10 +16,15 @@ CLAUDE.md # macOS .DS_Store -# Generated documentation -man/*.Rd +# Generated documentation (man/*.Rd is committed so GitHub installs have help pages) docs/ # Quarto/HTML output Generate_mock_data.html Generate_mock_data_files/ +vignettes/.quarto/ +vignettes/*_files/ +vignettes/*.html + +# Superpowers plugin scratch (per-developer) +.superpowers/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32805d0..bb5d863 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,6 +131,30 @@ covr::package_coverage() - If adding new test data, document its purpose - Keep test data small and focused +#### Vignettes are integration tests + +The vignettes run MockData's own code on realistic metadata, and `R CMD check` rebuilds every one of them in CI. That makes them integration tests, but only if a vignette fails when the code stops doing what its prose says. A vignette that prints a number without checking it renders happily after a regression, and a number typed into the prose goes stale without anyone noticing. + +When you write or change a vignette: + +- Compute every factual claim with inline R (`` `r n_deaths` ``) instead of typing the number into the prose. +- Guard the claims with a hidden chunk of named `stopifnot()` conditions, so the render, and therefore `R CMD check` and CI, fails with a message naming the broken claim: + + ````markdown + ```{r} + #| include: false + stopifnot( + "deaths are exactly floor(n * event_prop)" = n_deaths == floor(n * 0.3), + "no event is recorded after a death" = events_after_death == 0 + ) + ``` + ```` + +- State known issues as claims too, with the issue number in the message. When the issue is fixed, the render fails until the documentation is updated. +- For a new feature, check that the assertions can fail: break the behaviour in a scratch copy and check that the render stops. + +The survival vignettes (`tutorial-survival-data`, `survival-dates-v05`, `survival-design-v05`) follow this pattern. + ### Building Documentation #### Prerequisites for documentation builds diff --git a/DESCRIPTION b/DESCRIPTION index 19753c4..4177b06 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: MockData Title: Generate Mock Data from Metadata Specifications -Version: 0.4.0 +Version: 0.5.0 Authors@R: c( person("Juan", "Li", role = "aut", email = "juli@ohri.ca"), person("Douglas", "Manuel", role = c("aut", "cre"), email = "dmanuel@ohri.ca"), @@ -25,6 +25,7 @@ VignetteBuilder: quarto Depends: R (>= 4.2.0) Imports: + parallel, stats Suggests: testthat (>= 3.0.0), diff --git a/NAMESPACE b/NAMESPACE index 987afd8..756719d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -12,11 +12,13 @@ export(create_date_var) export(create_mock_data) export(create_survival_dates) export(create_wide_survival_data) +export(evaluate_mock_formulas) export(extract_distribution_params) export(extract_proportions) export(generate_garbage_values) export(generate_mock_data_native) export(generate_mock_data_simstudy) +export(generate_survival_dates) export(get_cycle_variables) export(get_enabled_variables) export(get_raw_var_dependencies) @@ -31,11 +33,14 @@ export(make_garbage) export(mock_categorical) export(mock_continuous) export(mock_date) +export(mock_formula) export(mock_spec) export(mock_spec_categorical) export(mock_spec_continuous) export(mock_spec_date) +export(mock_spec_formula) export(mock_spec_from_recodeflow) +export(mock_spec_survival) export(parse_range_notation) export(parse_variable_start) export(postprocess_mock_data) diff --git a/NEWS.md b/NEWS.md index 5fbebda..6775872 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,143 @@ +# MockData 0.5.0 (2026-09-27) + +## Breaking changes + +- Seeded output changed once for the RNG-stream mechanism in this release. + Within the `create_mock_data()` pipeline (`generate_mock_data_native()`, + `generate_mock_data_simstudy()`, `postprocess_mock_data()`), MockData now + derives all randomness from independent L'Ecuyer-CMRG sub-streams (one per + generation stage) seeded from the single public `seed`, replacing the + previous Mersenne-Twister `seed` / `seed + 1` scheme. The standalone + `create_*` helpers (`create_cat_var()`, `create_con_var()`, + `create_date_var()`, `create_survival_dates()`, + `create_wide_survival_data()`, `sample_with_proportions()`, + `make_garbage()`, `apply_garbage()`) called directly are unaffected and + still seed a single Mersenne-Twister stream per call. For a given seed + **and package version** pipeline output is reproducible and independent of + the session's ambient `RNGkind()`; it is not comparable across the v0.4 → + v0.5 boundary. For seeded calls, generation no longer alters the caller's + RNG state (previously the legacy `validate = FALSE` path reset it). (#38) + + **Migrating:** if your tests pin values from seeded v0.4 runs of + `create_mock_data()` or the `mock_spec` pipeline, regenerate those expected + values once under v0.5 and pin them again. The same seed keeps giving + identical output within this version. Standalone `create_*` calls need no + change. + +- Migration: add `anchor` (and `censored_by` where a competing risk applies) + to each survival date's row. A date with `followup_min`, `followup_max` or + `event_prop` but no `anchor` now fails with a message naming the fix; + previously it was silently dropped or generated as a plain calendar date. +- Missing codes and garbage are applied to survival dates after the survival + rules. Garbage that places a date before entry is therefore kept, whereas + the legacy engine set such dates to `NA`. +- The packaged minimal example now generates entirely through the v0.4 + pipeline, because its Gompertz survival dates no longer force the legacy + generator. Its seeded output differs from v0.4. +- If the legacy generator is selected (`validate = FALSE`, + `variable_details = NULL`, detail-level-only `databaseStart`, or another + unsupported variable) on metadata that sets `anchor`, `create_mock_data()` + stops and names the survival variables rather than dropping them. +- The same applies to formula variables. If the legacy generator is + selected for metadata that sets `mockFormula` on an enabled variable, + `create_mock_data()` stops and names the formula variables. Previously the + legacy generator ignored the formula and returned unrelated random values, + or dropped a `DerivedVar::` variable, without saying so. +- On the legacy path (`validate = FALSE` and the other legacy triggers), + `create_mock_data()` now generates only variables whose variable-level + `databaseStart` includes the requested database, as the v0.4 pipeline + does. Previously it chose variables from the detail rows alone, so a + variable listed only for another database was generated as random values, + and a formula or survival variable belonging to another database stopped + the run. + +## New features + +- Formula-derived variables (#39, Phase A). Variables can now be generated + from algebraic expressions over other generated variables via a new + `mockFormula` column in `variable_details` (e.g. `weight / (height^2)`), + or the new `mock_formula()` direct API. Formulas are evaluated in + dependency order by `evaluate_mock_formulas()`, in a restricted environment + exposing only the generated columns and a fixed allow-list of base + functions. `DerivedVar::`/`Func::` semantics are unchanged: derived + variables without a `mockFormula` remain excluded from generation, and + `Func::` dispatch is not yet supported. +- `create_mock_data()` now generates survival dates from metadata. A date + whose `variables.csv` row sets `anchor` (its entry-date variable) is a + survival date: `floor(n * event_prop)` rows receive a date between + `followup_min` and `followup_max` days after the anchor, drawn from a + uniform, exponential or Gompertz distribution, and the rest are `NA`. An + optional `censored_by` column names a competing survival date, such as + death, that sets this date to `NA` where it comes first. The statistics and + rules are those of `create_wide_survival_data()`, ported exactly. New + functions: `mock_spec_survival()` and `generate_survival_dates()`. This + resolves the known issue, listed since v0.2.0, that survival data had to be + generated separately. +- Derived columns, from `mockFormula` or survival dates, describe the clean + generated values; missing codes and garbage are then applied to each column + independently. `is.na` is added to the `mockFormula` allow-list so status + and follow-up time can be derived. Derive both from the same observation + window: `as.integer(!is.na(death_date))` only says a death date exists, + not that the death was observed before censoring. +- `create_mock_data()` now accepts `n = 0`, returning a zero-row data frame + with the full generated schema (useful for schema tests), and rejects + fractional, negative, `NA`, and non-finite values of `n` with a clear + message. The validation now matches `generate_mock_data_native()`. +- The native backend now supports `distribution = "exponential"` (parity with + the legacy generator), removing a forced legacy-fallback for exponential + metadata. When the optional simstudy backend is selected, exponential + variables are routed to the native generator (like all non-uniform + continuous distributions); the simstudy package itself is not involved in + their generation. Native exponential values are truncated to the declared + `range` by inverse-CDF sampling, rather than clipped at the range maximum + (with a point mass at the boundary) as the legacy `rexp()` path does. + +## Validation and error messages + +- Calling `create_mock_data()` without `databaseStart` now fails upfront with + a message naming the argument, instead of a raw missing-argument error. +- With `validate = TRUE` (the default), invalid distribution parameters in + metadata — e.g. `distribution = "exponential"` without a positive `rate` — + now stop generation with a message naming the variable and how to fix it, + instead of warning and substituting a uniform draw. The legacy + warn-and-substitute behaviour remains available via `validate = FALSE`. + +- A `censored_by` target may not itself have `censored_by`. Chained censoring + would report some events as observed after observation ended, so it is + rejected in this version with a message naming the fix. + +## Bug fixes + +- The simstudy backend now returns a typed zero-row data frame for `n = 0`, + identical to the native backend's output, instead of failing inside + `simstudy::genData()` (whose `1:n` id table has two rows when `n = 0`). + (#50) +- The v0.4 pipeline now expands range-notation missing codes such as + `[997,999]` (the usual CCHS and CHMS pattern for don't know, refusal and + not stated) into their individual codes, splitting the row's proportion + equally. Previously a continuous variable with such a code failed in + post-processing, and a categorical variable silently wrote the literal + string `"[997,999]"` into the data. A bracketed missing code that is not + an integer range now fails with a message naming the variable. (#58) +- The packaged minimal example corrects two metadata errors: BMI's low + garbage range had a stray parenthesis (`[-10;15])`), and height's + low-garbage proportion was 1.00 (every row) instead of 0.01; height's + high-garbage range had an infinite upper bound, `(2.1;inf]`, which + produced `NaN` values, and is now `(2.1;2.5]` (#60). With these, #58 and + the survival dates (#40), every variable in the example generates through + the v0.4 pipeline. Seeded output for the example changes. + +## Deprecations + +- `create_wide_survival_data()` is deprecated and warns once per session. + +## Known issues + +- Known issues found while porting, reproduced unchanged: #54 (Gompertz + follow-up times are degenerate with the packaged parameters), #55 + (administrative censoring is drawn per person), #56 (smaller legacy + discrepancies). + # MockData 0.4.0 (2026-06-10) ## Breaking changes @@ -299,8 +439,8 @@ vars_with_garbage <- variables %>% ## Known issues -- Survival variable type must be generated manually with `create_wide_survival_data()` -- Cannot be used in `create_mock_data()` batch generation (requires paired variables) +- Survival variable type must be generated manually with `create_wide_survival_data()` (resolved in 0.5.0, #40) +- Cannot be used in `create_mock_data()` batch generation (requires paired variables) (resolved in 0.5.0, #40) --- diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 7533f91..eeb1cfd 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -10,6 +10,20 @@ #' @noRd .create_mock_data_v04_unsupported_variables <- function(spec) { unsupported <- vapply(spec$variables, function(variable) { + # type = "formula" is supported end-to-end (#39, Phase A): baseline + # generation skips it, evaluate_mock_formulas() computes it, and + # postprocess_mock_data() applies its missing codes/garbage like any + # other variable. A stray non-empty `formula` field on a NON-formula + # variable is defensive fallback bait, not a supported feature. + if (variable$type == "formula") { + return(FALSE) + } + # type = "survival" is supported end-to-end (#40): both backends skip it, + # generate_survival_dates() computes it, postprocess treats it as a date. + if (variable$type == "survival") { + return(FALSE) + } + formula <- variable$formula has_formula <- !is.null(formula) && !(is.character(formula) && length(formula) == 1 && (is.na(formula) || trimws(formula) == "")) @@ -19,7 +33,7 @@ distribution <- tolower(variable$distribution %||% "uniform") if (variable$type == "continuous") { - return(!distribution %in% c("uniform", "normal")) + return(!distribution %in% c("uniform", "normal", "exponential")) } if (variable$type == "categorical") { return(FALSE) @@ -34,6 +48,47 @@ names(spec$variables)[unsupported] } +#' @noRd +.legacy_route_reasons <- function() { + paste0( + "It is used when validate = FALSE, when variable_details is NULL, when ", + "only variable_details has a databaseStart column, or when another ", + "variable uses a feature the v0.4 pipeline does not support. Run with ", + "verbose = TRUE to see which." + ) +} + +#' @noRd +.legacy_formula_variables <- function(enabled_vars, variable_details, databaseStart) { + # Enabled variables with a non-blank mockFormula for the requested + # database, using the same lookup as the v0.4 adapter's carve-out. + if (is.null(variable_details) || !"mockFormula" %in% names(variable_details)) { + return(character(0)) + } + database <- if ("databaseStart" %in% names(variable_details)) databaseStart else NULL + Filter(function(variable) { + details <- .filter_recodeflow_details(variable_details, variable, database) + !.is_blank(suppressWarnings(.details_mock_formula(details))) + }, as.character(enabled_vars$variable)) +} + +#' @noRd +.has_survival_metadata <- function(variables) { + # A row sets anchor, or carries survival parameters (followup_min, + # followup_max, event_prop): the metadata describes survival dates. + if ("anchor" %in% names(variables)) { + anchors <- as.character(variables$anchor) + if (any(!is.na(anchors) & trimws(anchors) != "")) { + return(TRUE) + } + } + survival_fields <- intersect(c("followup_min", "followup_max", "event_prop"), names(variables)) + any(vapply(survival_fields, function(field) { + values <- as.character(variables[[field]]) + any(!is.na(values) & trimws(values) != "") + }, logical(1))) +} + #' @noRd .create_mock_data_v04_native_supported <- function(spec) { length(.create_mock_data_v04_unsupported_variables(spec)) == 0 @@ -59,11 +114,38 @@ return(NULL) } - spec <- mock_spec_from_recodeflow( - variables = variables, - variable_details = variable_details, - databaseStart = .create_mock_data_v04_database_filter(variables, databaseStart), - role = "enabled" + spec <- tryCatch( + mock_spec_from_recodeflow( + variables = variables, + variable_details = variable_details, + databaseStart = .create_mock_data_v04_database_filter(variables, databaseStart), + role = "enabled" + ), + error = function(e) { + # Survival dates are generated only by the v0.4 pipeline (ADR + # v05-survival-dates D10): validate = FALSE would stop on anchored + # metadata or silently drop survival dates, so it is not offered then. + advice <- if (.has_survival_metadata(variables)) { + paste0( + "Fix the metadata as the message above describes. Survival dates ", + "are generated only by the v0.4 pipeline, so validate = FALSE is ", + "not a workaround for survival dates." + ) + } else { + paste0( + "Fix the metadata (for exponential variables, supply a positive ", + "'rate'), or call create_mock_data() with validate = FALSE to use ", + "the legacy generator, which warns and substitutes a uniform draw ", + "for invalid distribution parameters." + ) + } + stop( + conditionMessage(e), "\n", + "Metadata validation failed while building the v0.4 specification. ", + advice, + call. = FALSE + ) + } ) unsupported <- .create_mock_data_v04_unsupported_variables(spec) @@ -83,11 +165,15 @@ } baseline <- generate_mock_data_native(spec, n = n, seed = seed) - # The wrapper uses a second deterministic stream for post-processing so - # baseline generation and missing/garbage assignment can be reproduced - # independently from the single public seed. - postprocess_seed <- if (is.null(seed)) NULL else seed + 1L - postprocess_mock_data(baseline, spec, seed = postprocess_seed) + dated <- generate_survival_dates(baseline, spec, seed = seed) + staged <- evaluate_mock_formulas(dated, spec, seed = seed) + # Baseline generation, survival dates, formula evaluation, and + # post-processing use distinct L'Ecuyer-CMRG sub-streams derived from the + # single public seed (see .with_mock_seed / ADR v05-seed-contract), so all + # four stages pass the same seed and select their own stage internally. + # Survival precedes formulas so a mockFormula can use survival dates + # (ADR v05-survival-dates D6). + postprocess_mock_data(staged, spec, seed = seed) } #' Create mock data from configuration files @@ -120,6 +206,8 @@ #' Can also be a file path (character) to variable_details.csv. #' If NULL, uses simple fallback generation. #' @param n Integer. Number of observations to generate (default 1000). +#' `n = 0` is supported and returns a zero-row data frame with the full +#' generated schema. #' @param seed Integer. Optional random seed for reproducibility. #' @param validate Logical. Whether to use strict generation checks (default #' TRUE). When TRUE, unsupported variable types and generator errors stop @@ -144,11 +232,14 @@ #' when `variable_details = NULL`, when detail-level `databaseStart` filtering is #' needed but the variables metadata has no `databaseStart` column, or when a #' variable uses a feature not yet supported by the v0.4 native backend. Set -#' `verbose = TRUE` to see which path was chosen. +#' `verbose = TRUE` to see which path was chosen. Survival dates (rows that +#' set `anchor`) are generated only by the v0.4 pipeline: if the legacy path +#' is selected for metadata that sets `anchor`, `create_mock_data()` stops and +#' names the survival variables. #' -#' In the v0.4 path, `seed` is used for baseline generation and `seed + 1` is -#' used for post-processing. This makes both stages deterministic, but generated -#' values may differ from v0.3.x output for the same seed. +#' In the v0.4 path, baseline generation and post-processing draw from distinct, +#' independent sub-streams derived from a single `seed`; output is reproducible +#' for a given seed and package version but changed in v0.5 (see NEWS). #' #' **v0.3.0 API**: This function follows the "recodeflow pattern" where it passes #' full metadata data frames to create_* functions, which handle internal @@ -158,7 +249,7 @@ #' \enumerate{ #' \item Load metadata from file paths or accept data frames #' \item Filter for enabled variables (role has an exact "enabled" token) -#' \item Set global seed (if provided) +#' \item Generate within an isolated RNG sub-stream (if seeded), leaving the caller's RNG state untouched #' \item Loop through variables in position order: #' - Dispatch to create_cat_var, create_con_var, or create_date_var #' - Pass full metadata data frames (functions filter internally) @@ -181,9 +272,9 @@ #' see \code{vignette("reference-config", package = "MockData")}. #' #' @examples -#' # The packaged minimal example includes deliberately messy metadata -#' # (auto-normalized proportions, survival dates without an anchor): the -#' # warnings it generates are expected and demonstrate MockData's diagnostics. +#' # The packaged minimal example covers every variable type, including +#' # survival dates anchored on interview_date. It auto-normalizes some +#' # proportions, so warnings about that are expected. #' mock_data <- create_mock_data( #' databaseStart = "minimal-example", #' variables = system.file("extdata/minimal-example/variables.csv", @@ -200,12 +291,16 @@ #' # Columns with straightforward metadata generate cleanly: #' head(mock_data[, c("age", "smoking", "interview_date")]) #' -#' # Fallback mode: no variable_details, simple default generators +#' # Fallback mode: no variable_details, simple default generators. Survival +#' # dates need the v0.4 pipeline, so drop the rows that set an anchor first. +#' fallback_variables <- read.csv( +#' system.file("extdata/minimal-example/variables.csv", package = "MockData"), +#' stringsAsFactors = FALSE, +#' check.names = FALSE +#' ) #' mock_data <- create_mock_data( #' databaseStart = "minimal-example", -#' variables = system.file("extdata/minimal-example/variables.csv", -#' package = "MockData" -#' ), +#' variables = fallback_variables[fallback_variables$anchor == "", ], #' variable_details = NULL, #' n = 500 #' ) @@ -223,6 +318,14 @@ create_mock_data <- function(databaseStart, validate = TRUE, verbose = FALSE) { + if (missing(databaseStart)) { + stop( + "databaseStart is required. Pass the database or cycle name that ", + "matches your metadata's databaseStart values (e.g. \"cycle1\").", + call. = FALSE + ) + } + # ========== LOAD METADATA ========== variables <- .load_metadata_df(variables, "variables", verbose = verbose) @@ -237,8 +340,9 @@ create_mock_data <- function(databaseStart, # ========== VALIDATE INPUT ========== - if (n < 1) { - stop("n must be at least 1") + if (!is.numeric(n) || length(n) != 1 || is.na(n) || !is.finite(n) || + n < 0 || n != trunc(n)) { + stop("n must be a non-negative whole number.", call. = FALSE) } if (!"variable" %in% names(variables)) { @@ -272,6 +376,11 @@ create_mock_data <- function(databaseStart, if (!is.null(v04_result)) { return(v04_result) } + + message( + "Falling back to the legacy generator for an unsupported v0.4 feature; ", + "for a given seed this produces different values than the v0.4 pipeline." + ) } # ========== FILTER FOR ENABLED VARIABLES ========== @@ -286,6 +395,29 @@ create_mock_data <- function(databaseStart, enabled_vars <- variables } + # Variable-level databaseStart lists the databases a variable belongs to. + # The v0.4 adapter honours it; the legacy path used only detail rows, so a + # variable listed only for another database was generated here as random + # values, and the formula and survival guards below stopped on it. Same + # matcher and empty-means-all rule as the adapter. + if ("databaseStart" %in% names(enabled_vars)) { + enabled_vars <- .filter_recodeflow_by_database(enabled_vars, databaseStart, allow_empty = TRUE) + } + + # Formula variables are computed only by the v0.4 pipeline; the legacy + # dispatcher ignores mockFormula and would return unrelated random values, + # or drop a DerivedVar:: variable, without saying so. Checked before the + # derived exclusion below, which would otherwise hide DerivedVar:: rows. + formula_vars <- .legacy_formula_variables(enabled_vars, variable_details, databaseStart) + if (length(formula_vars) > 0) { + stop( + "Formula variable(s) ", paste(formula_vars, collapse = ", "), + " (mockFormula set) are computed only by the v0.4 pipeline, but the ", + "legacy generator was selected. ", .legacy_route_reasons(), + call. = FALSE + ) + } + # Exclude derived variables (identified by DerivedVar:: and Func:: patterns) if (!is.null(variable_details)) { derived_vars <- identify_derived_vars(enabled_vars, variable_details) @@ -301,6 +433,24 @@ create_mock_data <- function(databaseStart, } } + # ADR v05-survival-dates D10: the legacy dispatcher cannot build survival + # dates from anchors (it would warn and drop them), so stop rather than + # return plausible-looking partial survival data. + if ("anchor" %in% names(enabled_vars)) { + anchor_values <- as.character(enabled_vars$anchor) + anchored <- enabled_vars$variable[ + !is.na(anchor_values) & trimws(anchor_values) != "" + ] + if (length(anchored) > 0) { + stop( + "Survival date variable(s) ", paste(anchored, collapse = ", "), + " (anchor set) are generated only by the v0.4 pipeline, but the ", + "legacy generator was selected. ", .legacy_route_reasons(), + call. = FALSE + ) + } + } + if (nrow(enabled_vars) == 0) { stop("No enabled non-derived variables found in configuration. ", "Add 'enabled' to the role column for variables you want to generate, ", @@ -320,13 +470,6 @@ create_mock_data <- function(databaseStart, paste(enabled_vars$variable, collapse = ", ")) } - # ========== SET GLOBAL SEED ========== - - if (!is.null(seed)) { - if (verbose) message("Setting random seed: ", seed) - set.seed(seed) - } - # ========== GENERATE VARIABLES ========== if (verbose) message("Generating ", n, " observations...") @@ -347,6 +490,8 @@ create_mock_data <- function(databaseStart, ) # Generate variables in order + .with_mock_seed(seed, stage = "baseline", { + if (!is.null(seed) && verbose) message("Setting random seed: ", seed) for (i in seq_len(nrow(enabled_vars))) { var_row <- enabled_vars[i, ] var_name <- var_row$variable @@ -424,6 +569,24 @@ create_mock_data <- function(databaseStart, skipped_vars <- c(skipped_vars, var_name) } } + }) + + # An empty result is legitimate when every enabled variable was explicitly + # tracked as skipped (e.g. validate = FALSE + an unsupported rType, warned + # and recorded above). It is NOT legitimate when variables were enabled and + # none were recorded as skipped - that combination can only happen if the + # seed-scoping block above failed to propagate its assignments back to this + # frame. Guard against the latter, not the former. + if (ncol(df_mock) == 0L && nrow(enabled_vars) > 0L && + length(unique(skipped_vars)) < nrow(enabled_vars)) { + stop( + "Internal error: no variables were generated despite ", nrow(enabled_vars), + " enabled variable(s), and not all were recorded as skipped. The ", + "seed-scoping wrapper may have failed to propagate results to the ", + "caller frame - please file a bug report.", + call. = FALSE + ) + } # ========== RETURN RESULT ========== diff --git a/R/create_wide_survival_data.R b/R/create_wide_survival_data.R index 81a6e27..f2a0857 100644 --- a/R/create_wide_survival_data.R +++ b/R/create_wide_survival_data.R @@ -1,5 +1,13 @@ +# Session state for once-per-session notices (ADR v05-survival-dates D9). +.mockdata_state <- new.env(parent = emptyenv()) + #' Create wide survival data for cohort studies #' +#' **Deprecated as of MockData 0.5.0.** Add `anchor` (and `censored_by` for a +#' competing risk) to the survival dates' rows in `variables.csv` and generate +#' them with [create_mock_data()]; see `vignette("tutorial-survival-data")`. +#' The function still works and warns once per session. +#' #' Generates wide-format survival data (one row per individual) with up to 5 date #' variables (entry, event, death, loss-to-follow-up, administrative censoring). #' Applies temporal ordering constraints and supports garbage data generation for @@ -175,6 +183,18 @@ create_wide_survival_data <- function(var_entry_date, n, seed = NULL, prop_garbage = NULL) { + if (!isTRUE(.mockdata_state$wide_survival_warned)) { + .mockdata_state$wide_survival_warned <- TRUE + warning( + "create_wide_survival_data() is deprecated as of MockData 0.5.0. Add ", + "`anchor` (and `censored_by` for competing risks) to the survival ", + "dates' rows in variables.csv and generate them with create_mock_data(); ", + "see vignette(\"tutorial-survival-data\", package = \"MockData\"). ", + "This warning is shown once per session.", + call. = FALSE + ) + } + # ========== VALIDATION ========== diff --git a/R/mock_spec.R b/R/mock_spec.R index 5efd59e..cba2c0a 100644 --- a/R/mock_spec.R +++ b/R/mock_spec.R @@ -114,6 +114,10 @@ NULL stop("mock_spec variable type must be a non-empty string.", call. = FALSE) } + if (!is.null(distribution)) { + distribution <- tolower(distribution) + } + .validate_model_hint(model_hint) structure( @@ -260,6 +264,7 @@ mock_spec <- function(..., #' @param mean,sd Optional distribution parameters. Required when #' `distribution = "normal"`. #' @param rtype R output type. Defaults to `"double"`. +#' @param rate Rate parameter; required when `distribution = "exponential"`. #' @param missing_codes Explicit missing-code values. #' @param missing_proportions Missing-code probabilities aligned to #' `missing_codes`. @@ -283,6 +288,14 @@ mock_spec <- function(..., #' ) #' validate_mock_spec(age_spec) #' +#' wait_spec <- mock_continuous( +#' "wait", +#' range = c(0, 100), +#' distribution = "exponential", +#' rate = 0.1 +#' ) +#' validate_mock_spec(wait_spec) +#' #' @export mock_continuous <- function(name, range, @@ -290,6 +303,7 @@ mock_continuous <- function(name, mean = NA_real_, sd = NA_real_, rtype = "double", + rate = NA_real_, missing_codes = numeric(0), missing_proportions = numeric(0), garbage_rules = list(), @@ -305,6 +319,7 @@ mock_continuous <- function(name, distribution = distribution, mean = mean, sd = sd, + rate = rate, rtype = rtype, missing_codes = missing_codes, missing_proportions = missing_proportions, @@ -448,6 +463,73 @@ mock_date <- function(name, ) } +#' Create a direct formula-derived mock-data specification +#' +#' `mock_formula()` is the simple direct API for formula-derived variables. It +#' returns a validated `mock_spec`; it does not generate data. Formula +#' variables have no `range`/`levels`/`distribution` of their own — their +#' values come from evaluating `formula` over other variables in the same +#' specification once those have been generated; see +#' [evaluate_mock_formulas()]. +#' +#' @details +#' Use `mock_formula()` when specifying one formula variable directly in R +#' code. Use [mock_spec_formula()] with [mock_spec()] when composing several +#' variables or when writing an adapter from another metadata source. +#' +#' @param name Variable name. +#' @param formula Character scalar. An algebraic expression over other +#' variable names, e.g. `"weight / (height^2)"`. +#' @param rtype R output type for the computed column. Defaults to `"double"`. +#' @param missing_codes Explicit missing-code values. +#' @param missing_proportions Missing-code probabilities aligned to +#' `missing_codes`. +#' @param garbage_rules List of intentional invalid-value rules. +#' @param provenance Optional provenance metadata. Defaults to the direct API. +#' @param model_hint Backend hint. +#' @param spec_version Character version of the specification shape. +#' +#' @return A validated `mock_spec` object containing one formula variable. +#' @family direct specification APIs +#' @seealso [mock_spec()], [mock_spec_formula()], [evaluate_mock_formulas()] +#' +#' @examples +#' bmi_spec <- mock_spec( +#' mock_spec_continuous("height", range = c(1.4, 2.1)), +#' mock_spec_continuous("weight", range = c(45, 150)), +#' mock_spec_formula("bmi", formula = "weight / (height^2)") +#' ) +#' validate_mock_spec(bmi_spec) +#' +#' @export +mock_formula <- function(name, + formula, + rtype = "double", + missing_codes = numeric(0), + missing_proportions = numeric(0), + garbage_rules = list(), + provenance = NULL, + model_hint = "auto", + spec_version = .mock_spec_version) { + provenance <- .direct_api_provenance("mock_formula", provenance) + + mock_spec( + mock_spec_formula( + name = name, + formula = formula, + rtype = rtype, + missing_codes = missing_codes, + missing_proportions = missing_proportions, + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = model_hint + ), + spec_version = spec_version, + provenance = provenance, + model_hint = model_hint + ) +} + #' Create a continuous variable specification #' #' @param name Variable name. @@ -455,6 +537,7 @@ mock_date <- function(name, #' @param distribution Distribution name. Defaults to `"uniform"`. #' @param mean,sd Optional distribution parameters. #' @param rtype R output type. Defaults to `"double"`. +#' @param rate Rate parameter; required when `distribution = "exponential"`. #' @param missing_codes Explicit missing-code values. #' @param missing_proportions Missing-code probabilities aligned to #' `missing_codes`. @@ -476,6 +559,13 @@ mock_date <- function(name, #' rtype = "integer" #' ) #' +#' wait_spec <- mock_spec_continuous( +#' "wait", +#' range = c(0, 100), +#' distribution = "exponential", +#' rate = 0.1 +#' ) +#' #' @export mock_spec_continuous <- function(name, range, @@ -483,6 +573,7 @@ mock_spec_continuous <- function(name, mean = NA_real_, sd = NA_real_, rtype = "double", + rate = NA_real_, missing_codes = numeric(0), missing_proportions = numeric(0), garbage_rules = list(), @@ -496,6 +587,7 @@ mock_spec_continuous <- function(name, range = range, mean = mean, sd = sd, + rate = rate, missing_codes = missing_codes, missing_proportions = missing_proportions, garbage_rules = garbage_rules, @@ -602,6 +694,137 @@ mock_spec_date <- function(name, ) } +#' Create a formula-derived variable specification +#' +#' `mock_spec_formula()` describes a variable computed from other generated +#' variables by evaluating an algebraic expression (a `mockFormula`), rather +#' than sampled from a distribution. Evaluation happens in a restricted +#' environment exposing only the generated columns and a fixed allow-list of +#' base functions; see [evaluate_mock_formulas()]. +#' +#' @param name Variable name. +#' @param formula Character scalar. An algebraic expression over other +#' variable names, e.g. `"weight / (height^2)"`. +#' @param rtype R output type for the computed column. Defaults to `"double"`. +#' @param missing_codes,missing_proportions,garbage_rules,provenance,model_hint +#' As for other variable specifications; applied by post-processing. +#' +#' @return A `mock_spec_variable` object of type `"formula"`. +#' @family mock specification APIs +#' @seealso [mock_formula()], [evaluate_mock_formulas()] +#' @export +mock_spec_formula <- function(name, + formula, + rtype = "double", + missing_codes = numeric(0), + missing_proportions = numeric(0), + garbage_rules = list(), + provenance = "direct", + model_hint = "auto") { + variable <- .new_mock_spec_variable( + name = name, + type = "formula", + rtype = rtype, + formula = formula, + missing_codes = missing_codes, + missing_proportions = missing_proportions, + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = model_hint + ) + # Parse failures are reported by validate_mock_spec(), not the constructor, + # matching the sibling constructors' defer-all-validation contract. + # depends_on is computed once here at construction time; it is not + # re-synced if `formula` is mutated afterwards. + variable$depends_on <- tryCatch( + .formula_dependencies(variable), + error = function(e) character(0) + ) + variable +} + +#' Create a survival date variable specification +#' +#' `mock_spec_survival()` describes a date generated relative to an anchor +#' (entry) date: `floor(n * event_prop)` rows receive a date a follow-up time +#' after the anchor, drawn within `[followup_min, followup_max]` days, and the +#' rest are `NA` (censored). Survival variables are computed after baseline +#' generation by [generate_survival_dates()], so the anchor must be another +#' variable in the same [mock_spec()]. +#' +#' @param name Variable name. +#' @param anchor Name of the date variable this date is generated relative to. +#' @param followup_min,followup_max Follow-up window in days after the anchor. +#' @param event_prop Share of rows that receive an event, in `[0, 1]`. +#' @param distribution Follow-up time distribution: `"uniform"`, +#' `"exponential"`, or `"gompertz"`. +#' @param censored_by Optional name of a competing survival variable with the +#' same anchor. Where that date is earlier than this one, this one becomes +#' `NA`. +#' @param shape,rate Optional Gompertz parameters (defaults 0.1 and 0.0001). +#' The exponential distribution derives its rate from the follow-up window. +#' @param source_format Output format. Only `"analysis"` (R `Date`) is +#' supported for survival dates. +#' @param missing_codes,missing_proportions,garbage_rules,provenance,model_hint +#' As for other variable specifications; applied by post-processing after +#' the survival rules. +#' +#' @return A `mock_spec_variable` object of type `"survival"`. +#' @family mock specification APIs +#' @seealso [generate_survival_dates()], [mock_spec_date()] +#' +#' @examples +#' spec <- mock_spec( +#' mock_spec_date("entry", range = as.Date(c("2001-01-01", "2005-12-31"))), +#' mock_spec_survival("death", anchor = "entry", +#' followup_min = 365, followup_max = 7300, event_prop = 0.2), +#' mock_spec_survival("event", anchor = "entry", +#' followup_min = 0, followup_max = 5475, event_prop = 0.3, +#' censored_by = "death") +#' ) +#' validate_mock_spec(spec) +#' +#' @export +mock_spec_survival <- function(name, + anchor, + followup_min, + followup_max, + event_prop, + distribution = "uniform", + censored_by = NULL, + shape = NULL, + rate = NULL, + source_format = "analysis", + missing_codes = character(0), + missing_proportions = numeric(0), + garbage_rules = list(), + provenance = "direct", + model_hint = "native-postprocess") { + variable <- .new_mock_spec_variable( + name = name, + type = "survival", + rtype = "date", + distribution = distribution, + source_format = source_format, + missing_codes = missing_codes, + missing_proportions = missing_proportions, + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = model_hint, + anchor = anchor, + censored_by = censored_by, + followup_min = followup_min, + followup_max = followup_max, + event_prop = event_prop, + shape = shape, + rate = rate + ) + # Computed once at construction, as for mock_spec_formula(); anchor and + # censored_by feed the shared dependency ordering (ADR D7). + variable$depends_on <- .survival_dependencies(variable) + variable +} + #' Check whether an object is a MockData specification #' #' @param x Object to check. @@ -797,6 +1020,15 @@ print.mock_spec_validation_result <- function(x, ...) { errors <- c(errors, paste0("Variable '", variable$name, "' normal distribution requires sd > 0.")) } } + if (identical(variable$distribution, "exponential")) { + if (is.null(variable$rate) || length(variable$rate) != 1 || + is.na(variable$rate) || variable$rate <= 0) { + errors <- c(errors, paste0( + "Variable '", variable$name, + "' exponential distribution requires rate > 0." + )) + } + } } else if (variable$type == "categorical") { if (is.null(variable$levels) || length(variable$levels) == 0) { errors <- c(errors, paste0("Variable '", variable$name, "' must have at least one level.")) @@ -817,6 +1049,29 @@ print.mock_spec_validation_result <- function(x, ...) { } } else if (variable$type == "date") { errors <- c(errors, .validate_range(variable$range, variable$name, "Date")) + } else if (variable$type == "formula") { + f <- variable$formula + if (is.null(f) || !is.character(f) || length(f) != 1 || is.na(f) || + trimws(f) == "") { + errors <- c(errors, paste0( + "Variable '", variable$name, + "' formula type requires a non-empty character formula." + )) + } else { + parse_error <- tryCatch({ str2lang(f); NULL }, error = function(e) conditionMessage(e)) + if (!is.null(parse_error)) { + errors <- c(errors, paste0( + "Variable '", variable$name, "' formula could not be parsed: ", parse_error + )) + } + } + if (!variable$rtype %in% c("double", "numeric", "integer", "factor", "character", "logical")) { + errors <- c(errors, paste0( + "Variable '", variable$name, "' has unsupported formula rType '", variable$rtype, "'." + )) + } + } else if (variable$type == "survival") { + errors <- c(errors, .validate_survival_variable(variable)) } else { errors <- c(errors, paste0("Variable '", variable$name, "' has unsupported type '", variable$type, "'.")) } @@ -874,11 +1129,30 @@ validate_mock_spec <- function(spec, n = NULL, strict = TRUE) { for (variable in spec$variables) { errors <- c(errors, .validate_mock_spec_variable(variable)) } + + formula_error <- tryCatch({ + .validate_formula_referents(spec) + .order_formula_variables(spec) + NULL + }, error = function(e) conditionMessage(e)) + if (!is.null(formula_error)) { + errors <- c(errors, formula_error) + } + + errors <- c(errors, .validate_survival_referents(spec)) + survival_error <- tryCatch({ + .order_survival_variables(spec) + NULL + }, error = function(e) conditionMessage(e)) + if (!is.null(survival_error)) { + errors <- c(errors, survival_error) + } } } if (!is.null(n)) { - if (!is.numeric(n) || length(n) != 1 || is.na(n) || n < 0 || n != floor(n)) { + if (!is.numeric(n) || length(n) != 1 || is.na(n) || !is.finite(n) || + n < 0 || n != floor(n)) { errors <- c(errors, "n must be a non-negative whole number.") } } diff --git a/R/mock_spec_formula.R b/R/mock_spec_formula.R new file mode 100644 index 0000000..c457e71 --- /dev/null +++ b/R/mock_spec_formula.R @@ -0,0 +1,281 @@ +# ============================================================================== +# Formula-derived variables (#39, Phase A) — spec-side helpers. +# Evaluation itself lives in evaluate_mock_formulas() (same file, Task 2). +# ADR: development/adr/v05-formula-evaluator.md +# ============================================================================== + +# Base functions/operators a mockFormula may call. Deliberately closed and +# RNG-free: Phase A formulas are deterministic transformations of generated +# columns. Widen only on concrete need (ADR D3). is.na was added for deriving +# survival status (ADR v05-survival-dates D8). +.formula_allowlist <- c( + "+", "-", "*", "/", "^", "%%", "%/%", "(", + "<", "<=", ">", ">=", "==", "!=", "&", "|", "!", + "ifelse", "pmin", "pmax", "min", "max", "abs", "round", "floor", + "ceiling", "sqrt", "exp", "log", "log10", "sum", "mean", "cut", + "as.numeric", "as.integer", "as.factor", "factor", "c", "is.na" +) + +#' @noRd +.parse_mock_formula <- function(variable) { + tryCatch( + str2lang(variable$formula), + error = function(e) { + stop( + "Formula for variable '", variable$name, "' could not be parsed: ", + conditionMessage(e), + call. = FALSE + ) + } + ) +} + +#' @noRd +.is_formula_variable <- function(variable) { + identical(variable$type, "formula") +} + +#' @noRd +.formula_dependencies <- function(variable) { + if (!.is_formula_variable(variable)) { + return(character(0)) + } + all.vars(.parse_mock_formula(variable)) +} + +#' @noRd +.formula_function_symbols <- function(variable) { + expr <- .parse_mock_formula(variable) + setdiff(all.names(expr), all.vars(expr)) +} + +#' @noRd +.validate_formula_referents <- function(spec) { + spec_names <- names(spec$variables) + for (variable in spec$variables) { + if (!.is_formula_variable(variable)) next + # Unparseable formulas are reported once, by the per-variable validator + # branch (R/mock_spec.R) — re-parsing here (via .formula_dependencies()) + # would both duplicate that error and abort this loop before checking the + # OTHER, parseable formulas, masking their referent/symbol findings. + parseable <- tryCatch({ + .parse_mock_formula(variable) + TRUE + }, error = function(e) FALSE) + if (!parseable) next + missing <- setdiff(.formula_dependencies(variable), spec_names) + if (length(missing) > 0) { + stop( + "Formula for variable '", variable$name, + "' references unknown variable(s): ", + paste(missing, collapse = ", "), + call. = FALSE + ) + } + disallowed <- setdiff(.formula_function_symbols(variable), .formula_allowlist) + if (length(disallowed) > 0) { + stop( + "Formula for variable '", variable$name, + "' uses function(s) not permitted in mockFormula expressions: ", + paste(disallowed, collapse = ", "), + call. = FALSE + ) + } + } + invisible(TRUE) +} + +#' @noRd +.order_dependent_variables <- function(names_in_scope, dependencies, label) { + # Shared by every derived-variable stage (ADR v05-survival-dates D7): + # repeated passes in spec order, each placing the variables whose in-scope + # dependencies are already placed. Dependencies outside `names_in_scope` + # (baseline columns) are available from the start. + remaining <- names_in_scope + ordered <- character(0) + while (length(remaining) > 0) { + progressed <- FALSE + for (name in remaining) { + deps <- intersect(dependencies[[name]], names_in_scope) + if (all(deps %in% ordered)) { + ordered <- c(ordered, name) + remaining <- setdiff(remaining, name) + progressed <- TRUE + } + } + if (!progressed) { + stop( + label, " dependency cycle or unresolved ordering among: ", + paste(remaining, collapse = ", "), + call. = FALSE + ) + } + } + ordered +} + +#' @noRd +.order_formula_variables <- function(spec) { + formula_names <- names(spec$variables)[ + vapply(spec$variables, .is_formula_variable, logical(1)) + ] + # Skip unparseable formulas here too (see .validate_formula_referents()): + # the per-variable validator already reports the parse error once, and a + # variable that can't be parsed has no computable dependency set — leaving + # it in `formula_names` would either duplicate the parse error (via + # .formula_dependencies()) or permanently block any variable that + # references it from ever being ordered. + parseable_names <- Filter(function(name) { + tryCatch({ + .parse_mock_formula(spec$variables[[name]]) + TRUE + }, error = function(e) FALSE) + }, formula_names) + + dependencies <- lapply(spec$variables[parseable_names], .formula_dependencies) + .order_dependent_variables(parseable_names, dependencies, "Formula") +} + +#' @noRd +.formula_parent_env <- function() { + # baseNamespace() (the brief's original spelling) is not itself an + # exported/callable base function - .BaseNamespaceEnv is the base-R builtin + # binding for the same environment (verified: every .formula_allowlist + # symbol, including "(" and the operators, resolves via + # get(fn, envir = .BaseNamespaceEnv)). Its parent is emptyenv(), so nothing + # beyond this fixed set of base primitives is reachable from here. + env <- new.env(parent = emptyenv()) + for (fn in .formula_allowlist) { + env[[fn]] <- get(fn, envir = .BaseNamespaceEnv) + } + env +} + +#' Evaluate formula-derived variables over generated data +#' +#' Computes each `type = "formula"` variable in `spec` by evaluating its +#' expression over the columns of `data`, in dependency order. Evaluation runs +#' in a restricted environment exposing only the data columns and a fixed +#' allow-list of base functions — formulas cannot reach the caller's +#' environment. Any randomness would draw from the isolated `formula` +#' sub-stream (see the v0.5 seed contract), though the Phase A allow-list is +#' RNG-free, so evaluation is deterministic given `data`. +#' +#' @param data Data frame of generated baseline values (from +#' [generate_mock_data_native()] or [generate_mock_data_simstudy()]). +#' @param spec A `mock_spec`. Non-formula variables must already be columns of +#' `data`. Strict-validated at entry (like [postprocess_mock_data()]), so an +#' unparseable formula, an unknown referent, a disallowed symbol, or a +#' dependency cycle raises here rather than silently skipping the affected +#' variable. +#' @param seed Optional whole-number seed; reserved for future formulas with +#' random components. The caller's RNG state and kind are restored on exit. +#' +#' @return `data` with one appended column per formula variable. Returns +#' `data` unchanged if the spec has no formula variables. +#' @seealso [mock_formula()], [postprocess_mock_data()] +#' @export +evaluate_mock_formulas <- function(data, spec, seed = NULL) { + if (!is.data.frame(data)) { + stop("data must be a data frame.", call. = FALSE) + } + # Strict-validate first, exactly as postprocess_mock_data() does: the + # accumulate-and-skip behaviour inside .order_formula_variables() and + # .validate_formula_referents() is correct for validate_mock_spec()'s + # multi-error reporting, but it means an unparseable/cyclic/invalid-referent + # formula would otherwise be silently excluded from `ordered` below rather + # than reported — generation must never reach a bad formula. Strict + # validation already runs .validate_formula_referents()/ + # .order_formula_variables() (see validate_mock_spec()), so the direct call + # to .validate_formula_referents() that used to live below is redundant and + # has been removed. + validate_mock_spec(spec, n = nrow(data), strict = TRUE) + + ordered <- .order_formula_variables(spec) + if (length(ordered) == 0) { + return(data) + } + + non_formula <- setdiff(names(spec$variables), names(spec$variables)[ + vapply(spec$variables, .is_formula_variable, logical(1)) + ]) + missing_inputs <- setdiff(non_formula, names(data)) + if (length(missing_inputs) > 0) { + stop( + "data is missing generated column(s) required by formulas: ", + paste(missing_inputs, collapse = ", "), + call. = FALSE + ) + } + + .with_mock_seed(seed, stage = "formula", { + fn_env <- .formula_parent_env() + for (name in ordered) { + variable <- spec$variables[[name]] + values <- eval( + .parse_mock_formula(variable), + envir = as.list(data), + enclos = fn_env + ) + if (length(values) == 1 && nrow(data) != 1) { + values <- rep(values, nrow(data)) # scalar formulas broadcast + } + if (length(values) != nrow(data)) { + stop( + "Formula for variable '", name, "' returned length ", length(values), + ", expected ", nrow(data), ".", + call. = FALSE + ) + } + data[[name]] <- .coerce_formula_rtype(values, variable$rtype, name) + } + data + }) +} + +#' @noRd +.coerce_formula_rtype <- function(values, rtype, variable_name) { + switch(rtype, + double = { + if (is.factor(values)) { + warning( + "Variable '", variable_name, + "' formula produced a factor but rType is '", rtype, + "'; coercing to its integer level codes. Set rtype = \"factor\" (or \"character\") if that is not intended.", + call. = FALSE + ) + } + as.numeric(values) + }, + numeric = { + if (is.factor(values)) { + warning( + "Variable '", variable_name, + "' formula produced a factor but rType is '", rtype, + "'; coercing to its integer level codes. Set rtype = \"factor\" (or \"character\") if that is not intended.", + call. = FALSE + ) + } + as.numeric(values) + }, + integer = { + if (is.factor(values)) { + warning( + "Variable '", variable_name, + "' formula produced a factor but rType is '", rtype, + "'; coercing to its integer level codes. Set rtype = \"factor\" (or \"character\") if that is not intended.", + call. = FALSE + ) + } + as.integer(values) + }, + factor = as.factor(values), + character = as.character(values), + logical = as.logical(values), + stop( + "Variable '", variable_name, "' has unsupported formula rType '", + rtype, "'.", + call. = FALSE + ) + ) +} diff --git a/R/mock_spec_native.R b/R/mock_spec_native.R index 53c8286..d4ac437 100644 --- a/R/mock_spec_native.R +++ b/R/mock_spec_native.R @@ -5,8 +5,23 @@ # garbage, diagnostics, and richer rType handling lands in later milestones. # ============================================================================== +# Named generation stages -> fixed L'Ecuyer-CMRG sub-stream indices. Indices +# are FROZEN: adding a future stage must not renumber baseline/postprocess, or +# seeded output for existing features would shift. correlate (#42) is reserved +# though unused; survival (#40) is appended, never inserted. +.MOCK_STAGES <- c( + baseline = 0L, + postprocess = 1L, + formula = 2L, + correlate = 3L, + survival = 4L +) + #' @noRd -.with_mock_seed <- function(seed, expr) { +.with_mock_seed <- function(seed, expr, stage = "baseline") { + if (!stage %in% names(.MOCK_STAGES)) { + stop("Unknown generation stage: '", stage, "'.", call. = FALSE) + } if (is.null(seed)) { return(force(expr)) } @@ -15,13 +30,20 @@ stop("seed must be a single whole number.", call. = FALSE) } + # Save the caller's RNG state AND kind so generation never perturbs them. + old_kind <- RNGkind() had_seed <- exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE) if (had_seed) { old_seed <- get(".Random.seed", envir = .GlobalEnv, inherits = FALSE) } - - # Generation should be reproducible without changing the caller's RNG stream. on.exit({ + # Suppress: restoring the caller's own ambient RNGkind (e.g. sample.kind + # = "Rounding" from RNGversion("3.5.0")) would otherwise re-fire the + # "non-uniform 'Rounding' sampler used" warning on every seeded call - + # the caller already chose that kind and was already warned about it once. + suppressWarnings( + RNGkind(kind = old_kind[1], normal.kind = old_kind[2], sample.kind = old_kind[3]) + ) if (had_seed) { assign(".Random.seed", old_seed, envir = .GlobalEnv) } else if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)) { @@ -29,7 +51,16 @@ } }, add = TRUE) - set.seed(seed) + # One L'Ecuyer-CMRG stream from the public seed, advanced to this stage's + # provably-independent sub-stream. Pinning the kind makes output independent + # of the caller's ambient RNGkind(). + set.seed(seed, kind = "L'Ecuyer-CMRG", normal.kind = "Inversion", sample.kind = "Rejection") + stream <- .Random.seed + for (i in seq_len(.MOCK_STAGES[[stage]])) { + stream <- parallel::nextRNGStream(stream) + } + assign(".Random.seed", stream, envir = .GlobalEnv) + force(expr) } @@ -49,6 +80,14 @@ #' @noRd .native_formula_variables <- function(spec) { names(Filter(function(variable) { + # type == "formula" variables are supported (#39): the native backend + # skips them (see generate_mock_data_native()) and evaluate_mock_formulas() + # computes them post-baseline. This check only guards a *stray* formula + # field left on a variable of some OTHER type (e.g. an adapter mistake) - + # that combination remains an unsupported/fallback trigger. + if (.is_formula_variable(variable)) { + return(FALSE) + } formula <- variable$formula !is.null(formula) && !(is.character(formula) && length(formula) == 1 && (is.na(formula) || trimws(formula) == "")) @@ -105,6 +144,30 @@ values } +# Exact truncation via inverse-CDF (pexp/qexp): exponential's CDF is +# closed-form and invertible, so no rejection-sampling fallback is needed +# (contrast .native_truncated_normal above). +#' @noRd +.native_truncated_exponential <- function(n, rate, range, variable_name) { + if (n == 0) { + return(numeric(0)) + } + + lower <- range[[1]] + upper <- range[[2]] + p_lower <- stats::pexp(lower, rate = rate) + p_upper <- stats::pexp(upper, rate = rate) + if (!is.finite(p_lower) || !is.finite(p_upper) || p_upper <= p_lower) { + stop( + "Variable '", variable_name, + "' exponential distribution has no probability mass inside range [", + lower, ", ", upper, "].", + call. = FALSE + ) + } + stats::qexp(stats::runif(n, p_lower, p_upper), rate = rate) +} + #' @noRd .coerce_native_continuous <- function(values, rtype, variable_name) { if (rtype == "integer") { @@ -199,6 +262,13 @@ variable$range, variable$name ) + } else if (distribution == "exponential") { + values <- .native_truncated_exponential( + n, + variable$rate, + variable$range, + variable$name + ) } else { stop( "Native backend does not yet support continuous distribution '", @@ -272,30 +342,41 @@ #' #' `generate_mock_data_native()` consumes a validated `mock_spec` and generates #' baseline valid values using MockData's native R backend. This milestone does -#' not yet apply missing-code injection, garbage values, diagnostics, formula -#' evaluation, or optional `simstudy` features. +#' not yet apply missing-code injection, garbage values, diagnostics, or +#' optional `simstudy` features. #' #' @details #' The native backend is the default MIT-licensed baseline engine. It currently #' supports uniform continuous variables, truncated-normal continuous variables, -#' categorical variables, and uniform calendar dates. Missing codes, garbage +#' truncated-exponential continuous variables, categorical variables, and +#' uniform calendar dates. Missing codes, garbage #' values, and diagnostics are intentionally handled by [postprocess_mock_data()] #' so that all backends share the same audit trail. #' -#' If `seed` is supplied, the previous R random state is restored after -#' generation. This gives reproducible output without advancing the caller's RNG -#' stream. Formula variables are rejected loudly until the formula/dependency -#' milestone promotes the spike evaluator into production. +#' `type = "formula"` variables are skipped by this backend — they carry no +#' distribution to sample from. They are computed post-baseline by +#' [evaluate_mock_formulas()], which evaluates each formula over this +#' function's output columns in dependency order. A spec containing only +#' formula variables still returns an `n`-row, zero-column data frame here (see +#' [evaluate_mock_formulas()] for how columns are appended afterwards). A +#' stray `formula` field on a variable of some other type remains an +#' unsupported/fallback trigger. #' #' @param spec A `mock_spec` object. #' @param n Non-negative whole number of rows to generate. -#' @param seed Optional whole-number random seed. The previous R random state is -#' restored after generation. +#' @param seed Optional whole-number seed. Generation uses an isolated +#' L'Ecuyer-CMRG sub-stream and restores the caller's RNG state and kind on +#' exit, so output is reproducible for a given seed and package version +#' without perturbing the caller's RNG. #' -#' @return A data frame with one column per `mock_spec` variable and `n` rows. +#' @return A data frame with `n` rows and one column per non-derived +#' `mock_spec` variable (`type = "survival"` and `type = "formula"` +#' variables are appended afterwards by [generate_survival_dates()] and +#' [evaluate_mock_formulas()]). #' @family mock generation APIs #' @seealso [mock_spec()], [mock_continuous()], [mock_spec_from_recodeflow()], -#' [postprocess_mock_data()], [generate_mock_data_simstudy()] +#' [postprocess_mock_data()], [generate_mock_data_simstudy()], +#' [evaluate_mock_formulas()] #' #' @examples #' spec <- mock_spec( @@ -315,12 +396,22 @@ generate_mock_data_native <- function(spec, n, seed = NULL) { .check_native_backend_scope(spec) .with_mock_seed(seed, { - if (length(spec$variables) == 0) { + # Derived variables (type = "formula" or "survival") are computed + # post-baseline by evaluate_mock_formulas() and generate_survival_dates(). Filtering them + # out here (rather than in .generate_native_variable()) leaves this + # branch's assembly of `columns` byte-identical to before #39 for any + # spec with no formula variables (the common, zero-seeded-output-change + # case) - and a formula-only spec still falls through to the same + # .empty_native_data(n) path a variable-less spec would use. + generated_variables <- spec$variables[ + !vapply(spec$variables, .is_derived_variable, logical(1)) + ] + if (length(generated_variables) == 0) { .empty_native_data(n) } else { - columns <- lapply(spec$variables, .generate_native_variable, n = n) - names(columns) <- names(spec$variables) + columns <- lapply(generated_variables, .generate_native_variable, n = n) + names(columns) <- names(generated_variables) as.data.frame(columns, stringsAsFactors = FALSE, check.names = FALSE) } - }) + }, stage = "baseline") } diff --git a/R/mock_spec_postprocess.R b/R/mock_spec_postprocess.R index 8b394f2..bad34a4 100644 --- a/R/mock_spec_postprocess.R +++ b/R/mock_spec_postprocess.R @@ -7,7 +7,7 @@ # ============================================================================== #' @noRd -.postprocess_empty_diagnostics <- function(spec, n) { +.postprocess_empty_diagnostics <- function(spec, n, data = NULL) { variables <- lapply(spec$variables, function(variable) { garbage_rule_names <- names(variable$garbage_rules) if (is.null(garbage_rule_names)) { @@ -23,7 +23,7 @@ garbage_rule_names ) - list( + entry <- list( n = n, preexisting_missing_code_indices = integer(0), assigned_missing_indices = integer(0), @@ -31,6 +31,31 @@ assigned_garbage_indices = garbage_indices, assigned_garbage_values = garbage_values ) + + # D5 (#39, Phase A): formula variables get diagnostics fields marking + # them as derived, so downstream consumers can tell a computed column + # from a sampled one without re-deriving it from the spec. + if (.is_formula_variable(variable)) { + entry$derived <- TRUE + entry$formula <- variable$formula + entry$depends_on <- variable$depends_on + } + + # ADR v05-survival-dates D7: survival dates are derived from their anchor; + # n_events counts the dates present before post-processing. + if (.is_survival_variable(variable)) { + entry$derived <- TRUE + entry$anchor <- variable$anchor + entry$censored_by <- variable$censored_by + entry$depends_on <- variable$depends_on + entry$n_events <- if (is.null(data)) { + NA_integer_ + } else { + sum(!is.na(data[[variable$name]])) + } + } + + entry }) list( @@ -306,8 +331,10 @@ #' #' @param data Data frame with one column for each variable in `spec`. #' @param spec A validated `mock_spec` object. -#' @param seed Optional whole-number random seed. The previous R random state is -#' restored after post-processing. +#' @param seed Optional whole-number seed. Generation uses an isolated +#' L'Ecuyer-CMRG sub-stream and restores the caller's RNG state and kind on +#' exit, so output is reproducible for a given seed and package version +#' without perturbing the caller's RNG. #' @param diagnostics Logical. If `TRUE`, attach a `mockdata_diagnostics` #' attribute to the returned data frame. #' @@ -344,7 +371,7 @@ #' missing_proportions = 0.05 #' ) #' baseline <- generate_mock_data_native(spec, n = 20, seed = 1) -#' result <- postprocess_mock_data(baseline, spec, seed = 2) +#' result <- postprocess_mock_data(baseline, spec, seed = 1) #' attr(result, "mockdata_diagnostics")$variables$smoking #' #' @export @@ -372,7 +399,7 @@ postprocess_mock_data <- function(data, spec, seed = NULL, diagnostics = TRUE) { .with_mock_seed(seed, { output <- data - diag <- .postprocess_empty_diagnostics(spec, nrow(data)) + diag <- .postprocess_empty_diagnostics(spec, nrow(data), data) for (variable_name in names(spec$variables)) { variable <- spec$variables[[variable_name]] @@ -390,5 +417,5 @@ postprocess_mock_data <- function(data, spec, seed = NULL, diagnostics = TRUE) { } output - }) + }, stage = "postprocess") } diff --git a/R/mock_spec_recodeflow.R b/R/mock_spec_recodeflow.R index 1c82bed..a23edcc 100644 --- a/R/mock_spec_recodeflow.R +++ b/R/mock_spec_recodeflow.R @@ -119,6 +119,10 @@ variable_type <- tolower(.row_character(var_row, "variableType", "")) if (rtype == "date" || variable_type == "date") { + # A non-blank anchor makes a date a survival date (#40, ADR D3). + if (!.is_blank(.row_character(var_row, "anchor", ""))) { + return("survival") + } return("date") } if (variable_type == "categorical" || rtype %in% c("factor", "character", "logical")) { @@ -150,7 +154,8 @@ kind, continuous = "double", categorical = "factor", - date = "date" + date = "date", + survival = "date" ) } @@ -221,7 +226,45 @@ } #' @noRd -.recodeflow_missing <- function(details) { +.expand_range_missing_codes <- function(codes, proportions, variable) { + # #58: recodeflow writes grouped missing codes in range notation, e.g. + # "[997,999]" for don't know / refusal / not stated. Expand an integer range + # into its individual codes, splitting the row's proportion equally (the + # legacy generators draw uniformly within the range). Any other bracketed + # code cannot be inserted as a value, so fail naming the variable rather + # than writing the literal string into the data. + codes <- as.character(codes) + expanded_codes <- character(0) + expanded_proportions <- numeric(0) + for (i in seq_along(codes)) { + code <- trimws(codes[i]) + if (!startsWith(code, "[") && !startsWith(code, "(")) { + expanded_codes <- c(expanded_codes, code) + expanded_proportions <- c(expanded_proportions, proportions[i]) + next + } + parsed <- parse_range_notation(code) + if (is.null(parsed) || !identical(parsed$type, "integer") || + length(parsed$values) == 0) { + stop( + "Variable '", variable, "' has a range missing code '", code, + "' that is not an integer range. Write it as an integer range such ", + "as [997,999], or list each code on its own row.", + call. = FALSE + ) + } + values <- as.character(parsed$values) + expanded_codes <- c(expanded_codes, values) + expanded_proportions <- c( + expanded_proportions, + rep(proportions[i] / length(values), length(values)) + ) + } + list(codes = expanded_codes, proportions = expanded_proportions) +} + +#' @noRd +.recodeflow_missing <- function(details, variable = "") { if (is.null(details) || nrow(details) == 0 || !"recEnd" %in% names(details)) { return(list(codes = character(0), proportions = numeric(0))) } @@ -239,12 +282,43 @@ proportions <- if ("proportion" %in% names(missing_rows)) missing_rows$proportion else rep(NA_real_, nrow(missing_rows)) proportions[is.na(proportions)] <- 0 - list( - codes = as.character(missing_rows$recStart), - proportions = as.numeric(proportions) + .expand_range_missing_codes( + missing_rows$recStart, + as.numeric(proportions), + variable ) } +#' @noRd +# Formula-derived variables (#39, Phase A). A variable's mockFormula is a +# variable-level fact but is stored row-level on variable_details (ADR +# v05-formula-evaluator D1(a)): a variable's details may span multiple rows +# (e.g. one DerivedVar:: row plus missing-code rows), so read the first +# non-blank mockFormula among them. Returns "" when the column is absent or +# every value is blank/NA. +.details_mock_formula <- function(details) { + if (is.null(details) || nrow(details) == 0 || !"mockFormula" %in% names(details)) { + return("") + } + + values <- as.character(details$mockFormula) + values <- values[!vapply(values, .is_blank, logical(1))] + if (length(values) == 0) { + return("") + } + + # Warn if there are conflicting (distinct) mockFormula values + if (length(unique(values)) > 1) { + warning( + "Variable has conflicting mockFormula values across detail rows; using the first: '", + trimws(values[1]), "'.", + call. = FALSE + ) + } + + trimws(values[1]) +} + #' @noRd .recodeflow_distribution <- function(var_row, details) { distribution <- tolower(.row_character(var_row, "distribution", "")) @@ -308,22 +382,47 @@ kind <- .recodeflow_variable_kind(var_row) rtype <- .recodeflow_rtype(var_row, kind) provenance <- .recodeflow_provenance(variable, databaseStart) - missing <- .recodeflow_missing(details) + missing <- .recodeflow_missing(details, variable) garbage_rules <- .recodeflow_garbage_rules(var_row) + # Formula-type construction takes precedence over the kind dispatch below + # (#39, Phase A, ADR D1(a)/D6): a non-blank mockFormula on the variable's + # detail rows means it is generated by evaluate_mock_formulas(), regardless + # of what variableType/rType the row otherwise declares. Missing-code and + # garbage settings are reused as-is so postprocess behaves identically to + # any other variable. + mock_formula <- .details_mock_formula(details) + if (!.is_blank(mock_formula)) { + return(mock_spec_formula( + name = variable, + formula = mock_formula, + rtype = rtype, + missing_codes = missing$codes, + missing_proportions = missing$proportions, + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = "native" + )) + } + if (kind == "categorical") { proportions <- extract_proportions(details, variable_name = variable) if (length(proportions$categories) == 0) { stop("Variable '", variable, "' has no valid categorical levels.", call. = FALSE) } + categorical_missing <- .expand_range_missing_codes( + names(proportions$missing), + as.numeric(unlist(proportions$missing, use.names = FALSE)), + variable + ) return(mock_spec_categorical( name = variable, levels = proportions$categories, proportions = proportions$category_proportions, rtype = rtype, - missing_codes = names(proportions$missing), - missing_proportions = as.numeric(unlist(proportions$missing, use.names = FALSE)), + missing_codes = categorical_missing$codes, + missing_proportions = categorical_missing$proportions, garbage_rules = garbage_rules, provenance = provenance, model_hint = "native" @@ -339,6 +438,7 @@ distribution = distribution, mean = .row_numeric(var_row, "mean"), sd = .row_numeric(var_row, "sd"), + rate = .row_numeric(var_row, "rate"), rtype = rtype, missing_codes = missing$codes, missing_proportions = missing$proportions, @@ -348,6 +448,41 @@ )) } + if (kind == "survival") { + return(mock_spec_survival( + name = variable, + anchor = .row_character(var_row, "anchor"), + censored_by = .row_character(var_row, "censored_by", NULL), + followup_min = .row_numeric(var_row, "followup_min"), + followup_max = .row_numeric(var_row, "followup_max"), + event_prop = .row_numeric(var_row, "event_prop"), + distribution = .recodeflow_distribution(var_row, details), + shape = .row_numeric(var_row, "shape"), + rate = .row_numeric(var_row, "rate"), + source_format = .row_character(var_row, "sourceFormat", "analysis"), + missing_codes = missing$codes, + missing_proportions = missing$proportions, + garbage_rules = garbage_rules, + provenance = provenance + )) + } + + # ADR v05-survival-dates D3: survival parameters without an anchor were + # silently dropped or generated as a plain calendar date before #40. + survival_fields <- c("followup_min", "followup_max", "event_prop") + present <- survival_fields[!vapply(survival_fields, function(field) { + is.na(.row_numeric(var_row, field)) + }, logical(1))] + if (length(present) > 0) { + stop( + "Date variable '", variable, "' has survival parameter(s) ", + paste(present, collapse = ", "), " but no anchor. Add anchor = ", + "\"\" to its variables row to generate it as a ", + "survival date, or remove the survival parameters.", + call. = FALSE + ) + } + source_format <- .row_character(var_row, "sourceFormat", "analysis") .new_mock_spec_variable( @@ -363,10 +498,7 @@ provenance = provenance, model_hint = "native-postprocess", rate = .row_numeric(var_row, "rate"), - shape = .row_numeric(var_row, "shape"), - followup_min = .row_numeric(var_row, "followup_min"), - followup_max = .row_numeric(var_row, "followup_max"), - event_prop = .row_numeric(var_row, "event_prop") + shape = .row_numeric(var_row, "shape") ) } @@ -380,10 +512,14 @@ #' This adapter preserves recodeflow semantics instead of treating metadata as a #' generic table. It uses exact role and `databaseStart` token matching, parses #' valid ranges from `recStart`, classifies missing codes from `recEnd` values -#' that begin with `NA::`, preserves categorical levels and proportions, carries -#' `garbage_*` settings into `garbage_rules`, and stores survival/date fields -#' such as `rate`, `shape`, `followup_min`, `followup_max`, and `event_prop` on -#' date variables for later backend milestones. +#' that begin with `NA::` (expanding an integer range such as `[997,999]` into +#' its individual codes, with the row's proportion split equally), preserves +#' categorical levels and proportions, carries +#' `garbage_*` settings into `garbage_rules`, and builds a survival date +#' ([mock_spec_survival()]) from any date row that sets `anchor`, reading +#' `censored_by`, `followup_min`, `followup_max`, `event_prop`, `distribution`, +#' `shape` and `rate`. A date row with survival parameters but no `anchor` is +#' an error. #' #' By default, variables identified by `DerivedVar::` or `Func::` rows are #' excluded because they should be evaluated after raw mock variables are @@ -469,6 +605,19 @@ mock_spec_from_recodeflow <- function(variables, if (isTRUE(exclude_derived) && !is.null(variable_details)) { derived <- identify_derived_vars(variables, variable_details) + + # D6 carve-out (#39, Phase A): a DerivedVar::/Func:: variable carrying a + # non-blank mockFormula is intentionally generated by + # evaluate_mock_formulas(), not excluded here. Bare DerivedVar::/Func:: + # rows (no mockFormula) keep today's exclude-and-defer-to-recodeflow + # behaviour unchanged. + has_mock_formula <- vapply(derived, function(v) { + !.is_blank(.details_mock_formula( + .filter_recodeflow_details(variable_details, v, databaseStart) + )) + }, logical(1)) + derived <- derived[!has_mock_formula] + removed <- intersect(variables$variable, derived) if (length(removed) > 0) { message( diff --git a/R/mock_spec_simstudy.R b/R/mock_spec_simstudy.R index a6b08fc..a2998b2 100644 --- a/R/mock_spec_simstudy.R +++ b/R/mock_spec_simstudy.R @@ -128,7 +128,15 @@ #' @noRd .native_only_variables <- function(spec) { - Filter(function(variable) !.simstudy_can_generate(variable), spec$variables) + # Mirror generate_mock_data_native()'s skip: derived variables (formula and + # survival) have no distribution to sample from in either backend. They are + # computed post-baseline by evaluate_mock_formulas() and + # generate_survival_dates(), not by .generate_native_variable() + # (which has no "formula" branch and would stop with an unhelpful "Native + # backend does not support variable type 'formula'"). + Filter(function(variable) { + !.is_derived_variable(variable) && !.simstudy_can_generate(variable) + }, spec$variables) } #' @noRd @@ -137,6 +145,15 @@ return(.empty_native_data(n)) } + if (n == 0) { + # simstudy::genData(0, def) builds its id table as data.table(x = 1:n), + # and 1:0 is c(1, 0) -- two rows -- so formula evaluation stops with + # "Both 'dtSim' and 'n' are set but are of different length". No draws + # happen at zero rows, so the native generators yield the identical typed + # zero-row schema (see the edge-case contract tests). Issue #50. + return(.generate_native_only_baseline(variables, n)) + } + def <- NULL for (variable in variables) { def <- .simstudy_definition(def, variable) @@ -202,15 +219,24 @@ #' `simstudy`. They remain MockData-owned post-processing so both backends share #' the same auditability contract. #' +#' Like [generate_mock_data_native()], this backend skips `type = "formula"` +#' variables — they carry no distribution to sample from. They are computed +#' post-baseline by [evaluate_mock_formulas()], which works over either +#' backend's output. +#' #' @param spec A `mock_spec` object. #' @param n Non-negative whole number of rows to generate. -#' @param seed Optional whole-number random seed. The previous R random state is -#' restored after generation. +#' @param seed Optional whole-number seed. Generation uses an isolated +#' L'Ecuyer-CMRG sub-stream and restores the caller's RNG state and kind on +#' exit, so output is reproducible for a given seed and package version +#' without perturbing the caller's RNG. #' -#' @return A data frame with one column per `mock_spec` variable and `n` rows. +#' @return A data frame with `n` rows and one column per non-formula +#' `mock_spec` variable (`type = "formula"` variables are appended +#' afterwards by [evaluate_mock_formulas()]). #' @family mock generation APIs #' @seealso [generate_mock_data_native()], [postprocess_mock_data()], -#' [mock_spec()] +#' [mock_spec()], [evaluate_mock_formulas()] #' #' @examples #' spec <- mock_continuous("age", range = c(18, 85), rtype = "integer") @@ -227,6 +253,13 @@ generate_mock_data_simstudy <- function(spec, n, seed = NULL) { simstudy_variables <- .simstudy_variables(spec) native_only_variables <- .native_only_variables(spec) + # Derived variables (formula and survival) are excluded from both `simstudy_variables` and + # `native_only_variables` above; exclude them here too so the final + # assembly doesn't index `columns` by names that were never generated (see + # generate_mock_data_native()'s identical formula skip). + generated_variable_names <- names(spec$variables)[ + !vapply(spec$variables, .is_derived_variable, logical(1)) + ] .with_mock_seed(seed, { simstudy_data <- .generate_simstudy_baseline(simstudy_variables, n) @@ -235,14 +268,14 @@ generate_mock_data_simstudy <- function(spec, n, seed = NULL) { native_data <- .generate_native_only_baseline(native_only_variables, n) columns <- c(simstudy_data, native_data) - if (length(spec$variables) == 0) { + if (length(generated_variable_names) == 0) { return(.empty_native_data(n)) } as.data.frame( - columns[names(spec$variables)], + columns[generated_variable_names], stringsAsFactors = FALSE, check.names = FALSE ) - }) + }, stage = "baseline") } diff --git a/R/mock_spec_survival.R b/R/mock_spec_survival.R new file mode 100644 index 0000000..1a9b566 --- /dev/null +++ b/R/mock_spec_survival.R @@ -0,0 +1,295 @@ +# ============================================================================== +# Metadata-driven survival dates (#40) — spec-side helpers. +# Generation lives in generate_survival_dates() (same file, added in Task 3). +# ADR: development/adr/v05-survival-dates.md +# ============================================================================== + +.survival_distributions <- c("uniform", "exponential", "gompertz") + +#' @noRd +.is_survival_variable <- function(variable) { + identical(variable$type, "survival") +} + +#' @noRd +.is_derived_variable <- function(variable) { + # Derived types are computed after baseline generation from already-generated + # columns, so both backends skip them (ADR v05-survival-dates D1, D7). + .is_formula_variable(variable) || .is_survival_variable(variable) +} + +#' @noRd +.survival_dependencies <- function(variable) { + if (!.is_survival_variable(variable)) { + return(character(0)) + } + deps <- as.character(c(variable$anchor, variable$censored_by)) + unique(deps[!is.na(deps) & nzchar(trimws(deps))]) +} + +#' @noRd +.validate_survival_variable <- function(variable) { + errors <- character(0) + label <- paste0("Survival variable '", variable$name, "'") + is_number <- function(x) { + is.numeric(x) && length(x) == 1 && !is.na(x) && is.finite(x) + } + + if (.is_blank(variable$anchor)) { + errors <- c(errors, paste0( + label, " requires an anchor (the name of its entry-date variable)." + )) + } + + followup_min <- variable$followup_min + followup_max <- variable$followup_max + if (!is_number(followup_min) || !is_number(followup_max)) { + errors <- c(errors, paste0( + label, " requires finite numeric followup_min and followup_max (days)." + )) + } else { + if (followup_min < 0) { + errors <- c(errors, paste0(label, " followup_min must be >= 0.")) + } + if (followup_min > followup_max) { + errors <- c(errors, paste0(label, " followup_min must be <= followup_max.")) + } + } + + event_prop <- variable$event_prop + if (!is_number(event_prop) || event_prop < 0 || event_prop > 1) { + errors <- c(errors, paste0(label, " event_prop must be a number in [0, 1].")) + } + + distribution <- variable$distribution %||% "uniform" + if (!distribution %in% .survival_distributions) { + errors <- c(errors, paste0( + label, " distribution must be one of: ", + paste(.survival_distributions, collapse = ", "), "." + )) + } + + for (param in c("shape", "rate")) { + value <- variable[[param]] + supplied <- !is.null(value) && !(length(value) == 1 && is.na(value)) + if (supplied && !(is_number(value) && value > 0)) { + errors <- c(errors, paste0( + label, " ", param, " must be a positive number when supplied." + )) + } + } + + if (!identical(variable$rtype, "date")) { + errors <- c(errors, paste0(label, " must have rType 'date'.")) + } + if (!identical(variable$source_format %||% "analysis", "analysis")) { + errors <- c(errors, paste0( + label, " supports sourceFormat 'analysis' only in this version." + )) + } + + errors +} + +#' @noRd +.validate_survival_referents <- function(spec) { + errors <- character(0) + for (variable in spec$variables) { + if (!.is_survival_variable(variable)) next + label <- paste0("Survival variable '", variable$name, "'") + + anchor <- variable$anchor + if (!.is_blank(anchor)) { + anchor_variable <- spec$variables[[anchor]] + if (is.null(anchor_variable)) { + errors <- c(errors, paste0( + label, " has anchor '", anchor, "', which is not a variable in the ", + "spec. Enable the entry-date variable or correct the anchor." + )) + } else if (!identical(anchor_variable$type, "date")) { + errors <- c(errors, paste0( + label, " has anchor '", anchor, "' of type '", anchor_variable$type, + "'; an anchor must be a date variable." + )) + } + } + + censor <- variable$censored_by + if (.is_blank(censor)) next + censor_variable <- spec$variables[[censor]] + if (identical(censor, variable$name)) { + errors <- c(errors, paste0(label, " cannot be censored by itself.")) + } else if (is.null(censor_variable)) { + errors <- c(errors, paste0( + label, " has censored_by '", censor, + "', which is not a variable in the spec." + )) + } else if (!.is_survival_variable(censor_variable)) { + errors <- c(errors, paste0( + label, " has censored_by '", censor, "'; censored_by '", censor, + "' must be a survival variable." + )) + } else if (!identical(censor_variable$anchor, anchor)) { + errors <- c(errors, paste0( + label, " has censored_by '", censor, + "', which must share its anchor ('", anchor, "')." + )) + } else if (!.is_blank(censor_variable$censored_by)) { + # ADR D7: with a chain (A by B, B by C) the sequential rules report A as + # observed after observation ended, so chains are rejected in v0.5. + errors <- c(errors, paste0( + label, " has censored_by '", censor, "', which is itself censored by '", + censor_variable$censored_by, "'. Chained censoring is not supported in ", + "this version; censor '", variable$name, "' by the earliest date ", + "directly, or derive observed outcomes after generation." + )) + } + } + errors +} + +#' @noRd +.order_survival_variables <- function(spec) { + survival_names <- names(spec$variables)[ + vapply(spec$variables, .is_survival_variable, logical(1)) + ] + dependencies <- lapply(spec$variables[survival_names], .survival_dependencies) + .order_dependent_variables(survival_names, dependencies, "Survival") +} + +#' @noRd +.survival_param <- function(value, default) { + if (is.null(value) || length(value) != 1 || is.na(value)) default else value +} + +#' @noRd +.survival_event_days <- function(variable, n_events) { + # Exact port of the legacy follow-up-time draws (R/create_date_var.R:305-340, + # ADR v05-survival-dates D4), including behaviour filed as #54 (Gompertz + # clamp) and #56 (exponential rate derived from the window, not `rate`). + followup_min <- variable$followup_min + followup_max <- variable$followup_max + distribution <- tolower(variable$distribution %||% "uniform") + + if (distribution == "gompertz") { + shape <- .survival_param(variable$shape, 0.1) + rate <- .survival_param(variable$rate, 0.0001) + u <- stats::runif(n_events) + days <- (1 / shape) * log(1 - (shape / rate) * log(1 - u)) + return(pmax(followup_min, pmin(followup_max, days))) + } + if (distribution == "exponential") { + rate_exp <- 1 / ((followup_max - followup_min) / 3) + days <- stats::rexp(n_events, rate = rate_exp) + followup_min + return(pmin(days, followup_max)) + } + stats::runif(n_events, min = followup_min, max = followup_max) +} + +#' @noRd +.draw_survival_dates <- function(variable, anchor_dates) { + n <- length(anchor_dates) + values <- rep(as.Date(NA), n) + if (n == 0) { + return(values) + } + # Legacy event assignment: a fixed count, shuffled (R/create_date_var.R:295). + n_events <- floor(n * variable$event_prop) + is_event <- c(rep(TRUE, n_events), rep(FALSE, n - n_events)) + is_event <- is_event[sample(n)] + if (n_events > 0) { + # floor(): legacy output is whole days because create_date_var() converts + # dates to character and back (R/create_date_var.R:479). + values[is_event] <- anchor_dates[is_event] + + floor(.survival_event_days(variable, n_events)) + } + values +} + +#' Generate survival dates from their anchor dates +#' +#' Computes each `type = "survival"` variable in `spec` from its anchor column +#' in `data`: `floor(n * event_prop)` rows receive a date a follow-up time +#' after the anchor (whole days, within `[followup_min, followup_max]`), and +#' the rest are `NA`. Variables are processed in dependency order; each is +#' drawn and then has its rules applied: a date earlier than its anchor +#' becomes `NA`, and, if `censored_by` is set, a date later than the censoring +#' date becomes `NA`. Draws use the isolated `survival` sub-stream of the seed +#' contract. +#' +#' Survival dates describe true values. [postprocess_mock_data()] applies +#' missing codes and garbage afterwards, independently for each column. +#' +#' @param data Data frame of generated baseline values (from +#' [generate_mock_data_native()] or [generate_mock_data_simstudy()]), +#' containing each anchor column as class `Date`. +#' @param spec A `mock_spec`. Strict-validated at entry. +#' @param seed Optional whole-number seed. The caller's RNG state and kind are +#' restored on exit. +#' +#' @return `data` with one appended `Date` column per survival variable. +#' Returns `data` unchanged if the spec has no survival variables. +#' @family mock generation APIs +#' @seealso [mock_spec_survival()], [evaluate_mock_formulas()], +#' [postprocess_mock_data()] +#' +#' @examples +#' spec <- mock_spec( +#' mock_spec_date("entry", range = as.Date(c("2001-01-01", "2005-12-31"))), +#' mock_spec_survival("death", anchor = "entry", +#' followup_min = 365, followup_max = 7300, event_prop = 0.2) +#' ) +#' baseline <- generate_mock_data_native(spec, n = 10, seed = 1) +#' generate_survival_dates(baseline, spec, seed = 1) +#' +#' @export +generate_survival_dates <- function(data, spec, seed = NULL) { + if (!is.data.frame(data)) { + stop("data must be a data frame.", call. = FALSE) + } + validate_mock_spec(spec, n = nrow(data), strict = TRUE) + + ordered <- .order_survival_variables(spec) + if (length(ordered) == 0) { + return(data) + } + + anchors <- unique(vapply(spec$variables[ordered], function(variable) { + variable$anchor + }, character(1))) + missing_anchors <- setdiff(anchors, names(data)) + if (length(missing_anchors) > 0) { + stop( + "data is missing anchor column(s) required by survival variables: ", + paste(missing_anchors, collapse = ", "), + call. = FALSE + ) + } + for (anchor in anchors) { + if (!inherits(data[[anchor]], "Date")) { + stop( + "Anchor column '", anchor, "' must be of class Date; got ", + class(data[[anchor]])[1], ". Convert it with as.Date() first.", + call. = FALSE + ) + } + } + + .with_mock_seed(seed, stage = "survival", { + for (name in ordered) { + variable <- spec$variables[[name]] + anchor_dates <- data[[variable$anchor]] + values <- .draw_survival_dates(variable, anchor_dates) + # Legacy rules (R/create_wide_survival_data.R:347,363), applied per + # variable in dependency order so a censoring date is already final. + values[!is.na(values) & !is.na(anchor_dates) & values < anchor_dates] <- + as.Date(NA) + if (!.is_blank(variable$censored_by)) { + censor <- data[[variable$censored_by]] + values[!is.na(values) & !is.na(censor) & censor < values] <- as.Date(NA) + } + data[[name]] <- values + } + data + }) +} diff --git a/README.md b/README.md index f71d571..0170a9e 100644 --- a/README.md +++ b/README.md @@ -3,19 +3,21 @@ [![Lifecycle: experimental](https://img.shields.io/badge/lifecycle-experimental-orange.svg)](https://lifecycle.r-lib.org/articles/stages.html#experimental) -[![Version: 0.4.0](https://img.shields.io/badge/version-0.4.0-blue.svg)](https://github.com/Big-Life-Lab/MockData) +[![Version: 0.5.0](https://img.shields.io/badge/version-0.5.0-blue.svg)](https://github.com/Big-Life-Lab/MockData) [![pkgdown](https://github.com/Big-Life-Lab/MockData/actions/workflows/pkgdown.yaml/badge.svg)](https://github.com/Big-Life-Lab/MockData/actions/workflows/pkgdown.yaml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -**Status: Experimental v0.4.0 release** +**Status: Experimental v0.5.0 release** MockData is a work-in-progress R package for generating mock testing data from -small metadata specifications. Version 0.4 introduces the +small metadata specifications. Version 0.4 introduced the `mock_spec` architecture: direct specification helpers, a recodeflow metadata adapter, native generation, optional `simstudy` generation, and post-processing -diagnostics. It is useful today for development and documentation workflows, +diagnostics. Version 0.5 adds formula-derived variables (`mockFormula`), +survival dates generated from metadata (`anchor`, `censored_by`), and a +per-stage seed contract. It is useful today for development and documentation workflows, especially when paired with recodeflow-style metadata (see below), but it should be treated as experimental infrastructure rather than a stable released package. @@ -23,14 +25,14 @@ People are using MockData and reporting that it is helpful. We take that as an encouraging signal, not as evidence that the package is mature. Please review the generated data before using it in any workflow that matters. -v0.4 keeps the existing v0.3 public functions available. Several -larger ideas were deliberately deferred rather than rushed into this release: -formula-derived variables, multi-variable correlation, Table 1 bootstrap, -LinkML/schema-first integration, and advanced survival simulation. MockData +v0.5 keeps the existing v0.3 public functions available. Several +larger ideas remain deliberately deferred rather than rushed into a release: +`Func::` dispatch for derived variables, multi-variable correlation, Table 1 +bootstrap, LinkML/schema-first integration, and advanced survival simulation. MockData also remains framed as mock data for development and QA, not synthetic data for privacy release or inference. -Please try v0.4 with representative `variables.csv` and +Please try v0.5 with representative `variables.csv` and `variable_details.csv` files and report bugs, surprising output, confusing diagnostics, or migration issues in [GitHub Issues](https://github.com/Big-Life-Lab/MockData/issues). Broader design @@ -43,7 +45,7 @@ feedback is welcome in - Use inline metadata for small examples, without creating separate CSV files - Create categorical, continuous, and date-like fields from simple specifications - Add intentional invalid or out-of-range "garbage" values for QA testing -- Generate basic time-to-event style mock dates for survival-analysis workflows +- Generate survival dates (entry, events, competing risks, censoring) from metadata - Support examples and tutorials without exposing real person-level data **Current development limitations:** @@ -191,16 +193,18 @@ mock_data <- create_mock_data( seed = 123 ) -head(mock_data) -#> age smoking interview_date -#> 1 42 2 2001-08-05 -#> 2 47 2 2002-11-27 -#> 3 73 3 2002-11-14 -#> 4 51 1 2005-01-14 -#> 5 52 1 2001-02-25 -#> 6 76 3 2004-07-25 +head(mock_data[, c("age", "smoking", "BMI", "interview_date", "death_date")]) +#> age smoking BMI interview_date death_date +#> 1 35 3 996.00000 2004-10-20 +#> 2 999 1 31.52719 2004-01-11 +#> 3 72 1 21.02576 2002-01-21 +#> 4 997 2 28.36755 2004-06-25 +#> 5 55 1 29.52888 2001-02-22 +#> 6 998 3 25.80269 2004-01-12 2005-01-11 ``` +The minimal example generates all of its variables; this shows five of them. Values such as 996 and 999 are missing codes declared by the metadata's `NA::` rows. + **What's in those CSV files?** See [inst/extdata/minimal-example/](inst/extdata/minimal-example/README.md) - just variable names, types, ranges, and optional mock-data parameters. **When should you use metadata files?** diff --git a/_pkgdown.yml b/_pkgdown.yml index b3169e3..faa3a30 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -22,9 +22,12 @@ reference: - mock_continuous - mock_categorical - mock_date + - mock_formula - mock_spec_continuous - mock_spec_categorical - mock_spec_date + - mock_spec_formula + - mock_spec_survival - is_mock_spec - validate_mock_spec - mock_spec_model_hints @@ -37,6 +40,8 @@ reference: - mock_spec_from_recodeflow - generate_mock_data_native - generate_mock_data_simstudy + - generate_survival_dates + - evaluate_mock_formulas - postprocess_mock_data - title: Main generation functions @@ -113,6 +118,7 @@ articles: navbar: How-to guides contents: - recodeflow-metadata-v04 + - survival-dates-v05 - diagnostics-and-garbage-v04 - migrating-from-v03-v04 - choosing-a-backend-v04 @@ -123,6 +129,7 @@ articles: navbar: Explanation contents: - design-philosophy-v04 + - survival-design-v05 - advanced-topics - title: Reference diff --git a/cran-comments.md b/cran-comments.md new file mode 100644 index 0000000..778e2d1 --- /dev/null +++ b/cran-comments.md @@ -0,0 +1,42 @@ +# CRAN Comments + +## Local check + +Checked with: + +- R 4.4.2 +- macOS 27.0, aarch64-apple-darwin20 +- `R CMD check --as-cran --no-manual` on the built tarball +- `_R_CHECK_FORCE_SUGGESTS_=false`, because the local check library does not + include the suggested package `simstudy` + +Result: + +- 0 errors +- 0 warnings +- 3 notes + +Notes: + +- New submission. +- `simstudy` (suggested) not available for checking locally. It is installed + in continuous integration, where its tests run. +- The local check reported `unable to verify current time`. + +## Continuous integration + +The `R-CMD-check` workflow runs the same check on Ubuntu 24.04 with +R 4.6.1, with all suggested packages installed, for every push to +`main`/`dev` and every pull request into them. Latest result: `Status: OK` +(0 errors, 0 warnings, 0 notes). + +## Release notes + +This is the first CRAN submission of MockData. The package generates mock +testing data from metadata specifications supplied as data frames or CSV files. +It supports categorical, continuous, date, garbage-data, formula-derived, and +survival-style variables for testing data pipelines and documentation examples. + +MockData generates mock data for software testing and workflow validation. It +does not generate privacy-preserving synthetic data for inference or data +release. diff --git a/development/adr/v05-formula-evaluator.md b/development/adr/v05-formula-evaluator.md new file mode 100644 index 0000000..674861e --- /dev/null +++ b/development/adr/v05-formula-evaluator.md @@ -0,0 +1,259 @@ +# ADR: v0.5 Formula-Derived Variables + +**Status**: ACCEPTED 2026-07-07 — Phase A (algebraic formulas), via a `mockFormula` extension column on `variable_details`, evaluated as a post-baseline stage in a restricted sandbox. Any individual decision remains open to maintainer revision before implementation begins. Implemented on branch v05-formula-evaluator (Phase A). Allow-list amended 2026-09-24 by `v05-survival-dates.md` D8 (adds `is.na`). +**Date**: 2026-07-07 +**Decision owner**: MockData maintainers +**Issue**: #39 +**Depends on**: #38 (seed contract — reserves the `formula` sub-stream index) + +## Decision summary + +| # | Question | Decision | +|---|---|---| +| D1 | Formula syntax entry | **(a)** New `mockFormula` MockData-extension column; `DerivedVar::`/`Func::` semantics untouched | +| D2 | Evaluation model | **(a)** Post-baseline stage over generated data — **no new seeded-output break** (#39 rides #38's) | +| D3 | Sandbox | Restricted-parent env: generated columns + ratified base-function allow-list; validated before generation | +| D4 | Scope of #39 | **Phase A** (algebraic) only; `Func::` dispatch (Phase B) deferred to a separate future workstream | +| D5 | Diagnostics | Formula variables marked `derived`/`source = "formula"` + dependency list in `mockdata_diagnostics` | +| D6 | `identify_derived_vars()` | `mockFormula` present ⇒ generated; bare `DerivedVar::`/`Func::` ⇒ still excluded (backward compatible) | +| Column placement | Where `mockFormula` lives | `variable_details` (row-level, beside `recStart`/`recEnd`) | + +Key property of D2(a): because formula variables depend on generated *values* +(which exist regardless of generation order), the evaluator needs no whole-spec +reorder — random variables keep their draw order, so #39 adds no seeded-output +break beyond #38's. It co-releases with #38 in v0.5 but does not spend a second +break. + +## Context + +Derived variables are the reason the recodeflow ecosystem exists: cchsflow and +chmsflow are, in large part, libraries of variables *derived* from raw survey +items (BMI from height/weight, risk scores from many inputs). MockData today +**detects and skips** them — `identify_derived_vars()` (`R/identify_derived_vars.R`) +finds `DerivedVar::` (in `recStart`) and `Func::` (in `recEnd`) patterns and +excludes those variables from generation, leaving derivation to the downstream +pipeline. So mock data cannot exercise the derivation logic it exists to test: +a user testing a cchsflow recode gets mock raw inputs but no mock derived +outputs, and must derive them by hand. + +The v0.4 spike (merged in PR #27, later removed from the tree, recoverable at +`git show f4f9b41:development/v04-simstudy-spike/prototype.R`) already prototyped +the hard parts: + +- `formula_dependencies(var)` — extracts referents via `all.vars(str2lang(var$formula))`. +- `order_spec_by_dependencies(spec)` — topological sort with cycle detection + ("Formula dependency cycle or unresolved ordering among: …"). +- `validate_formula_referents(spec)` — errors on formulas referencing unknown + variables. +- Evaluation: `eval(str2expression(var$formula), envir = data, enclos = parent.frame())`. + +Two spike details are load-bearing for this ADR. First, the spike **reorders the +whole spec** by dependency before generating — which would change the order in +which *random* variables consume the RNG stream, shifting seeded output. Second, +its eval uses `enclos = parent.frame()` — which is **not a sandbox**: a formula +could reach any binding up the call stack. Both must be reconsidered here. + +The `mock_spec` variable constructor already carries unused `formula` and +`depends_on` fields (`R/mock_spec.R:101,106`), reserved for exactly this. + +### Constraints + +- **Downstream semantics.** cchsflow/chmsflow (CRAN) consume `DerivedVar::`/`Func::` + from the same metadata. MockData must not *redefine* what those mean. A + MockData-extension column is the safe channel (same rule as the seed and + garbage extensions). +- **Seed contract (#38).** The seed ADR reserves a `formula` sub-stream (index 2). + How formula evaluation relates to baseline generation determines whether #39 + adds a *second* seeded-output break or rides #38's single break — a decision + below, not an assumption. +- **Dependency ethos.** Evaluation must stay in base R (`eval`/`str2lang`); no new + dependency. Any function dispatch (Phase B) must not force the consuming + package to be attached for the native path to work. +- **Scope boundary.** MockData generates *plausible test data*, not validated + synthetic data. A derived variable computed from mock inputs is only as + meaningful as those inputs — this is a testing aid, restated in docs. + +## Decision points + +Recommendations given, not enacted. No code until accepted. + +### D1 — How does a formula enter the metadata? + +- **(a) New MockData-extension column `mockFormula`** on `variable_details` (or + `variables`). A variable with a non-empty `mockFormula` is generated by + evaluating that expression over already-generated columns. `DerivedVar::`/`Func::` + keep their current recodeflow meaning untouched. +- **(b) Reuse `DerivedVar::`/`Func::`.** Infer the formula from the existing + recodeflow notation. + +**Recommendation: (a).** Reusing (b) would make MockData reinterpret recodeflow's +derived-variable notation as an executable generation formula — a semantic change +to a schema two CRAN packages depend on. A dedicated `mockFormula` column is +additive, self-documenting, ignored by other recodeflow tools, and lets a +variable be *both* a real `DerivedVar::` downstream *and* mock-generated here. +See D6 for how this flips a variable from "excluded" to "generated". + +### D2 — Evaluation model: post-baseline stage, or whole-spec reorder? + +- **(a) Post-baseline stage.** Generate all random (non-formula) variables first, + in their existing order, then evaluate formula variables as a distinct stage + over the assembled data frame — using #38's reserved `formula` sub-stream for + any randomness *inside* a formula. Formula variables are topologically ordered + *among themselves* (a formula may reference another formula's output). +- **(b) Whole-spec topological reorder** (the spike's approach): order every + variable by dependency and generate in that order. + +**Recommendation: (a).** It has a decisive property: **it adds no new +seeded-output break.** Random variables keep their generation order, so their +draws are unchanged; formula columns are appended deterministically (or draw from +the isolated `formula` sub-stream if a formula includes noise). #39 therefore +*rides* #38's single v0.5 break rather than adding a second one. (b) would +reshuffle random draws and force another break, for no functional gain — +formulas depend on generated values, which exist regardless of generation order. +Cycle detection and referent validation from the spike carry over, scoped to the +formula set. + +### D3 — Evaluation sandbox + +The spike's `enclos = parent.frame()` is unsafe. For a testing tool run on the +user's own machine the threat model is modest (accidental reference to a stray +global, a typo resolving to some function), not adversarial — but the evaluator +should still be *predictable and self-contained*. + +- **Environment:** evaluate in an environment whose only variables are the + generated columns, with `enclos` set to a **restricted parent** exposing an + explicit allow-list of base operators/functions, not `parent.frame()` and not + the global environment. +- **Allow-list (to ratify/amend):** arithmetic and comparison operators + `+ - * / ^ %% %/% ( ) < <= > >= == != & | !`; `ifelse`, `pmin`, `pmax`, `min`, + `max`, `abs`, `round`, `floor`, `ceiling`, `sqrt`, `exp`, `log`, `log10`, + `sum`, `mean`, `cut`, `as.numeric`, `as.integer`, `as.factor`, `factor`. A + formula referencing anything outside the generated columns + this list errors + at validation time (extend `validate_formula_referents()` to check function + symbols, not just variable referents). +- **No** filesystem, network, `system`, `eval`/`parse` re-entry, or `<<-`. + +**Recommendation:** restricted-parent environment + ratified allow-list, validated +up front so a bad formula fails with a clear message before any generation. + +### D4 — Scope of #39: Phase A only, or A + B? + +- **Phase A — algebraic formulas** over generated columns (`mockFormula = + "weight / (height^2)"`). Fully covered by the spike's eval; bounded by the D3 + sandbox. +- **Phase B — `Func::` dispatch** to a derivation function in the consuming + package's namespace: generate the raw inputs, then *call the real derivation + function* on them. Powerful (exercises actual cchsflow logic) but raises the + hardest questions: namespace lookup order, what happens when the package isn't + loaded, and it steps outside the D3 allow-list by design. + +**Recommendation: Phase A in #39; Phase B as a separate future workstream (own +issue, own ADR).** Phase A delivers the core value (mock derived variables for +pipeline tests) at low risk and no new dependency. Phase B's coupling and +sandbox questions deserve their own decision and shouldn't hold up the v0.5 +co-release with #38. Note the deferral in `NEWS`/docs so users know `Func::` +generation is not yet supported (those variables stay excluded — D6). + +### D5 — Diagnostics + +Formula variables should be distinguishable in the `mockdata_diagnostics` +attribute: a per-variable entry marking `derived = TRUE` (or a `source = +"formula"` field) plus the resolved dependency list. This lets downstream checks +tell generated-from-formula columns from directly-sampled ones. + +**Recommendation:** add `derived`/`formula`/`depends_on` to each formula +variable's diagnostics entry, consistent with the existing diagnostics shape. + +### D6 — Interaction with `identify_derived_vars()` + +Today every `DerivedVar::`/`Func::` variable is excluded from generation. Under +D1(a): + +- A variable with a non-empty `mockFormula` is **generated** by the evaluator — + it moves from "excluded" to "generated", even if it also carries `DerivedVar::` + (its downstream derivation notation is untouched). +- A derived variable **without** `mockFormula` stays **excluded** exactly as today + (external derivation), including all `Func::` variables until Phase B exists. + +**Recommendation:** ratify this rule — `mockFormula` is the explicit opt-in for +"MockData, generate this derived variable yourself." Backward compatible: +metadata with no `mockFormula` behaves identically to today. + +## Guarantee / scope statement (to ratify) + +> A variable with a `mockFormula` is generated by evaluating that expression, in +> dependency order among formula variables, over the generated data, in a +> restricted environment exposing only the generated columns and a fixed +> allow-list of base functions. Formula evaluation is deterministic given its +> inputs; any randomness inside a formula draws from the isolated `formula` +> sub-stream (#38) and does not perturb baseline variables. A derived value is +> only as meaningful as the mock inputs it is computed from — this is a testing +> aid, not validated synthetic data. + +## Consequences + +**If accepted as recommended (D1a, D2a, D3 restricted, D4 Phase A, D5, D6):** + +- Recover the spike's `formula_dependencies` / cycle-detection / referent + validation into `R/` (scoped to the formula set), add the restricted-env + evaluator, and run it as a post-baseline stage keyed to #38's `formula` + sub-stream. +- Add the `mockFormula` extension column to the recodeflow adapter and the + direct API (populate the existing `formula`/`depends_on` spec fields). +- **No new seeded-output break** beyond #38's — #39 rides the v0.5 break. +- `identify_derived_vars()` gains a `mockFormula`-aware carve-out (generate vs + exclude). +- `Func::` generation remains unsupported (documented), pending Phase B. + +**Phase B caveat:** allow-listed base functions can reach R internal-generic S3 +dispatch (e.g. `as.factor` → `as.character` generic), which resolves methods in +the caller's globalenv. This is not exploitable in Phase A (formulas cannot +assign a class to a value — no `structure`/`class<-`/`oldClass` in the +allow-list — and MockData only feeds numeric/factor/character/Date columns to +eval). If Phase B introduces `Func::` dispatch or class-producing operations, +this dispatch path must be re-audited. + +**Costs / risks:** + +- The sandbox allow-list is a maintained surface; too narrow frustrates users, + too wide erodes the "predictable/self-contained" property. Start narrow (D3), + widen on concrete request. +- Formula validation must run *before* generation so errors are early and clear + (referent + function-symbol checks), not a mid-generation `eval` failure. + +## Test strategy + +New `tests/testthat/test-formula-vars.R`: +1. Algebraic derivation correctness (e.g. BMI = weight / height^2 matches a + hand computation over the generated columns). +2. Formula-of-a-formula (dependency ordering among formula variables). +3. Cycle detection errors clearly; unknown-referent errors clearly; a + disallowed function symbol errors at validation, before generation. +4. Seed behaviour: a deterministic formula adds no change to baseline variables' + seeded output (pin a baseline variable's values with and without a formula + variable present); a noisy formula is reproducible via the `formula` + sub-stream and does not perturb baseline. +5. Diagnostics: formula variables marked derived with their dependency list. +6. `identify_derived_vars()` carve-out: `mockFormula` → generated; bare + `DerivedVar::`/`Func::` → still excluded. + +## Migration + +- Implementation is a separate ADR-gated plan + (`development/plans/YYYY-MM-DD-formula-evaluator.md`) written after acceptance. +- **Co-releases with #38 in v0.5.** Additive feature (new column, new + capability); adds no new break under D2(a). One combined NEWS entry for the + v0.5 line. +- Downstream heads-up: `mockFormula` is a new MockData-extension column; + cchsflow/chmsflow metadata without it is unaffected. + +## Resolved / carried to implementation + +1. D1 (a), D2 (a), D4 Phase A — see Decision summary. ✅ +2. D3 allow-list — ratified as the starting set; widen on concrete request during + implementation. ✅ +3. Column `mockFormula` on `variable_details` (row-level). ✅ +4. **Carried to the implementation plan:** exact diagnostics field name + (`derived` flag vs `source = "formula"`); whether the direct API exposes a + `mock_formula()` constructor or just the `formula =` argument on existing + constructors. Low-stakes; decide when writing the plan. diff --git a/development/adr/v05-seed-contract.md b/development/adr/v05-seed-contract.md new file mode 100644 index 0000000..b8e8bc3 --- /dev/null +++ b/development/adr/v05-seed-contract.md @@ -0,0 +1,266 @@ +# ADR: v0.5 Seed and RNG-Stream Contract + +**Status**: ACCEPTED 2026-07-06 — full-bundle contract, co-released with #39 in v0.5. Implemented per `development/plans/2026-07-06-seed-contract.md` and merged to `dev` via PR #48 (2026-07-16). Follow-up: #47 (standalone generators). +**Date**: drafted 2026-07-03; accepted 2026-07-06 +**Decision owner**: MockData maintainers +**Issue**: #38 (fulfils #22; resolves #33 item 11) + +## Decision summary + +| # | Question | Decision | +|---|---|---| +| D1 | Stream-derivation scheme | **(b)** L'Ecuyer-CMRG stage sub-streams via `parallel::nextRNGStream()` | +| D2 | Isolation across paths | **(a)** Unify all three paths on save/restore (no path perturbs the caller's RNG) | +| D3 | Guarantee scope | Ratified as stated: same seed + spec + **version** ⇒ identical output; not across minor versions | +| D4 | Fallback stream-switch announcement | **(a)** Always-on, once-per-call message | +| Timing | Release | **Co-release with #39** in v0.5 — one break, one NEWS note (maintainer-selected) | + +Rationale for the bundle: #39 (formula evaluator) reorders draws and so forces a +seeded-output break in v0.5 independent of this ADR. Given that break is already +budgeted and being spent, adopting the full principled contract in the same +release is near-zero marginal cost, and it means #42 (correlations) later needs +**no** further break. The minimal alternative would spend the #39 break without +buying the contract, then require a second break for #42. + +## Context + +MockData accepts a single public `seed` argument and promises reproducible mock +data. Today that promise is kept by three different mechanisms that do not agree +on either *how the seed is consumed* or *whether generation perturbs the +caller's random state*. Before v0.5 adds features that necessarily change seeded +output — the formula evaluator (#39) reorders draws by dependency, correlated +variables (#42) add a third RNG consumer — the contract must be defined once, so +those features land as a single, well-documented reproducibility break rather +than a series of silent ones. + +### The three regimes today + +Verified against `dev` at merge commit `6099176` (2026-07-03). + +**1. v0.4 native path** — `.create_mock_data_v04()` (`R/create_mock_data.R:98-103`): + +```r +baseline <- generate_mock_data_native(spec, n = n, seed = seed) +postprocess_seed <- if (is.null(seed)) NULL else seed + 1L +postprocess_mock_data(baseline, spec, seed = postprocess_seed) +``` + +Both stages run inside `.with_mock_seed()` (`R/mock_spec_native.R:8-34`), which +**saves `.Random.seed`, calls `set.seed()`, and restores the caller's +`.Random.seed` on exit** — generation is reproducible *and* non-invasive to the +user's RNG stream. Baseline uses `seed`; post-processing uses `seed + 1L`. The +`seed + 1L` split is explicitly a pragmatic choice (its own code comment calls it +that), giving baseline and post-processing "independent" streams by using +adjacent Mersenne-Twister seeds. + +**2. Legacy path** — `create_mock_data()` main body (`R/create_mock_data.R:349-352`): + +```r +if (!is.null(seed)) { + if (verbose) message("Setting random seed: ", seed) + set.seed(seed) +} +``` + +A bare `set.seed(seed)` with **no save/restore** — this *clobbers the caller's +global RNG stream*. Generators are then invoked with `seed = NULL` +(`R/create_mock_data.R:424`, "Global seed already set") and draw sequentially +from that one stream. The legacy per-variable helpers +(`sample_with_proportions()`, `make_garbage()`, `apply_garbage()` in +`R/mockdata_helpers.R`) each *can* self-seed, but on this path receive `NULL`. + +**3. simstudy backend** — `generate_mock_data_simstudy()` +(`R/mock_spec_simstudy.R:223-231`): uses `.with_mock_seed(seed, ...)` — same +save/restore as the native path, but a single `seed` (no `+ 1L` split). + +### What actually differs (and why it matters) + +Two independent axes are conflated in the current design: + +| Axis | Native (v0.4) | simstudy | Legacy | +|---|---|---|---| +| **Isolation** — does generation perturb the caller's RNG? | No (save/restore) | No (save/restore) | **Yes (clobbers)** | +| **Stream derivation** — how sub-stages get distinct streams | `seed`, `seed+1L` | single `seed` | one shared stream, sequential draws | +| **RNG kind** | ambient `RNGkind()` | ambient `RNGkind()` | ambient `RNGkind()` | + +Three consequences: + +1. **Isolation is inconsistent.** `create_mock_data(..., validate = TRUE)` leaves + the user's `.Random.seed` untouched; `create_mock_data(..., validate = FALSE)` + silently resets it. A user who seeds their own analysis, calls MockData on the + legacy path, then draws more random numbers gets *different* downstream results + than if they had used the v0.4 path — a surprising, undocumented side effect. + +2. **Stream derivation is unprincipled.** `seed` and `seed + 1L` produce + well-separated Mersenne-Twister states in practice (R's seeding scrambles the + integer through a linear congruential step), so overlap is not a *practical* + risk today. But there is no theoretical guarantee, and the scheme does not + generalize: #39 and #42 will need a third and fourth stream, and "`seed + 2L`, + `seed + 3L`" is not a contract anyone should have to reason about. + +3. **RNG kind is unpinned.** All paths call `set.seed(seed)` under whatever + `RNGkind()` the session happens to have. A user who has set a non-default + generator gets different mock data for the same `seed` — reproducibility is + silently conditional on ambient session state the contract never mentions. + +### Constraints + +- **One break, budgeted.** The post-v0.4.0 plan allocates exactly one + seeded-output break for v0.5, covered by one NEWS migration note. This ADR's + implementation must land in the same release as #39 (formula evaluator), which + independently reorders draws — see Migration. +- **Dependency ethos.** MockData is MIT and `Imports: stats` only. `parallel` is + a base R package (present in every R installation, no new external + dependency), so it is available for RNG-stream work at zero dependency cost. + A non-base dependency such as `withr` would need explicit justification. +- **Downstream.** cchsflow/chmsflow (CRAN) consume the recodeflow adapter, not + the RNG internals, so this change does not touch shared metadata semantics — + but their test suites may pin MockData output, so the break must be announced. +- **R floor** ≥ 4.2.0; `parallel::nextRNGStream()` and `"L'Ecuyer-CMRG"` have + been available far longer, so no floor concern. + +## Decision points + +Each is a decision the maintainer must make. Recommendations are given but not +enacted — no code is written until this ADR is accepted. + +### D1 — Stream-derivation scheme + +How do distinct generation stages (baseline, post-processing, and future formula +/ correlation stages) get non-overlapping, reproducible streams from one public +`seed`? + +- **(a) Document the status quo.** Keep `seed` / `seed + 1L`; write down that + adjacent seeds are treated as independent. Cheapest; no output break for the + native path. But it does not generalize to more stages and never becomes + *principled*. +- **(b) L'Ecuyer-CMRG sub-streams via `parallel::nextRNGStream()`.** Seed once + from the public `seed` under `RNGkind("L'Ecuyer-CMRG")`, then advance one + guaranteed-independent sub-stream per stage. Theoretically sound, base-R only, + extends to any number of stages. Cost: changes the RNG kind, so **all seeded + output changes** (this is the budgeted break). +- **(c) Hash-derived per-stage seeds.** Derive each stage's integer seed by + hashing `(seed, stage_name)`. Generalizes and reads clearly, but still relies + on Mersenne-Twister seed-scrambling for independence (same theoretical gap as + (a), just tidier) and adds a hashing helper. + +**Recommendation: (b), at stage granularity.** It is the only option that makes +"independent streams" a guarantee rather than an empirical accident, costs no new +dependency, and scales to the #39/#40/#42 stages without inventing new arithmetic +each time. Per-*variable* sub-streams are explicitly **out of scope** (YAGNI +until parallel generation exists); stages, not variables, are the unit. + +### D2 — Isolation: unify the three paths + +Should all paths adopt the native path's save/restore behaviour, so no +`create_mock_data()` call ever perturbs the caller's RNG stream? + +- **(a) Unify on save/restore.** Route the legacy path's seeding through the same + wrapper as native/simstudy. Removes the surprising side effect; makes isolation + a uniform guarantee. Changes legacy-path seeded output (already inside the + budgeted break) and touches a stable code path. +- **(b) Freeze legacy as-is.** Document that the legacy (`validate = FALSE`) path + clobbers the RNG stream and leave it; only the v0.4 path gets the new contract. + Smaller blast radius, but the contract then has an asterisk and the legacy path + keeps a genuine footgun. + +**Recommendation: (a).** Isolation should be a property of *MockData*, not of +which internal path a call happens to take. Extend `.with_mock_seed()` to also +save/restore `RNGkind` (needed for D1 (b) regardless), set L'Ecuyer-CMRG within +scope, and have every path — native, simstudy, and legacy — generate inside it. +This unifies all three regimes under one mechanism and confines the non-default +RNG kind to the generation scope so the user's session generator is untouched. + +### D3 — Guarantee scope (what `seed` promises) + +**Recommendation** (a statement to ratify, not options): + +> Given identical `seed`, identical resolved `mock_spec`, and identical MockData +> **package version**, `create_mock_data()` returns identical output — regardless +> of backend path, ambient `RNGkind()`, or the caller's prior RNG state, and +> without altering that prior state. Reproducibility is **not** guaranteed across +> MockData minor versions; each break is announced in NEWS. Nothing about the +> *statistical* properties of the values (independence across variables, +> distributional fidelity) is promised — see the design-philosophy scope +> boundary. + +Pinning RNG kind inside the generation scope (D1 (b) / D2 (a)) is what makes the +"regardless of ambient `RNGkind()`" clause true. + +### D4 — Fallback stream-switch announcement (resolves #33 item 11) + +When the v0.4 path falls back to the legacy engine for an unsupported feature, it +switches RNG regimes for the same `seed` (different output), today announced only +under `verbose = TRUE`. Under the new contract the two regimes differ by RNG kind +as well, widening the gap. + +- **(a) Always-on, once-per-call message** when a fallback switches streams. +- **(b) Keep it `verbose`-gated.** + +**Recommendation: (a).** A silent change in what `seed` produces is exactly the +class of surprise this ADR exists to remove. One `message()` per call (not per +variable) is proportionate. + +## Consequences + +**If accepted as recommended (D1(b), D2(a), D3, D4(a)):** + +- One seeded-output break for every path, in v0.5, co-released with #39. +- `.with_mock_seed()` becomes the single seeding primitive: save/restore + `.Random.seed` **and** `RNGkind`, set L'Ecuyer-CMRG, expose a per-stage + sub-stream helper. Native, simstudy, and legacy paths all route through it. +- The `seed + 1L` arithmetic in `.create_mock_data_v04()` is replaced by named + stage sub-streams (`"baseline"`, `"postprocess"`, later `"formula"`, + `"correlate"`). +- `create_mock_data()` no longer perturbs the caller's RNG on any path. +- Reproducibility becomes independent of the user's ambient `RNGkind()`. + +**Costs / risks:** + +- Every existing test that pins a specific generated value breaks once and must + be re-baselined in the same PR. This is the budgeted break, but it is real work + and must be done deliberately (regenerate expected values, eyeball a sample for + sanity, not blind-accept). +- Touching the stable legacy path (D2 (a)) carries regression risk; mitigated by + the characterization tests merged in #35 and the new pinned-value suite below. + +## Test strategy (fulfils #22) + +A new `tests/testthat/test-seed-contract.R` pinning the ratified contract: + +1. **Determinism** — same `seed` + same spec ⇒ `identical()` output, across the + native path, the simstudy path (skip if `simstudy` absent), and the legacy + (`validate = FALSE`) path. +2. **Isolation** — capture `.Random.seed` before and after a seeded + `create_mock_data()` call on *every* path; assert unchanged. This is the + regression pin for D2. +3. **RNG-kind independence** — same `seed` under two different ambient + `RNGkind()` settings ⇒ `identical()` output (pins D1(b)/D3). +4. **Stage independence** — baseline and post-processing draws are not trivially + correlated (e.g. differ where a naïve shared stream would coincide). +5. **Pinned values** — a small `expect_equal` against committed reference output + for one canonical spec, so the *next* accidental break is caught loudly. These + are the values re-baselined when this ADR's implementation lands. + +## Migration + +- Implementation is written only after this ADR is accepted, as its own plan + (`development/plans/YYYY-MM-DD-seed-contract.md`) per the post-v0.4.0 plan's + ADR-gate rule. +- **Co-release with #39.** The formula evaluator reorders draws by dependency and + so changes seeded output on its own; batching both into one v0.5 release means + one NEWS migration note, not two. #40 (survival pairs) may ride the same release + (no independent seed impact). #42 (correlations) must not begin until this lands + — it is the fourth RNG consumer and needs the stage-substream mechanism. +- **NEWS migration note** states plainly: seeded output changes once in v0.5; + same-version reproducibility is unaffected; pin your own expected values against + the version you use. Precedent: the v0.3→v0.4 divergence already documented in + NEWS. + +## Resolved / carried to implementation + +1. D1 (b), D2 (a), D3 (ratified), D4 (a) — see Decision summary. ✅ +2. Co-release of #38 + #39 in v0.5 — confirmed. ✅ +3. **Carried to the implementation plan:** where the pinned reference values (test + strategy item 5) live — inline in the test file vs. a committed fixture under + `tests/testthat/_snaps/` or `inst/`. Low-stakes; decide when writing the plan. diff --git a/development/adr/v05-survival-dates.md b/development/adr/v05-survival-dates.md new file mode 100644 index 0000000..c5a2c6e --- /dev/null +++ b/development/adr/v05-survival-dates.md @@ -0,0 +1,229 @@ +# ADR: v0.5 Metadata-Driven Survival Dates + +**Status**: ACCEPTED 2026-09-24. Design agreed with the maintainer in conversation on 2026-09-22 (scope, fidelity policy, architecture, metadata convention, semantics); D5 and D8 ratified on 2026-09-24. Amended the same day after an external design review (Astra): D8's example corrected to derive status and time from one observation window, chained censoring rejected (D7), the three data layers named (D5), and the fixed stage order and event allocation stated as v0.5 restrictions (D4, D6, Future directions). Implemented on branch v05-survival-dates per development/plans/2026-09-24-survival-dates.md. +**Date**: 2026-09-22 +**Decision owner**: MockData maintainers +**Issue**: #40 (also advances #23, #17) +**Depends on**: #38 (seed contract), #39 (formula stage; dependency-ordering machinery reused here) + +## Decision summary + +| # | Question | Decision | +|---|---|---| +| D1 | Architecture | Survival dates are a new variable type computed in a **post-baseline stage** from their already-generated anchor date. One column per spec variable; the assembly contract is unchanged | +| D2 | Scope | **Full parity** with `create_wide_survival_data()`: entry, event, death, loss to follow-up, administrative censoring, and both consistency rules | +| D3 | Metadata convention | Two columns on `variables.csv` beside `followup_min`/`followup_max`/`event_prop`: `anchor` (required) and `censored_by` (optional) | +| D4 | Fidelity | **Exact port** of the legacy statistics and rules; concerns are filed as issues, not fixed in #40 | +| D5 | Contamination order | Consistency rules run on clean dates; missing codes and garbage are applied afterwards by postprocess | +| D6 | Stage and seed | Pipeline becomes baseline, survival, formulas, postprocess. `.MOCK_STAGES` gains `survival = 4L` (appended; no existing index moves) | +| D7 | Dependencies | Survival dates record `depends_on = c(anchor, censored_by)`, the field formulas already use; one ordering utility serves every derived stage | +| D8 | Formula allow-list | Add `is.na`, so survival status and time can be derived with `mockFormula` (amends the #39 ADR's D3) | +| D9 | Legacy path | `create_wide_survival_data()` warns once as deprecated and is kept as the characterization reference | +| D10 | Legacy generator and `anchor` | If the legacy generator runs on metadata carrying `anchor`, `create_mock_data()` stops and names the survival variables instead of dropping them | + +## Context + +Survival data has been generated outside `create_mock_data()` since v0.2.0. The NEWS known-issue entry reads: "Cannot be used in `create_mock_data()` batch generation (requires paired variables)". Users call `create_wide_survival_data()` by hand. + +The legacy engine is a loop. It generates the entry date as a plain calendar date. It then generates each other date by calling `create_date_var()` with the entry date passed in a `df_mock` column named `anchor_date` (`R/create_wide_survival_data.R`, step 1). Finally it applies two rules: any date earlier than entry becomes `NA` (`:347`), and an event date later than the death date becomes `NA` (`:363`). The per-date statistics live in `create_date_var()` (`R/create_date_var.R:209-350`): a date is a survival date when `followup_min`, `followup_max` and `event_prop` are all present; `floor(n_valid * event_prop)` rows receive an event, shuffled; follow-up times are uniform, exponential or Gompertz within the window; non-events are `NA`. + +The v0.4 pipeline handles these dates incorrectly today, in both of its paths (verified 2026-09-22 on the minimal example): + +- A Gompertz or exponential survival date fails the native support check (`R/create_mock_data.R:36`), and the fallback is all-or-nothing for the spec. The legacy dispatcher then calls `create_date_var()` without an `anchor_date` column, so every survival date in the spec, uniform ones included, warns and is dropped. For the minimal example, `create_mock_data()` returns `interview_date` and none of the four survival dates. +- In a spec whose survival dates are all uniform, nothing forces the fallback, and the native backend generates them as plain calendar dates over their `recStart` range. The adapter reads `followup_min`, `followup_max` and `event_prop` onto the spec (`R/mock_spec_recodeflow.R:418`), but nothing downstream uses them. With `interview_date` and `ltfu_date` alone (n = 2,000), `ltfu_date` is 100 per cent non-missing although `event_prop` is 0.1, and 128 dates fall before entry. + +So #40 fixes a correctness defect as well as a usability gap. + +The design follows from #39. A formula variable is a column computed from other generated columns, evaluated in dependency order after baseline generation. A survival date has the same structure: a column computed from its anchor. The only difference is that it draws random numbers. Treating survival dates as a derived stage reuses #39's skip-in-baseline pattern, dependency ordering, strict validation at stage entry, and diagnostics marking. + +### Constraints + +- **One seeded-output break in v0.5**, already spent by #38. Specs without survival variables must produce byte-identical output; the #48 pinned-value tests enforce this. +- **No backward-compatibility obligation** for 0.x metadata (maintainer decision, 2026-09-22). Metadata that relied on the old silent behaviour may now fail validation, provided the error names the fix. +- **recodeflow semantics untouched.** The new columns are MockData extensions; cchsflow and chmsflow need no change. +- **Keep causal modelling open.** The maintainer expects mock data to be used to study causal structure (DAG-based simulation) in future. That is out of scope here, but no decision should make a later structural-equation stage harder. D1 and D7 are chosen with this in mind. + +## Decision points + +### D1: Architecture + +**(a) Post-baseline anchored stage (chosen).** A new type `survival` (rtype `date`). Both backends skip it in baseline generation, as they skip `formula` (`R/mock_spec_native.R:87,405`; `R/mock_spec_simstudy.R:137,260`). A new exported `generate_survival_dates(data, spec, seed = NULL)` computes the survival dates from the anchor columns in `data`, then applies the consistency rules. Postprocess then treats them like any other column. + +**(b) Multi-column generator (rejected).** One spec entry returning a data frame of two or more columns. This changes the one-column-per-variable contract in the native backend, the simstudy backend, postprocess and diagnostics. It also forces an awkward ownership question for a five-date family, since the entry date would be both a plain variable and a member of the group. + +**(c) Delegate to `create_wide_survival_data()` (rejected).** This bypasses spec validation, diagnostics, the seed contract and postprocess, and makes a deprecated function part of the v0.4 pipeline. + +### D2: Scope + +Full parity with the five legacy roles and both rules. No time-to-event or status columns are generated; the legacy engine produces none. D8 lets users derive them with `mockFormula`. + +### D3: Metadata convention + +| Column | On | Meaning | +|---|---|---| +| `anchor` | `variables.csv` | Name of the date variable this date is generated relative to. A non-blank `anchor` makes the variable type `survival` | +| `censored_by` | `variables.csv` | Optional. Name of a competing survival date. Where that date is earlier than this one, this one becomes `NA` | + +The minimal example becomes: + +``` +variable anchor censored_by followup_min followup_max event_prop distribution +interview_date uniform +primary_event_date interview_date death_date 0 5475 0.3 gompertz +death_date interview_date 365 7300 0.2 gompertz +ltfu_date interview_date 365 7300 0.1 uniform +admin_censor_date interview_date 365 7300 1.0 +``` + +A single `survival_role` column (`entry`, `event`, `death`, `ltfu`, `admin_censor`) was considered and rejected. It reproduces the legacy function's five argument slots, which fix one entry date and one competing-risk rule. `anchor` and `censored_by` express the same case and extend to a second cohort or a second competing risk without a new vocabulary. `censored_by` also makes the competing-risk rule visible in metadata. + +**Placement.** The columns are unprefixed and sit on `variables.csv`, beside the existing survival parameters. The rule, to be written into `reference-config.qmd`, is: MockData extension columns on `variables.csv` are unprefixed; MockData columns on `variable_details.csv`, where they sit among recodeflow's semantic columns, carry the `mock` prefix (as `mockFormula` does). A MockData-owned sidecar that keeps recodeflow files free of extension columns is a separate v0.6 decision (see Future directions); the spec layer does not depend on where columns are read from, so that change will not affect this design. + +**Validation of the convention.** A date variable with any of `followup_min`, `followup_max` or `event_prop` but no `anchor` is an error at spec construction, naming the fix (`anchor = ""`). A variable with `anchor` but without all three parameters is also an error. + +### D4: Fidelity + +The legacy statistics and rules are ported verbatim: + +1. `n_events = floor(n * event_prop)`; the event indicator is shuffled with `sample()`. +2. Follow-up days for events, by `distribution`. **Uniform:** `runif(n_events, followup_min, followup_max)`. **Exponential:** `rexp(n_events, rate = 1 / ((followup_max - followup_min) / 3)) + followup_min`, capped at `followup_max`. **Gompertz:** `(1/shape) * log(1 - (shape/rate) * log(1 - u))` with `u ~ runif`, clamped to the window; `shape` defaults to 0.1 and `rate` to 0.0001. +3. Date = anchor + `floor(follow-up days)` for events; `NA` otherwise. The legacy engine produces whole days because `create_date_var()` converts dates to character and back (`R/create_date_var.R:479`), which floors; the port floors explicitly. +4. Any date earlier than its anchor becomes `NA`; then, if the variable has `censored_by`, rows where the censoring date is earlier become `NA`. + +Survival variables are processed one at a time in dependency order over `depends_on` (D7), using the #39 ordering: repeated passes in spec order, each taking the variables whose dependencies are already placed. Each variable is drawn and then has its own rules applied. Because chained censoring is rejected (D7), a date named in another's `censored_by` is never itself censored, so every rule compares against an unchanged drawn date. For the legacy rule set this gives the legacy results. The draw order differs from spec order where `censored_by` points forward (in the minimal example, `death_date` is drawn before `primary_event_date`); that has no comparability cost, because the RNG streams differ from the legacy engine's anyway. + +The fixed-count allocation in step 1 is the legacy generation method, kept in v0.5 for parity. It does not define survival generation in general: a future hazard model, such as one where risk depends on exposure, must let event occurrence and timing follow that model, which a fixed count of `floor(n * event_prop)` events cannot do. Such a model would be a second generation method beside this one. + +Output cannot be bit-identical to `create_wide_survival_data()`, because the RNG streams differ (#38). Parity is therefore tested structurally and distributionally (Test strategy). Two further differences are inherent in the pipeline: + +| Legacy | New pipeline | Reason | +|---|---|---| +| Missing codes fill the last `n - n_valid` rows; events are split across the first `n_valid` rows (`R/create_date_var.R:295,417`) | Postprocess assigns missing codes at random rows | Missingness belongs to postprocess in v0.4. Proportions match in expectation | +| Garbage is applied inside `create_date_var()`, before the rules, so before-entry garbage is erased | Rules run first; garbage afterwards (D5) | Stage order | + +### D5: Contamination after the consistency rules + +The consistency rules produce clean, analysis-ready dates. Missing codes and garbage are applied afterwards, by postprocess, as for every other variable. This matches #39, where formulas are computed on clean baseline values before contamination. + +It changes one behaviour. In the legacy engine, garbage that places a date before entry is set to `NA` by the before-entry rule; `test-survival-garbage-deprecation.R:147` acknowledges this. The garbage tutorial states that survival garbage exists to create temporal violations such as death before entry, for testing cleaning pipelines. Under D5 those violations survive, so the pipeline does what the tutorial describes. This is a change of stage order, not of any rule. Ratified 2026-09-24. + +One consequence: the rules use the true dates, and postprocess may then give a missing code to a date that censored another. The output can therefore show an event censored by a death that is itself recorded as missing. This mirrors real data, where a death is known to the registry but missing from the analysis file, and it is pinned by a test so it stays deliberate. + +The pipeline therefore produces three layers, which the documentation names explicitly: + +- **Clean truth:** the generated dates, and any formula columns derived from them, before contamination. Available by running the stages directly: `generate_mock_data_native()`, then `generate_survival_dates()`, then `evaluate_mock_formulas()`. Keep that data frame before calling `postprocess_mock_data()`. +- **Observed data:** the output of `postprocess_mock_data()` (and of `create_mock_data()`), after missing codes and garbage. +- **Analysis variables:** status, follow-up time and similar quantities that the downstream analysis recalculates from the observed data. MockData does not produce this layer; a formula column computed from clean truth is not a substitute for it. + +D5 applies contamination between the first and second layers. It is the right default for testing cleaning pipelines and for simulating measurement error, and it is misleading only if someone assumes that formula columns were computed from the returned dates. + +### D6: Stage and seed + +`create_mock_data()` runs baseline, survival, formulas, then postprocess. Survival precedes formulas so that a `mockFormula` can use survival dates. `.MOCK_STAGES` (`R/mock_spec_native.R:12`) gains `survival = 4L`. Appending leaves indices 0 to 3 in place, so every existing pinned value holds; the frozen-stage test (`test-seed-contract.R:99`) is extended, not renumbered. Survival draws run inside `.with_mock_seed(seed, stage = "survival")`. + +The fixed order is a v0.5 restriction. It supports `death_date` then follow-up time, but not a hazard that depends on a derived exposure (for example, smoking intensity and duration, then pack-years, then `death_date`, then follow-up time), because pack-years would be computed too late. Lifting it means scheduling derived variables across types in one dependency-ordered pass; that is a scheduler refactor, which would keep the spec, the metadata adapters and the one-column-per-variable contract. + +### D7: Dependencies + +Survival variables set `depends_on = c(anchor, censored_by)`. `.order_formula_variables()` (`R/mock_spec_formula.R:88`) is generalized to order any derived type by `depends_on`, and the formula stage uses the generalized version. The purpose is forward compatibility: a later structural-equation stage, whether in MockData or a separate package, would read a single dependency field from the spec and add a type rather than a mechanism. + +`depends_on` is a scheduling field only. The two relationships it combines have different roles and stay as separate fields on the spec: `anchor` determines how a date is generated; `censored_by` determines what is observed. A future observation layer can therefore treat them differently without a spec change. + +**Chained censoring is rejected in v0.5.** A `censored_by` target may not itself have `censored_by`. With a chain (A censored by B, B censored by C), applying the rules in sequence gives a wrong result: with A on day 30, B on day 20 and C on day 10, B becomes `NA`, so A is no longer compared with an earlier date and is reported as observed on day 30, although observation ended on day 10 (reproduced 2026-09-24). Comparing against unchanged dates instead gives the right answer here only by coincidence. The legacy engine has a single rule and the minimal example no chain, so parity is unaffected. The longer-term design (Future directions) keeps generated event times and derives observed outcomes from all censoring sources at once. + +### D8: Add `is.na` to the formula allow-list + +The #39 allow-list has no missingness test, so a formula cannot derive an event indicator from a survival date. With `is.na` added, status and follow-up time can be derived, but they must come from the same observation window. `as.integer(!is.na(death_date))` only records that a death date exists: for a death on day 50 and administrative censoring on day 20 it gives status 1 beside a follow-up time of 20 days (reproduced 2026-09-24). The correct pair uses the end of observation for both: + +```r +end = pmin(death_date, ltfu_date, admin_censor_date, na.rm = TRUE) +status = as.integer(!is.na(death_date) & death_date == end) +followup_days = as.numeric(end - interview_date) +``` + +(A formula cannot return a `Date`, so `end` is written out inside both formulas.) Ties count as observed events: a death on the censoring date gives status 1. This matches the censoring rule, which removes an event only when the censoring date is strictly earlier. The external review points to simstudy's survival documentation () for the same separation of event times from observed outcomes. The #39 ADR permits widening the list on concrete need; this is one. `is.na` has no side effects and cannot reach outside the sandbox. Ratified 2026-09-24. + +Derived columns describe the true generated values. The #39 stage order computes formulas before postprocess, and postprocess contaminates each column independently: garbage only replaces existing values (`R/mock_spec_postprocess.R:255`), but missing codes may land on any row (`:192`). So a derived `status` of 1 can sit beside a `death_date` shown as missing, and a derived follow-up time reflects the true date where the observed one is garbage. (Verified with #39 code: in 1,000 rows, all 300 rows where a source column shows its missing code keep the formula value computed from the true value.) For testing a cleaning pipeline this is useful, because the derived columns are the answer key. For an analysis-ready teaching dataset it is not, and v0.5 does not offer the alternative; see Future directions. + +### D9: Legacy path + +`create_wide_survival_data()` is already tagged `@keywords deprecated`. v0.5 adds a one-time deprecation warning pointing to metadata-driven generation. The function is retained unchanged: it is the reference for the characterization tests that pin the rules before the port. + +### D10: Legacy generator and `anchor` + +`create_mock_data()` uses the legacy generator when `validate = FALSE`, when `variable_details` is `NULL`, when only `variable_details` has a `databaseStart` column, or when another variable uses a feature the v0.4 pipeline does not support. The legacy dispatcher cannot generate survival dates from metadata; today it warns and drops them. With `anchor` in the metadata, that would silently produce partial survival data. Instead, the legacy path stops, names the survival variables, lists the four conditions that select it, and suggests `verbose = TRUE` to see which applied. This follows the no-backward-compatibility constraint: a loud error that names the fix, rather than plausible-looking partial output. + +## Validation, direct API and diagnostics + +**Validator.** A `survival` branch in `validate_mock_spec()` beside the `date` and `formula` branches (`R/mock_spec.R:968`): + +- `anchor` names a spec variable of type `date` (not `survival` or `formula`; chained anchors are not supported) +- `censored_by`, if set, names a `survival` variable with the same `anchor`, and that variable has no `censored_by` of its own (no chained censoring, D7) +- `followup_min` and `followup_max` finite, non-negative, and `followup_min <= followup_max` +- `event_prop` in [0, 1] +- `distribution` one of `uniform`, `exponential`, `gompertz`; `shape` and `rate` positive when supplied +- `rtype` is `date` +- `sourceFormat` is `analysis` (other formats are not supported for survival dates in v0.5) + +`generate_survival_dates()` strict-validates at entry, like `evaluate_mock_formulas()`, and stops if an anchor column is absent from `data`. + +**Adapter.** `.recodeflow_variable_kind()` (`R/mock_spec_recodeflow.R:117`) returns `survival` for a date row with a non-blank `anchor`. Construction reads `anchor` and `censored_by` beside the existing `followup_*`, `event_prop`, `rate` and `shape` reads. The `recStart` range of a survival date is not used for generation, as in the legacy engine. + +**Direct API.** `mock_spec_survival()` returns a composable variable, used with `mock_spec()` beside its anchor. Arguments: `name`, `anchor`, `followup_min`, `followup_max`, `event_prop`, `distribution = "uniform"`, `censored_by = NULL`, `shape = NULL`, `rate = NULL`, `source_format = "analysis"`, plus the usual missing-code, garbage, provenance and model-hint arguments. There is no one-variable `mock_survival()` counterpart to `mock_formula()`: a spec holding only a survival variable can never validate, because its anchor must be in the same spec. + +**Diagnostics.** Following #39's D5 (`R/mock_spec_postprocess.R:35`): `derived = TRUE`, `anchor`, `censored_by`, `depends_on`, and `n_events`, the number of non-`NA` dates after the consistency rules and before postprocess. + +## Concerns filed as issues (not fixed in #40) + +Found while preparing this design and filed on 2026-09-24. Each is reproduced exactly by the port. + +1. **Gompertz output is degenerate with the packaged parameters** (#54). With `shape = 0.1` and `rate = 1e-04`, time in days, the inverse-CDF never exceeds about 94 days (median 65). In the minimal example every non-garbage `death_date` therefore clamps to `followup_min`, exactly 365 days after entry (388 of 400 deaths in a 2,000-row legacy run; the other 12 are the 3 per cent high garbage). Every non-garbage `primary_event_date` falls within about 94 days despite a 15-year window. No death precedes an event, so the competing-risk rule never fires. The survival tutorial is built on this fixture. Whether the fault lies in the parameterization, the time scale, or the fixture values needs a methodological decision before the tutorial is rewritten around `create_mock_data()`. +2. **Administrative censoring is drawn per person** (#55). `admin_censor_date` has `event_prop = 1.0` and a follow-up window, so each person gets a random date between one and 20 years after entry (1,762 distinct values in 2,000 rows). Its `variable_details` row specifies a fixed date, 2024-12-31, which is ignored. Administrative censoring is usually a fixed study end date. +3. **Declared date missingness is ignored** (evidence added to #15). Every missing row for the minimal example's dates is `else` with `NA::b` and a proportion (0.05 for `death_date`). Neither path applies it: the v0.4 adapter skips `else` rows (`R/mock_spec_recodeflow.R:232`), and the legacy `death_date` is exactly 20.0 per cent non-missing, its `event_prop`. Belongs with #15. +4. **The documentation overstates censoring** (#56). `create_wide_survival_data()` documents that "observation ends at min(event, death, ltfu, admin_censor)" (`:61`), but the code applies only death before event. Under D3, users encode additional rules with `censored_by`. +5. **Positional missing-code placement** (#56) in `create_date_var()`: the last rows are the missing ones, so row order carries meaning. +6. **Inert range rows** (#56). The `recStart` range on a survival date's `variable_details` row (for example `[2002-01-01,2021-01-01]`) never bounds anything, which invites the reader to think it does. +7. **Exponential survival dates ignore `rate`** (#56). The generator derives its rate from the window, `1 / ((followup_max - followup_min) / 3)` (`R/create_date_var.R:328`), while `reference-config.qmd` says the exponential distribution requires `rate`. + +## Consequences + +- `create_mock_data()` generates complete survival data from metadata, closing the NEWS known issue from v0.2.0. +- Survival metadata that previously generated silently wrong output now either generates correctly (with `anchor`) or fails validation with a message naming the fix. +- New exports: `generate_survival_dates()` and `mock_spec_survival()`. Additions to `_pkgdown.yml` in the same PR. +- The packaged minimal example now generates entirely through the v0.4 pipeline. Its two Gompertz dates were its only legacy-fallback triggers, so every vignette and test that runs `create_mock_data()` on it sees different values. +- The formula allow-list grows by one function (D8). +- Specs without survival variables: output unchanged. +- Derived columns (formulas, survival status or time) describe true values; contamination is applied to each column independently (D8). Documented in the formula and survival vignettes. + +## Test strategy + +1. **Characterization first.** Pin the legacy engine's two rules and its structural invariants on `create_wide_survival_data()` before any port code, as the reference. +2. **Validator and adapter.** One failing case, with its message, for each rule in "Validation"; the missing-anchor error. +3. **Generation.** Event proportion within two-sided binomial bounds for each distribution (#23's direction); every non-`NA` date at or after its anchor; `censored_by` sets `NA` exactly where the censoring date is earlier and nowhere else; typed zero-row output for `n = 0`; skipping the stage makes postprocess fail on the missing column. +4. **Contract.** Frozen-stage test extended with `survival = 4L`; pinned reference values for one survival spec; all existing pins untouched. +5. **End to end.** `create_mock_data()` on the amended minimal example produces all five dates with no fallback; the simstudy backend produces the same survival columns; a `mockFormula` derives status and follow-up time from them (D8); D5 is pinned by a test showing before-entry garbage survives, and by a test showing an event censored by a death that postprocess then records as missing. +6. **Draw order.** A test pins that survival draws follow dependency order with position tie-breaking, so a later change to either is visible. + +## Migration + +- Metadata: add `anchor` (and `censored_by` where a competing risk applies) to each survival date's row. Validation names the missing column. +- Code calling `create_wide_survival_data()` keeps working, with a deprecation warning. Replace it with `create_mock_data()` on metadata carrying `anchor`. +- Seeded survival output differs from the legacy function's; regenerate pinned expected values once. +- NEWS: feature entry; removal of the v0.2.0 known-issue entry with a migration note; the D5 behaviour change stated explicitly. + +## Future directions + +- **Sidecar overlay (v0.6, separate ADR; #57).** A MockData-owned table keyed by variable, joined onto `variables` before spec construction, so all extension columns can live outside recodeflow files. The legacy `mock_data_config.csv` readers from v0.2 are the starting point to reconcile or retire. +- **Observation layer.** Keep the generated event times and derive observed outcomes (status, time, cause) from all censoring sources at once, instead of overwriting censored dates with `NA`. This handles several censoring sources per event cleanly and removes the chaining problem in D7. An option to compute formulas from observed rather than clean values (D5) belongs with it. +- **Structural (causal) generation.** Out of scope. The maintainer's motivating case is exposure-dependent survival, such as smoking changing the hazard. Assessed on 2026-09-24, with corrections from an external review: + + | Use case | What would need adding | Architectural implication | + |---|---|---| + | Baseline smokers have a different hazard | A survival model using each person's exposure and covariates, with event occurrence following the model rather than the fixed allocation (D4) | Fits the existing spec and generator pattern | + | Hazard depends on derived pack-years | Formulas evaluated before the survival variables that need them | Scheduling refactor (D6) | + | Someone quits smoking during follow-up | Exposure history, and a hazard that changes over time using only the exposure known at each time; applying final smoking status retrospectively would use information from the future | Substantial generator extension; the core spec may remain | + | Illness changes smoking, which changes later outcomes | Sequential updates to exposures and health states | A longitudinal simulation mechanism; the current execution architecture cannot be assumed to hold | + + A causal interpretation of any of these would also need explicit assumptions about confounding and interventions, which the metadata does not yet express. + + Future exposure-dependent survival models should reuse the spec, the metadata adapters and the column assembly contract. They may need additional model parameters, dependency scheduling across variable types, and separate event-generation and observation rules. The fixed stage order and the legacy event-allocation method are v0.5 restrictions. diff --git a/development/plans/2026-07-06-seed-contract.md b/development/plans/2026-07-06-seed-contract.md new file mode 100644 index 0000000..2283417 --- /dev/null +++ b/development/plans/2026-07-06-seed-contract.md @@ -0,0 +1,466 @@ +# Seed & RNG-Stream Contract Implementation Plan (#38) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make one public `seed` reproduce identical MockData output across every generation path, independent of the caller's ambient `RNGkind()` and without perturbing the caller's RNG state — via L'Ecuyer-CMRG per-stage sub-streams — landing the single budgeted seeded-output break for v0.5. + +**Architecture:** One seeding primitive, `.with_mock_seed(seed, expr, stage)`, owns all randomness. It saves/restores both `.Random.seed` and `RNGkind`, seeds one `L'Ecuyer-CMRG` stream from the public seed, advances to the named stage's guaranteed-independent sub-stream via `parallel::nextRNGStream()`, and runs the generation expression under it. The native, simstudy, and legacy paths all route through it; stage indices are frozen so future stages (#39 formula, #42 correlate) add without renumbering. + +**Tech Stack:** R (≥ 4.2.0), base `parallel` (new Import — ships with every R install, no external dependency), testthat edition 3, roxygen2, renv. + +**Decision source:** [development/adr/v05-seed-contract.md](../adr/v05-seed-contract.md) (ACCEPTED 2026-07-06). This plan implements D1(b), D2(a), D3, D4(a). + +## Global Constraints + +Inherited from `development/post-v040-development-plan.md` Global Constraints (R floor, `en_US.UTF-8` CI, `roxygenize()` before pkgdown, no committing check artefacts, plain-imperative commits with no AI credit, review before push, fixtures via `system.file()` + skip guards). Plus, specific to this workstream: + +- **This is the budgeted break.** Seeded output changes once, here. Every value-pinning test breaks and is re-baselined *deliberately* (regenerate → sanity-check → paste), never blind-accepted. A test asserting a *property* (range, type, count, error message) that breaks is a real regression — stop, do not edit it green. +- **`parallel` is base R.** Add it to `DESCRIPTION` `Imports` and use `parallel::nextRNGStream()`. Run `renv::snapshot()` after (base packages usually don't alter `renv.lock`; commit it only if it changes). +- **Stage indices are frozen** at `baseline = 0, postprocess = 1, formula = 2, correlate = 3`. Reserve `formula`/`correlate` now though unused, so #39/#42 don't renumber existing streams and re-break output. +- **Co-releases with #39** in v0.5 — do not tag until both land. This plan is #38 only. +- **Downstream:** cchsflow/chmsflow may pin MockData output; the NEWS migration note is mandatory. + +## The mechanism (read before Task 1) + +R's default Mersenne-Twister has no guaranteed stream-splitting. `L'Ecuyer-CMRG` does: `parallel::nextRNGStream(state)` returns a state advanced 2^127 steps — provably non-overlapping. Pattern: + +```r +set.seed(seed, kind = "L'Ecuyer-CMRG") # sets kind AND seeds in one call +stream <- .Random.seed # sub-stream 0 (baseline) +stream <- parallel::nextRNGStream(stream) # sub-stream 1 (postprocess) +assign(".Random.seed", stream, envir = .GlobalEnv) # install; draws now use it +``` + +Because the kind is pinned inside the scope and `.Random.seed`/`RNGkind` are saved and restored around it, output is independent of ambient `RNGkind()` and the caller's stream is untouched. Lazy evaluation makes wrapping work: `.with_mock_seed(seed, { df[[v]] <- ... })` forces the `{...}` promise in the *caller's* frame, so assignments inside it persist — this is how the native path already operates and how the legacy loop will be wrapped. + +## File structure + +- `R/mock_spec_native.R` — `.MOCK_STAGES` (new), `.with_mock_seed()` (extend), `generate_mock_data_native()` call site. +- `R/mock_spec_postprocess.R` — `postprocess_mock_data()` call site → stage `"postprocess"`. +- `R/mock_spec_simstudy.R` — `generate_mock_data_simstudy()` call site → stage `"baseline"`. +- `R/create_mock_data.R` — `.create_mock_data_v04()` drop `seed + 1L`; legacy loop wrap; always-on fallback message; roxygen `@details` seed paragraph. +- `DESCRIPTION` — add `parallel` to `Imports`. +- `tests/testthat/test-seed-contract.R` — new contract suite. +- `NEWS.md` — migration note. +- Re-baselined value-pin tests across `tests/testthat/` (discovered during execution). + +--- + +### Task 1: RNG primitive + native/spec-layer wiring + +**Files:** +- Modify: `R/mock_spec_native.R` (add `.MOCK_STAGES`; rewrite `.with_mock_seed()` at :8-34; `generate_mock_data_native()` call at :349) +- Modify: `R/mock_spec_postprocess.R:373` (call site) +- Modify: `R/mock_spec_simstudy.R:231` (call site) +- Modify: `R/create_mock_data.R` (`.create_mock_data_v04()` :98-103; `@details` seed paragraph :164-166) +- Modify: `DESCRIPTION` (Imports) +- Test: `tests/testthat/test-seed-contract.R` (new) + +**Interfaces:** +- Produces: `.with_mock_seed(seed, expr, stage = "baseline")` — save/restore `.Random.seed` + `RNGkind`; L'Ecuyer sub-stream per stage; returns `force(expr)`. `.MOCK_STAGES` named int vector. Consumed by Task 2 (legacy path). + +- [ ] **Step 1: Write the failing contract tests (primitive + native level)** + +```r +# tests/testthat/test-seed-contract.R +# Contract pinned by ADR development/adr/v05-seed-contract.md (#38). + +test_that("distinct stages yield distinct, reproducible streams from one seed", { + draw <- function(stage) MockData:::.with_mock_seed(1L, runif(5), stage = stage) + expect_false(identical(draw("baseline"), draw("postprocess"))) + expect_identical(draw("baseline"), draw("baseline")) +}) + +test_that("seeded generation leaves the caller's RNG state and kind untouched", { + set.seed(999) + before_state <- .Random.seed + before_kind <- RNGkind() + spec <- mock_continuous("age", range = c(18, 80)) + generate_mock_data_native(spec, n = 50, seed = 42) + expect_identical(.Random.seed, before_state) + expect_identical(RNGkind(), before_kind) +}) + +test_that("same seed and spec give identical native output", { + spec <- mock_continuous("age", range = c(18, 80)) + expect_identical( + generate_mock_data_native(spec, n = 100, seed = 7), + generate_mock_data_native(spec, n = 100, seed = 7) + ) +}) + +test_that("native output is independent of the caller's ambient RNGkind", { + spec <- mock_continuous("age", range = c(18, 80)) + old <- RNGkind() + on.exit(RNGkind(kind = old[1], normal.kind = old[2], sample.kind = old[3]), add = TRUE) + RNGkind("Mersenne-Twister") + a <- generate_mock_data_native(spec, n = 100, seed = 7) + RNGkind("Marsaglia-Multicarry") + b <- generate_mock_data_native(spec, n = 100, seed = 7) + expect_identical(a, b) +}) +``` + +- [ ] **Step 2: Run — verify failure** + +Run: `Rscript -e 'devtools::load_all(quiet = TRUE); testthat::test_file("tests/testthat/test-seed-contract.R")'` +Expected: FAIL — `.with_mock_seed()` has no `stage` argument yet (unused-argument error), and RNGkind-independence fails because current code uses ambient MT. + +- [ ] **Step 3: Add `.MOCK_STAGES` and rewrite `.with_mock_seed()`** + +In `R/mock_spec_native.R`, replace the existing `.with_mock_seed` (:8-34) with: + +```r +# Named generation stages -> fixed L'Ecuyer-CMRG sub-stream indices. Indices +# are FROZEN: adding a future stage must not renumber baseline/postprocess, or +# seeded output for existing features would shift. formula (#39) and correlate +# (#42) are reserved now though unused in this release. +.MOCK_STAGES <- c( + baseline = 0L, + postprocess = 1L, + formula = 2L, + correlate = 3L +) + +#' @noRd +.with_mock_seed <- function(seed, expr, stage = "baseline") { + if (is.null(seed)) { + return(force(expr)) + } + + if (!is.numeric(seed) || length(seed) != 1 || is.na(seed) || seed != floor(seed)) { + stop("seed must be a single whole number.", call. = FALSE) + } + if (!stage %in% names(.MOCK_STAGES)) { + stop("Unknown generation stage: '", stage, "'.", call. = FALSE) + } + + # Save the caller's RNG state AND kind so generation never perturbs them. + old_kind <- RNGkind() + had_seed <- exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE) + if (had_seed) { + old_seed <- get(".Random.seed", envir = .GlobalEnv, inherits = FALSE) + } + on.exit({ + RNGkind(kind = old_kind[1], normal.kind = old_kind[2], sample.kind = old_kind[3]) + if (had_seed) { + assign(".Random.seed", old_seed, envir = .GlobalEnv) + } else if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)) { + rm(".Random.seed", envir = .GlobalEnv) + } + }, add = TRUE) + + # One L'Ecuyer-CMRG stream from the public seed, advanced to this stage's + # provably-independent sub-stream. Pinning the kind makes output independent + # of the caller's ambient RNGkind(). + set.seed(seed, kind = "L'Ecuyer-CMRG") + stream <- .Random.seed + for (i in seq_len(.MOCK_STAGES[[stage]])) { + stream <- parallel::nextRNGStream(stream) + } + assign(".Random.seed", stream, envir = .GlobalEnv) + + force(expr) +} +``` + +- [ ] **Step 4: Point the three spec-layer call sites at their stages** + +`R/mock_spec_native.R:349` — `generate_mock_data_native()`: change `.with_mock_seed(seed, {` to `.with_mock_seed(seed, stage = "baseline", {` (or add `stage = "baseline"` after the `expr` — since `expr` is positional and `{...}` is large, pass `stage` as the named 3rd arg *after* the block: keep `.with_mock_seed(seed, { ... })` and append `, stage = "baseline")` — baseline is the default, so this call may be left unchanged; make it explicit for clarity). + +`R/mock_spec_postprocess.R:373` — `postprocess_mock_data()`: the `.with_mock_seed(seed, { ... })` must pass `stage = "postprocess"`. Add the named argument. + +`R/mock_spec_simstudy.R:231` — `generate_mock_data_simstudy()`: it is an alternative *baseline* backend; pass `stage = "baseline"` (explicit). + +(Because `stage` is a named argument with default `"baseline"`, add it after the closing brace of the block, e.g. `.with_mock_seed(seed, stage = "postprocess", { ... })` — verify the call parses.) + +- [ ] **Step 5: Drop the `seed + 1L` split in the orchestrator** + +`R/create_mock_data.R:98-103` — replace: + +```r + baseline <- generate_mock_data_native(spec, n = n, seed = seed) + # The wrapper uses a second deterministic stream for post-processing so + # baseline generation and missing/garbage assignment can be reproduced + # independently from the single public seed. + postprocess_seed <- if (is.null(seed)) NULL else seed + 1L + postprocess_mock_data(baseline, spec, seed = postprocess_seed) +``` + +with: + +```r + baseline <- generate_mock_data_native(spec, n = n, seed = seed) + # Baseline and post-processing use distinct L'Ecuyer-CMRG sub-streams derived + # from the single public seed (see .with_mock_seed / ADR v05-seed-contract), + # so both stages pass the same seed and select their own stage internally. + postprocess_mock_data(baseline, spec, seed = seed) +``` + +Then fix the now-stale `@details` seed paragraph at `R/create_mock_data.R:164-166` ("seed + 1 is used for post-processing…") to describe the sub-stream contract, e.g.: "In the v0.4 path, baseline generation and post-processing draw from distinct, independent sub-streams derived from a single `seed`; output is reproducible for a given seed and package version but changed in v0.5 (see NEWS)." + +- [ ] **Step 6: Add `parallel` to Imports** + +`DESCRIPTION` — under `Imports:`, add `parallel` beside `stats`: + +``` +Imports: + parallel, + stats +``` + +- [ ] **Step 7: Document, then run the contract tests** + +Run: `Rscript -e 'devtools::document()'` (no roxygen surface change expected beyond the edited `@details`; confirm clean). +Run: `Rscript -e 'devtools::load_all(quiet = TRUE); testthat::test_file("tests/testthat/test-seed-contract.R")'` +Expected: all four PASS. + +- [ ] **Step 8: Re-baseline the value-pin tests broken by the MT→L'Ecuyer switch** + +Run: `Rscript -e 'devtools::test()' 2>&1 | tee /private/tmp/claude-502/-Users-dmanuel-github-mock-data/20beff35-3d8d-499a-b425-4bfa03cd3bb1/scratchpad/seed-break-t1.log` + +Triage every failure: +- **Exact-value assertion** (`expect_equal`/`expect_identical` against a hard-coded number or vector produced under a seed): expected re-baseline. Read the new actual value from the failure output and VERIFY before pasting: value(s) within the variable's declared range; correct type/class/length; for a distribution, mean/spread in the right ballpark (draw a quick `summary()` if unsure). Then replace the literal. Never blind-copy the failure's "actual". +- **Property assertion** (range, count, NA-proportion within tolerance, type, error message, `expect_error`): if this broke, it is a REAL regression — STOP and report BLOCKED with the test and output. Do not edit it to pass. + +Re-run `devtools::test()` until green (0 FAIL; pre-existing 10 skips OK; WARN not above the pre-existing baseline of 35). + +- [ ] **Step 9: Commit** + +```bash +git add R/mock_spec_native.R R/mock_spec_postprocess.R R/mock_spec_simstudy.R \ + R/create_mock_data.R DESCRIPTION tests/testthat/test-seed-contract.R \ + man/ NEWS.md +# (NEWS.md only if you added the note here; otherwise defer to Task 3) +git commit -m "Adopt L'Ecuyer-CMRG per-stage seed sub-streams (#38) + +Replace the seed/seed+1L split with named, provably-independent +sub-streams (baseline, postprocess) derived from one public seed via +parallel::nextRNGStream(). .with_mock_seed() now pins RNGkind within +scope and restores it, so output no longer depends on the caller's +ambient RNGkind(). Native, postprocess, and simstudy paths route +through it. Re-baselines value-pinning tests for the RNG change." +``` + +--- + +### Task 2: Unify legacy-path isolation + always-on fallback message + +**Files:** +- Modify: `R/create_mock_data.R` (legacy seed block :347-352; generation loop wrap; fallback message after :296-298) +- Test: `tests/testthat/test-seed-contract.R` (append) + +**Interfaces:** +- Consumes: `.with_mock_seed()` and `.MOCK_STAGES` from Task 1. +- Produces: legacy path no longer clobbers the caller's RNG; a once-per-call `message()` when the v0.4 path silently falls back to legacy. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/testthat/test-seed-contract.R`: + +```r +test_that("legacy path (validate = FALSE) leaves the caller's RNG untouched", { + vars <- system.file("extdata", "minimal-example", "variables.csv", package = "MockData") + dets <- system.file("extdata", "minimal-example", "variable_details.csv", package = "MockData") + if (!nzchar(vars) || !nzchar(dets)) skip("minimal-example fixtures not installed") + variables <- read.csv(vars, stringsAsFactors = FALSE, check.names = FALSE) + variable_details <- read.csv(dets, stringsAsFactors = FALSE, check.names = FALSE) + + set.seed(123) + before <- .Random.seed + suppressWarnings(suppressMessages( + create_mock_data("minimal-example", variables, variable_details, + n = 20, seed = 42, validate = FALSE) + )) + expect_identical(.Random.seed, before) +}) + +test_that("legacy path is reproducible for a given seed", { + vars <- system.file("extdata", "minimal-example", "variables.csv", package = "MockData") + dets <- system.file("extdata", "minimal-example", "variable_details.csv", package = "MockData") + if (!nzchar(vars) || !nzchar(dets)) skip("minimal-example fixtures not installed") + variables <- read.csv(vars, stringsAsFactors = FALSE, check.names = FALSE) + variable_details <- read.csv(dets, stringsAsFactors = FALSE, check.names = FALSE) + a <- suppressWarnings(suppressMessages( + create_mock_data("minimal-example", variables, variable_details, n = 20, seed = 42, validate = FALSE))) + b <- suppressWarnings(suppressMessages( + create_mock_data("minimal-example", variables, variable_details, n = 20, seed = 42, validate = FALSE))) + expect_identical(a, b) +}) +``` + +- [ ] **Step 2: Run — verify the isolation test fails** + +Run: `Rscript -e 'devtools::load_all(quiet = TRUE); testthat::test_file("tests/testthat/test-seed-contract.R")'` +Expected: "legacy path … leaves the caller's RNG untouched" FAILS (current bare `set.seed()` clobbers `.Random.seed`). The reproducibility test may pass already. + +- [ ] **Step 3: Replace the bare `set.seed` and wrap the generation loop** + +`R/create_mock_data.R` — remove the SET GLOBAL SEED block (:347-352): + +```r + # ========== SET GLOBAL SEED ========== + + if (!is.null(seed)) { + if (verbose) message("Setting random seed: ", seed) + set.seed(seed) + } +``` + +Wrap the variable-generation loop (the `for (i in seq_len(nrow(enabled_vars)))` block, currently ~:374 onward through its closing brace) in `.with_mock_seed(seed, stage = "baseline", { ... })`. Because the promise is forced in this frame, in-loop assignments (`df_mock[[var_name]] <- …`, `skipped_vars <- …`) persist. Concretely, change: + +```r + for (i in seq_len(nrow(enabled_vars))) { + ... + } +``` + +to: + +```r + .with_mock_seed(seed, stage = "baseline", { + for (i in seq_len(nrow(enabled_vars))) { + ... + } + }) +``` + +Do not alter the loop body or the post-loop assembly. If `verbose`, keep a seed message inside the wrapper: `if (!is.null(seed) && verbose) message("Setting random seed: ", seed)` as the first line inside the block. + +- [ ] **Step 4: Add the always-on fallback message (D4)** + +`R/create_mock_data.R:286-298` — the `else` branch attempts v0.4 and falls through when `v04_result` is NULL. Add an always-on message on that silent-fallback edge only: + +```r + } else { + v04_result <- .create_mock_data_v04( + databaseStart = databaseStart, + variables = variables, + variable_details = variable_details, + n = n, + seed = seed, + verbose = verbose + ) + + if (!is.null(v04_result)) { + return(v04_result) + } + + message( + "Falling back to the legacy generator for an unsupported v0.4 feature; ", + "for a given seed this produces different values than the v0.4 pipeline." + ) + } +``` + +(Do not add it to the `validate = FALSE` or `variable_details = NULL` branches — those are explicit user choices already messaged under `verbose`.) + +- [ ] **Step 5: Run the contract tests, then re-baseline legacy value-pins** + +Run the seed-contract file: expected all PASS (isolation now holds). +Run: `Rscript -e 'devtools::test()' 2>&1 | tee /private/tmp/claude-502/-Users-dmanuel-github-mock-data/20beff35-3d8d-499a-b425-4bfa03cd3bb1/scratchpad/seed-break-t2.log` +Apply the same triage as Task 1 Step 8 to any newly-broken value-pin tests on the legacy path (they switched MT→L'Ecuyer). Property regressions → STOP. A new fallback `message()` may surface in tests that call the fallback edge without `suppressMessages()` — wrap those calls, do not silence the message globally. + +- [ ] **Step 6: Commit** + +```bash +git add R/create_mock_data.R tests/testthat/ +git commit -m "Unify legacy path on the seed contract and announce fallbacks (#38) + +Route the legacy generation loop through .with_mock_seed() so it no +longer clobbers the caller's RNG stream, matching the native path. +Emit an always-on message when the v0.4 path silently falls back to +the legacy generator for an unsupported feature (resolves #33 item 11). +Re-baselines affected legacy value-pin tests." +``` + +--- + +### Task 3: Contract test suite completion, docs, NEWS + +**Files:** +- Modify: `tests/testthat/test-seed-contract.R` (cross-path + pinned reference values) +- Modify: `R/mock_spec_native.R`, `R/mock_spec_postprocess.R`, `R/mock_spec_simstudy.R` (`@param seed` consistency) +- Modify: `NEWS.md` +- Modify: `man/` (regenerated) + +**Interfaces:** consumes everything above; no new production code. + +- [ ] **Step 1: Add cross-path determinism and pinned-reference tests** + +Append to `tests/testthat/test-seed-contract.R`: + +```r +test_that("full orchestrated pipeline is reproducible for a given seed", { + vars <- system.file("extdata", "minimal-example", "variables.csv", package = "MockData") + dets <- system.file("extdata", "minimal-example", "variable_details.csv", package = "MockData") + if (!nzchar(vars) || !nzchar(dets)) skip("minimal-example fixtures not installed") + variables <- read.csv(vars, stringsAsFactors = FALSE, check.names = FALSE) + variable_details <- read.csv(dets, stringsAsFactors = FALSE, check.names = FALSE) + a <- suppressWarnings(suppressMessages( + create_mock_data("minimal-example", variables, variable_details, n = 50, seed = 1))) + b <- suppressWarnings(suppressMessages( + create_mock_data("minimal-example", variables, variable_details, n = 50, seed = 1))) + expect_identical(a, b) +}) + +test_that("pinned reference values catch the next accidental RNG change", { + spec <- mock_continuous("x", range = c(0, 1)) + got <- generate_mock_data_native(spec, n = 3, seed = 20260706)$x + # Reference values captured under the v0.5 L'Ecuyer-CMRG contract. If this + # breaks, seeded output changed — treat as a deliberate, NEWS-documented break, + # not a silent one. + expect_equal(got, c(), tolerance = 1e-8) +}) +``` + +For the pinned test: run it once, read the three actual values, sanity-check they lie in `[0, 1]`, and paste them into ``. This is the golden baseline; capturing it is the point. + +- [ ] **Step 2: Harmonize `@param seed` docs** + +In `generate_mock_data_native()`, `postprocess_mock_data()`, and `generate_mock_data_simstudy()`, make each `@param seed` state the shared contract, e.g.: "Optional whole-number seed. Generation uses an isolated L'Ecuyer-CMRG sub-stream and restores the caller's RNG state and kind on exit, so output is reproducible for a given seed and package version without perturbing the caller's RNG." Run `Rscript -e 'devtools::document()'`. + +- [ ] **Step 3: NEWS migration note** + +Under `# MockData (development version)` in `NEWS.md`: + +```markdown +## Reproducibility (breaking change) + +- Seeded output changed once in this release. MockData now derives all + randomness from independent L'Ecuyer-CMRG sub-streams (one per generation + stage) seeded from the single public `seed`, replacing the previous + Mersenne-Twister `seed` / `seed + 1` scheme. For a given seed **and package + version** output is reproducible and independent of the session's ambient + `RNGkind()`; it is not comparable across the v0.4 → v0.5 boundary. Pin your + own expected values against the version you use. Generation no longer alters + the caller's RNG state on any path (previously the legacy `validate = FALSE` + path reset it). +``` + +- [ ] **Step 4: Full verification** + +Run: `Rscript -e 'devtools::test()'` — 0 FAIL, ≤10 skips, ≤35 WARN. +Run: `Rscript -e 'devtools::check()'` — no new ERRORs/WARNINGs/NOTEs attributable to this work (a `parallel` Import NOTE, if any, is expected and acceptable). +Run: `Rscript -e 'renv::snapshot()'` — commit `renv.lock` only if it changed. + +- [ ] **Step 5: Commit** + +```bash +git add tests/testthat/test-seed-contract.R NEWS.md man/ R/ renv.lock +git commit -m "Complete seed-contract tests and docs (#38) + +Add cross-path determinism and pinned-reference tests, harmonize the +seed @param docs across the three generators, and document the v0.5 +reproducibility break in NEWS. Fulfils #22." +``` + +--- + +## Self-review (performed at plan date) + +- **ADR coverage:** D1(b) sub-streams — Task 1 Step 3; D2(a) unify isolation — Task 1 (native/simstudy/postprocess already save/restore; Task 2 legacy); D3 guarantee — pinned + RNGkind-independence tests; D4(a) always-on fallback — Task 2 Step 4. Test strategy items 1–5 map to Task 1 Steps 1, Task 2 Step 1, Task 3 Step 1. +- **Placeholder scan:** the only intentional fill-in is the pinned reference vector (Task 3 Step 1), which cannot exist until the mechanism runs; the step specifies exactly how to capture and sanity-check it. Re-baselining (Task 1 Step 8, Task 2 Step 5) is genuine golden-value regeneration with explicit triage criteria, not hand-waving. +- **Type consistency:** `.with_mock_seed(seed, expr, stage = "baseline")` signature and `.MOCK_STAGES` names (`baseline`/`postprocess`/`formula`/`correlate`) are used identically at every call site; `stage` passed as a named argument everywhere because `expr` is a large positional block. +- **Sequencing risk:** Task 1 changes RNG output → its own tests re-baseline within the task, so each task ends green. Legacy wrap (Task 2) is the highest-risk edit; its isolation test is written red-first to prove the fix. +- **Open item carried from ADR:** pinned-reference storage location — chosen here as inline in the test file (simplest; a fixture under `_snaps/` is unnecessary for three scalars). diff --git a/development/plans/2026-07-16-formula-evaluator.md b/development/plans/2026-07-16-formula-evaluator.md new file mode 100644 index 0000000..e56dd30 --- /dev/null +++ b/development/plans/2026-07-16-formula-evaluator.md @@ -0,0 +1,692 @@ +# Formula-Derived Variables Implementation Plan (#39, Phase A) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Variables carrying a `mockFormula` expression are generated by evaluating that expression over already-generated columns — in dependency order, in a restricted sandbox — so mock data can exercise derived-variable pipelines. Algebraic formulas only (Phase A); `Func::` dispatch stays excluded. + +**Architecture:** A third pipeline stage. `generate_mock_data_native()` skips `type = "formula"` variables; a new exported `evaluate_mock_formulas(data, spec, seed = NULL)` computes them post-baseline (topologically ordered among themselves, evaluated in an environment exposing only generated columns + a fixed base-function allow-list, under `.with_mock_seed(stage = "formula")`); `postprocess_mock_data()` then applies missing codes/garbage to all columns including derived ones. Orchestrator order: baseline → formulas → postprocess. Formulas are computed on *clean* baseline values (before contamination), then may receive their own missing codes/garbage — and postprocess's existing missing-column check makes running the stages out of order fail loudly. + +**Tech Stack:** base R (`str2lang`, `all.vars`, `all.names`, `eval` with restricted environments — no new dependency), testthat edition 3, roxygen2. + +**Decision source:** [development/adr/v05-formula-evaluator.md](../adr/v05-formula-evaluator.md) (ACCEPTED 2026-07-07): D1(a) `mockFormula` column on `variable_details`; D2(a) post-baseline stage; D3 restricted sandbox + ratified allow-list; D4 Phase A only; D5 diagnostics marking; D6 `mockFormula` ⇒ generated, bare `DerivedVar::`/`Func::` ⇒ still excluded. + +**Base:** branch `v05-seed-contract` head `0f9bda8` (#38 provides `.with_mock_seed(stage=)` and `.MOCK_STAGES["formula"]`). If PR #48 has merged, branch `v05-formula-evaluator` off `dev` instead — verify `.MOCK_STAGES` exists before starting. + +## Global Constraints + +Inherited from `development/post-v040-development-plan.md` Global Constraints (commit style, fixtures via `system.file()`+skip, CI locale, no check artefacts, review before push). Plus: + +- **Zero seeded-output change for existing specs.** This feature is additive; specs without formula variables must produce byte-identical output. Enforced mechanically by #48's pins in `tests/testthat/test-seed-contract.R`: the frozen `.MOCK_STAGES` equality test and the baseline/postprocess pinned values. If any of those fail during this work, the change is wrong — fix the code, never the pins. +- **Formula variables draw exclusively from the `formula` sub-stream** (`.MOCK_STAGES["formula"]`, index 2 — already reserved). Note: the Phase A allow-list contains no RNG functions, so Phase A formulas are deterministic; the sub-stream wrap is structural (future-proofing), not load-bearing yet. +- **Sandbox is closed:** evaluation must not reach the caller's frame, globalenv, or any function outside the allow-list. `enclos = parent.frame()` (the spike's approach) is prohibited. +- **recodeflow semantics untouched:** `DerivedVar::`/`Func::` meaning unchanged; `mockFormula` is a MockData-extension column; metadata without it behaves identically to today (regression-tested). +- Test suite baseline at plan date: FAIL 0 | WARN 35 | SKIP 11 | PASS 699. Do not exceed WARN/SKIP with new noise. + +## Evidence base (verified 2026-07-16 at `0f9bda8`) + +- Spike machinery to recover: `git show f4f9b41:development/v04-simstudy-spike/prototype.R` — `formula_dependencies()` (`all.vars(str2lang(...))`), `order_spec_by_dependencies()` (progress-loop topological sort; cycle message "Formula dependency cycle or unresolved ordering among: ..."), `validate_formula_referents()`. Its eval (`enclos = parent.frame()`) is NOT recovered — replaced by the D3 sandbox. +- Spec fields `formula` / `depends_on` already exist on `.new_mock_spec_variable()` (`R/mock_spec.R:101,106`), unused. +- Per-variable validator branches on type at `R/mock_spec.R:815/834/852`, else **errors "unsupported type"** at `:855` — a `formula` branch is mandatory or construction-time validation rejects formula specs. +- v0.4 supported-check **already flags any formula-carrying variable as unsupported** (`R/create_mock_data.R:12-18`) → legacy fallback. Task 3 inverts this for `type == "formula"` (and keeps flagging a stray `formula` on a *non*-formula variable). +- Orchestrator pipeline seam: `R/create_mock_data.R:98-102` (`baseline <- generate_mock_data_native(...)` then `postprocess_mock_data(...)`). +- Native dispatch `.generate_native_variable()` at `R/mock_spec_native.R:314-323`; generation loop `columns <- lapply(spec$variables, .generate_native_variable, n = n)` at `:383` — formula vars must be filtered out before this lapply. +- `postprocess_mock_data()` missing-column check (`setdiff(names(spec$variables), names(data))`) means skipping the formula stage fails loudly. `.postprocess_variable()` dispatches on `type == "categorical" || rtype == "integer"` (`R/mock_spec_postprocess.R:154`) — formula variables carry a concrete `rtype`, so missing-code/garbage handling keys off rtype as usual; Task 3 verifies with a test. +- Adapter: `mock_spec_from_recodeflow(..., exclude_derived = TRUE)` excludes `identify_derived_vars()` hits at `R/mock_spec_recodeflow.R:471-472`; kind resolution `.recodeflow_variable_kind(var_row)` at `:117`; per-variable construction `.recodeflow_to_spec_variable(var_row, details, databaseStart)` at `:306`; column access pattern `.row_character(row, col, default)`. +- Docs that will be stale the moment this merges (Task 4): `vignettes/design-philosophy-v04.qmd` "What is deliberately deferred" (formula variables listed as deferred); `vignettes/migrating-from-v03-v04.qmd` fallback-conditions section (names formula variables as a legacy-fallback trigger). + +### Decisions carried from the ADR, resolved here + +- **Direct API:** a dedicated `mock_formula()` constructor (+ `mock_spec_formula()` composable form), not a `formula=` argument on existing constructors — formula variables have no `range`/`levels`/`distribution`, so overloading the other constructors would create nonsense parameter combinations. +- **Diagnostics fields (D5):** `derived = TRUE`, `formula` (string), `depends_on` (character vector) on the variable's `mockdata_diagnostics` entry. +- **`mockFormula` read semantics:** first non-blank `mockFormula` among the variable's `variable_details` rows (a variable's details may span multiple rows; the formula is a variable-level fact stored row-level per ADR). +- **Allow-list:** the ADR's ratified set **plus `c`** (required by `cut(breaks = c(...))`; the ADR authorizes widening on concrete need). + +### Task map + +``` +Task 1: spec layer — constructor, validator branch, allow-list, deps/order/referent checks +Task 2: evaluator — evaluate_mock_formulas(); native backend skips formula vars +Task 3: wiring — adapter mockFormula + D6 carve-out; orchestrator stage; diagnostics; e2e +Task 4: docs — NEWS, roxygen family, stale-vignette fixes, ADR status note +``` + +--- + +### Task 1: Formula spec layer + +**Files:** +- Modify: `R/mock_spec.R` (constructors near `mock_categorical()`; validator continuous/categorical/date chain at :815-855; `validate_mock_spec()` for spec-level checks) +- Create: `R/mock_spec_formula.R` (allow-list, dependency/order/referent helpers — the evaluator itself is Task 2) +- Test: `tests/testthat/test-formula-vars.R` (new) + +**Interfaces:** +- Produces (consumed by Tasks 2–3): + - `mock_formula(name, formula, rtype = "double", missing_codes = numeric(0), missing_proportions = numeric(0), garbage_rules = list(), provenance = NULL, model_hint = "auto")` → validated one-variable `mock_spec`; `mock_spec_formula(...)` → `mock_spec_variable` with `type = "formula"`. + - `.formula_allowlist` (character vector), `.formula_dependencies(variable)`, `.formula_function_symbols(variable)`, `.order_formula_variables(spec)` (returns ordered names of formula variables only; cycle error), `.validate_formula_referents(spec)` (unknown-referent + disallowed-symbol errors). + +- [x] **Step 1: Write the failing tests** + +```r +# tests/testthat/test-formula-vars.R +# Formula-derived variables (#39, Phase A). ADR: development/adr/v05-formula-evaluator.md + +test_that("mock_formula constructs a validated formula variable", { + spec <- mock_spec( + mock_spec_continuous("height", range = c(1.4, 2.1)), + mock_spec_continuous("weight", range = c(45, 150)), + mock_spec_formula("bmi", formula = "weight / (height^2)") + ) + expect_true(validate_mock_spec(spec)$valid) + expect_identical(spec$variables$bmi$type, "formula") + expect_identical(spec$variables$bmi$formula, "weight / (height^2)") + expect_setequal(spec$variables$bmi$depends_on, c("weight", "height")) +}) + +test_that("a formula referencing an unknown variable fails validation", { + expect_error( + mock_spec( + mock_spec_continuous("height", range = c(1.4, 2.1)), + mock_spec_formula("bmi", formula = "weight / (height^2)") + ), + "references unknown variable" + ) +}) + +test_that("a disallowed function symbol fails validation before generation", { + expect_error( + mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("bad", formula = "system('echo pwned') + x") + ), + "not permitted in mockFormula" + ) +}) + +test_that("a dependency cycle among formula variables errors clearly", { + expect_error( + mock_spec( + mock_spec_formula("a", formula = "b + 1"), + mock_spec_formula("b", formula = "a + 1") + ), + "dependency cycle" + ) +}) + +test_that("an unparseable formula errors at validation", { + expect_error( + mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("bad", formula = "x +* 2") + ), + "could not be parsed" + ) +}) +``` + +- [x] **Step 2: Run to verify failure** — `Rscript -e 'devtools::load_all(quiet = TRUE); testthat::test_file("tests/testthat/test-formula-vars.R")'`. Expected: FAIL, `could not find function "mock_spec_formula"`. + +- [x] **Step 3: Create `R/mock_spec_formula.R` helpers** + +```r +# ============================================================================== +# Formula-derived variables (#39, Phase A) — spec-side helpers. +# Evaluation itself lives in evaluate_mock_formulas() (same file, Task 2). +# ADR: development/adr/v05-formula-evaluator.md +# ============================================================================== + +# Base functions/operators a mockFormula may call. Deliberately closed and +# RNG-free: Phase A formulas are deterministic transformations of generated +# columns. Widen only on concrete need (ADR D3). +.formula_allowlist <- c( + "+", "-", "*", "/", "^", "%%", "%/%", "(", + "<", "<=", ">", ">=", "==", "!=", "&", "|", "!", + "ifelse", "pmin", "pmax", "min", "max", "abs", "round", "floor", + "ceiling", "sqrt", "exp", "log", "log10", "sum", "mean", "cut", + "as.numeric", "as.integer", "as.factor", "factor", "c" +) + +#' @noRd +.parse_mock_formula <- function(variable) { + tryCatch( + str2lang(variable$formula), + error = function(e) { + stop( + "Formula for variable '", variable$name, "' could not be parsed: ", + conditionMessage(e), + call. = FALSE + ) + } + ) +} + +#' @noRd +.is_formula_variable <- function(variable) { + identical(variable$type, "formula") +} + +#' @noRd +.formula_dependencies <- function(variable) { + if (!.is_formula_variable(variable)) { + return(character(0)) + } + all.vars(.parse_mock_formula(variable)) +} + +#' @noRd +.formula_function_symbols <- function(variable) { + expr <- .parse_mock_formula(variable) + setdiff(all.names(expr), all.vars(expr)) +} + +#' @noRd +.validate_formula_referents <- function(spec) { + spec_names <- names(spec$variables) + for (variable in spec$variables) { + if (!.is_formula_variable(variable)) next + missing <- setdiff(.formula_dependencies(variable), spec_names) + if (length(missing) > 0) { + stop( + "Formula for variable '", variable$name, + "' references unknown variable(s): ", + paste(missing, collapse = ", "), + call. = FALSE + ) + } + disallowed <- setdiff(.formula_function_symbols(variable), .formula_allowlist) + if (length(disallowed) > 0) { + stop( + "Formula for variable '", variable$name, + "' uses function(s) not permitted in mockFormula expressions: ", + paste(disallowed, collapse = ", "), + call. = FALSE + ) + } + } + invisible(TRUE) +} + +#' @noRd +.order_formula_variables <- function(spec) { + formula_names <- names(spec$variables)[ + vapply(spec$variables, .is_formula_variable, logical(1)) + ] + remaining <- formula_names + ordered <- character(0) + while (length(remaining) > 0) { + progressed <- FALSE + for (name in remaining) { + deps <- intersect(.formula_dependencies(spec$variables[[name]]), formula_names) + if (all(deps %in% ordered)) { + ordered <- c(ordered, name) + remaining <- setdiff(remaining, name) + progressed <- TRUE + } + } + if (!progressed) { + stop( + "Formula dependency cycle or unresolved ordering among: ", + paste(remaining, collapse = ", "), + call. = FALSE + ) + } + } + ordered +} +``` + +- [x] **Step 4: Constructors in `R/mock_spec.R`** + +`mock_spec_formula()` (place after `mock_spec_date()`), populating the existing `formula`/`depends_on` fields via `.new_mock_spec_variable()`: + +```r +#' Create a formula-derived variable specification +#' +#' `mock_spec_formula()` describes a variable computed from other generated +#' variables by evaluating an algebraic expression (a `mockFormula`), rather +#' than sampled from a distribution. Evaluation happens in a restricted +#' environment exposing only the generated columns and a fixed allow-list of +#' base functions; see [evaluate_mock_formulas()]. +#' +#' @param name Variable name. +#' @param formula Character scalar. An algebraic expression over other +#' variable names, e.g. `"weight / (height^2)"`. +#' @param rtype R output type for the computed column. Defaults to `"double"`. +#' @param missing_codes,missing_proportions,garbage_rules,provenance,model_hint +#' As for other variable specifications; applied by post-processing. +#' +#' @return A `mock_spec_variable` object of type `"formula"`. +#' @family mock specification APIs +#' @seealso [mock_formula()], [evaluate_mock_formulas()] +#' @export +mock_spec_formula <- function(name, + formula, + rtype = "double", + missing_codes = numeric(0), + missing_proportions = numeric(0), + garbage_rules = list(), + provenance = "direct", + model_hint = "auto") { + variable <- .new_mock_spec_variable( + name = name, + type = "formula", + rtype = rtype, + formula = formula, + missing_codes = missing_codes, + missing_proportions = missing_proportions, + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = model_hint + ) + # Parse failures are reported by validate_mock_spec(), not the constructor, + # matching the sibling constructors' defer-all-validation contract. + # depends_on is computed once here at construction time; it is not + # re-synced if `formula` is mutated afterwards. + variable$depends_on <- tryCatch( + .formula_dependencies(variable), + error = function(e) character(0) + ) + variable +} +``` + +`mock_formula()` (simple direct API, mirroring `mock_continuous()`'s shape: wraps `mock_spec_formula()` in `mock_spec(...)` with `.direct_api_provenance("mock_formula", provenance)`). Follow the `mock_continuous()` pattern at `R/mock_spec.R:287` exactly, forwarding the same parameters. + +- [x] **Step 5: Validator branches** + +Per-variable (`R/mock_spec.R`, extend the type chain before the `:855` unsupported-type error): + +```r + } else if (variable$type == "formula") { + f <- variable$formula + if (is.null(f) || !is.character(f) || length(f) != 1 || is.na(f) || + trimws(f) == "") { + errors <- c(errors, paste0( + "Variable '", variable$name, + "' formula type requires a non-empty character formula." + )) + } else { + parse_error <- tryCatch({ str2lang(f); NULL }, error = function(e) conditionMessage(e)) + if (!is.null(parse_error)) { + errors <- c(errors, paste0( + "Variable '", variable$name, "' formula could not be parsed: ", parse_error + )) + } + } + if (!variable$rtype %in% c("double", "numeric", "integer", "factor", "character", "logical")) { + errors <- c(errors, paste0( + "Variable '", variable$name, "' has unsupported formula rType '", variable$rtype, "'." + )) + } + } +``` + +Spec-level (`validate_mock_spec()`, after per-variable validation succeeds): call `.validate_formula_referents(spec)` and `.order_formula_variables(spec)` inside a tryCatch that converts their `stop()`s into validation errors in the same style as existing spec-level checks — read how `validate_mock_spec()` accumulates errors and match it. Constructor-time validation (`mock_spec(validate = TRUE)`) then rejects unknown referents, disallowed symbols, and cycles at construction, which is what the Step 1 tests assert. + +- [x] **Step 6: Run the test file** — expected all PASS. **Bounded contingency:** if construction is *lazy* for spec-level checks (errors only via explicit `validate_mock_spec()`), check how `mock_spec()` invokes validation (`R/mock_spec.R:239` area, `validate = TRUE` default → strict) — the seed/exponential work confirmed construction validates eagerly, so eager is expected; if a test still fails, STOP and report rather than weakening the test. + +- [x] **Step 7: `devtools::document()`; full suite; commit** + +Run full suite — expected: baseline counts + new passes, no new WARN/SKIP. + +```bash +git add R/mock_spec.R R/mock_spec_formula.R tests/testthat/test-formula-vars.R man/ NAMESPACE +git commit -m "Add formula variable type to the mock_spec layer (#39) + +mock_formula()/mock_spec_formula() constructors populate the reserved +formula/depends_on fields; validation rejects unparseable formulas, +unknown referents, disallowed function symbols, and dependency cycles +at construction, using the allow-list and topological ordering +recovered from the v0.4 spike." +``` + +### Task 2: Evaluator + native-backend skip + +**Files:** +- Modify: `R/mock_spec_formula.R` (add `evaluate_mock_formulas()` + eval env helpers) +- Modify: `R/mock_spec_native.R` (`generate_mock_data_native()` — filter formula vars from the `:383` lapply) +- Test: `tests/testthat/test-formula-vars.R` (extend) + +**Interfaces:** +- Consumes: Task 1 helpers; `.with_mock_seed(seed, expr, stage)` and `.MOCK_STAGES["formula"]` from #38. +- Produces: exported `evaluate_mock_formulas(data, spec, seed = NULL)` → data frame with formula columns appended (order of existing columns unchanged; no-op returning `data` when the spec has no formula variables). Consumed by Task 3's orchestrator. + +- [x] **Step 1: Write the failing tests** + +```r +test_that("evaluate_mock_formulas computes algebraic derivations correctly", { + spec <- mock_spec( + mock_spec_continuous("height", range = c(1.4, 2.1)), + mock_spec_continuous("weight", range = c(45, 150)), + mock_spec_formula("bmi", formula = "weight / (height^2)") + ) + baseline <- generate_mock_data_native(spec, n = 50, seed = 11) + expect_false("bmi" %in% names(baseline)) # native skips formula vars + result <- evaluate_mock_formulas(baseline, spec, seed = 11) + expect_identical(result$bmi, baseline$weight / (baseline$height^2)) + expect_identical(result$height, baseline$height) # inputs untouched +}) + +test_that("formula-of-a-formula evaluates in dependency order", { + spec <- mock_spec( + mock_spec_continuous("x", range = c(1, 10)), + mock_spec_formula("obese_flag", formula = "ifelse(y > 15, 1, 0)", rtype = "integer"), + mock_spec_formula("y", formula = "x * 2") + ) + baseline <- generate_mock_data_native(spec, n = 20, seed = 5) + result <- evaluate_mock_formulas(baseline, spec) + expect_identical(result$y, baseline$x * 2) + expect_identical(result$obese_flag, as.integer(ifelse(baseline$x * 2 > 15, 1L, 0L))) +}) + +test_that("evaluation cannot reach the caller's environment", { + leaky <- 999 + spec <- mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("z", formula = "x + leaky") + ) + # 'leaky' is not a spec variable -> referent validation must reject it at + # construction; the sandbox property is enforced before eval ever runs. + expect_error(validate_mock_spec(spec), NA) # if construction was lazy +}, ) +# NOTE to implementer: the construction call above should already error under +# eager validation ("references unknown variable"), making the test body as +# written unreachable. Restructure to expect_error(mock_spec(...), "references +# unknown variable") if construction is eager (expected). Keep ONE test that +# pins the sandbox at eval level too: + +test_that("eval environment exposes only columns and allow-listed functions", { + spec <- mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("z", formula = "sqrt(x)") + ) + baseline <- generate_mock_data_native(spec, n = 5, seed = 3) + env_probe <- evaluate_mock_formulas(baseline, spec) + expect_identical(env_probe$z, sqrt(baseline$x)) +}) + +test_that("evaluate_mock_formulas is a no-op without formula variables", { + spec <- mock_continuous("x", range = c(0, 1)) + baseline <- generate_mock_data_native(spec, n = 5, seed = 3) + expect_identical(evaluate_mock_formulas(baseline, spec), baseline) +}) + +test_that("evaluate_mock_formulas handles n = 0", { + spec <- mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("z", formula = "x * 2") + ) + baseline <- generate_mock_data_native(spec, n = 0, seed = 3) + result <- evaluate_mock_formulas(baseline, spec) + expect_identical(nrow(result), 0L) + expect_true("z" %in% names(result)) +}) + +test_that("formula stage leaves the caller's RNG untouched and is deterministic", { + spec <- mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("z", formula = "x * 2") + ) + baseline <- generate_mock_data_native(spec, n = 10, seed = 7) + set.seed(123); before <- .Random.seed + a <- evaluate_mock_formulas(baseline, spec, seed = 7) + expect_identical(.Random.seed, before) + expect_identical(a, evaluate_mock_formulas(baseline, spec, seed = 7)) +}) + +test_that("rtype coercion applies to formula outputs", { + spec <- mock_spec( + mock_spec_continuous("x", range = c(1, 9)), + mock_spec_formula("band", formula = "ifelse(x > 5, 'high', 'low')", rtype = "factor") + ) + baseline <- generate_mock_data_native(spec, n = 20, seed = 9) + result <- evaluate_mock_formulas(baseline, spec) + expect_s3_class(result$band, "factor") +}) +``` + +(Clean up the sandbox-test scaffolding per the NOTE — the plan expects eager construction validation, so the "leaky" test becomes `expect_error(mock_spec(...), "references unknown variable")`.) + +- [x] **Step 2: Run — verify failures** (`could not find function "evaluate_mock_formulas"`; the native-skip expectation fails because `.generate_native_variable()` stops on type "formula"). + +- [x] **Step 3: Native skip** + +In `generate_mock_data_native()` (`R/mock_spec_native.R`, around `:383`), before the lapply, filter: + +```r + generated <- spec$variables[ + !vapply(spec$variables, .is_formula_variable, logical(1)) + ] + columns <- lapply(generated, .generate_native_variable, n = n) +``` + +(Adapt names to the actual local code; the empty-formula-only-spec case must still return an `n`-row 0-column frame via the existing `.empty_native_data(n)` path — check how the current code assembles `columns` into a data frame and preserve it.) Document the skip in the function's roxygen `@details`: formula variables are produced by `evaluate_mock_formulas()`, not the backend. + +- [x] **Step 4: The evaluator** (append to `R/mock_spec_formula.R`) + +```r +#' @noRd +.formula_parent_env <- function() { + env <- new.env(parent = emptyenv()) + for (fn in .formula_allowlist) { + env[[fn]] <- get(fn, envir = baseNamespace()) + } + env +} + +#' Evaluate formula-derived variables over generated data +#' +#' Computes each `type = "formula"` variable in `spec` by evaluating its +#' expression over the columns of `data`, in dependency order. Evaluation runs +#' in a restricted environment exposing only the data columns and a fixed +#' allow-list of base functions — formulas cannot reach the caller's +#' environment. Any randomness would draw from the isolated `formula` +#' sub-stream (see the v0.5 seed contract), though the Phase A allow-list is +#' RNG-free, so evaluation is deterministic given `data`. +#' +#' @param data Data frame of generated baseline values (from +#' [generate_mock_data_native()] or [generate_mock_data_simstudy()]). +#' @param spec A `mock_spec`. Non-formula variables must already be columns of +#' `data`. +#' @param seed Optional whole-number seed; reserved for future formulas with +#' random components. The caller's RNG state and kind are restored on exit. +#' +#' @return `data` with one appended column per formula variable. Returns +#' `data` unchanged if the spec has no formula variables. +#' @seealso [mock_formula()], [postprocess_mock_data()] +#' @export +evaluate_mock_formulas <- function(data, spec, seed = NULL) { + if (!is.data.frame(data)) { + stop("data must be a data frame.", call. = FALSE) + } + # Strict-validate first, exactly as postprocess_mock_data() does (Task 2 + # review fix): the accumulate-and-skip behaviour inside + # .order_formula_variables()/.validate_formula_referents() is correct for + # validate_mock_spec()'s multi-error reporting, but it means an unparseable, + # cyclic, or invalid-referent formula would otherwise be silently excluded + # from `ordered` below rather than reported. Strict validation already runs + # .validate_formula_referents()/.order_formula_variables(), so the direct + # .validate_formula_referents() call that used to live below is redundant + # and has been removed. + validate_mock_spec(spec, n = nrow(data), strict = TRUE) + + ordered <- .order_formula_variables(spec) + if (length(ordered) == 0) { + return(data) + } + + non_formula <- setdiff(names(spec$variables), names(spec$variables)[ + vapply(spec$variables, .is_formula_variable, logical(1)) + ]) + missing_inputs <- setdiff(non_formula, names(data)) + if (length(missing_inputs) > 0) { + stop( + "data is missing generated column(s) required by formulas: ", + paste(missing_inputs, collapse = ", "), + call. = FALSE + ) + } + + .with_mock_seed(seed, stage = "formula", { + fn_env <- .formula_parent_env() + for (name in ordered) { + variable <- spec$variables[[name]] + values <- eval( + .parse_mock_formula(variable), + envir = as.list(data), + enclos = fn_env + ) + if (length(values) == 1 && nrow(data) != 1) { + values <- rep(values, nrow(data)) # scalar formulas broadcast + } + if (length(values) != nrow(data)) { + stop( + "Formula for variable '", name, "' returned length ", length(values), + ", expected ", nrow(data), ".", + call. = FALSE + ) + } + data[[name]] <- .coerce_formula_rtype(values, variable$rtype, name) + } + data + }) +} + +#' @noRd +.coerce_formula_rtype <- function(values, rtype, variable_name) { + switch(rtype, + double = , + numeric = as.numeric(values), + integer = as.integer(values), + factor = as.factor(values), + character = as.character(values), + logical = as.logical(values), + stop( + "Variable '", variable_name, "' has unsupported formula rType '", + rtype, "'.", + call. = FALSE + ) + ) +} +``` + +Note on `envir = as.list(data)`: eval with a list envir + `enclos` gives column lookup first, then the allow-list env, whose parent is `emptyenv()` — nothing else is reachable. Verify with the sandbox tests; if `as.list()` + enclos misbehaves for `nrow(data) == 0` columns, use `list2env(as.list(data), parent = fn_env)` and drop `enclos` — either satisfies the tests; pick the one that passes and note which in the report. + +- [x] **Step 5: Run tests → PASS; full suite; commit** (message: "Add evaluate_mock_formulas() with sandboxed post-baseline evaluation (#39)" + body noting native backend skips formula variables and the sandbox contract). + +### Task 3: Adapter + orchestrator wiring, diagnostics, end-to-end + +**Files:** +- Modify: `R/mock_spec_recodeflow.R` (kind resolution `:117`; `.recodeflow_to_spec_variable()` `:306`; `exclude_derived` block `:471-472`) +- Modify: `R/create_mock_data.R` (unsupported-check `:12-18`; pipeline `:98-102`) +- Modify: `R/mock_spec_postprocess.R` (`.postprocess_empty_diagnostics()` — D5 fields) +- Test: `tests/testthat/test-formula-vars.R` (extend) + +**Interfaces:** +- Consumes: Tasks 1–2. Produces: `create_mock_data()` end-to-end support; `mockFormula` metadata column. + +- [x] **Step 1: Write the failing tests** (model in-memory metadata on the recodeflow-exponential integration test in `tests/testthat/test-native-exponential.R` — reuse its exact column structure, adding a `mockFormula` column on the details rows): + +```r +test_that("mockFormula metadata generates a derived column end-to-end", { + # variables: height, weight (continuous) + bmi (Continuous rType double, + # detail row carrying DerivedVar:: recStart AND a mockFormula) + # -> bmi must be GENERATED (D6 carve-out), on the v0.4 path, with + # diagnostics marking it derived. + + result <- suppressMessages(create_mock_data("study", variables, variable_details, n = 30, seed = 4)) + expect_true("bmi" %in% names(result)) + diag <- attr(result, "mockdata_diagnostics") + expect_false(is.null(diag)) # v0.4 path, not legacy + expect_true(isTRUE(diag$variables$bmi$derived)) + expect_setequal(diag$variables$bmi$depends_on, c("height", "weight")) +}) + +test_that("DerivedVar rows without mockFormula stay excluded (D6, backward compat)", { + + result <- suppressMessages(create_mock_data("study", variables, variable_details, n = 10, seed = 4)) + expect_false("bmi" %in% names(result)) +}) + +test_that("formula variables receive their own missing codes in postprocess", { + spec <- mock_spec( + mock_spec_continuous("x", range = c(1, 9)), + mock_spec_formula("z", formula = "x * 2", + missing_codes = -99, missing_proportions = 0.3) + ) + baseline <- generate_mock_data_native(spec, n = 200, seed = 8) + staged <- evaluate_mock_formulas(baseline, spec, seed = 8) + result <- postprocess_mock_data(staged, spec, seed = 8) + expect_true(any(result$z == -99)) + expect_true(all(result$z[result$z != -99] == staged$z[result$z != -99])) +}) + +test_that("existing formula-free seeded output is unchanged by the formula stage", { + # The #48 pinned-reference tests already enforce this mechanically; this + # test additionally pins the orchestrator route: a formula-free spec must + # not pass through evaluate_mock_formulas' seeded wrapper at all. + spec <- mock_continuous("x", range = c(0, 1)) + baseline <- generate_mock_data_native(spec, n = 3, seed = 20260706) + expect_identical(evaluate_mock_formulas(baseline, spec, seed = 20260706), baseline) +}) +``` + +- [x] **Step 2: Adapter changes** (`R/mock_spec_recodeflow.R`): + 1. A helper `.details_mock_formula(details)` returning the first non-blank `mockFormula` among the variable's detail rows ("" default; column may be absent). + 2. In `.recodeflow_to_spec_variable()`: if `.details_mock_formula(details)` is non-blank, construct via `mock_spec_formula(name, formula = , rtype = , missing_codes/garbage from the existing extraction)` instead of the kind-dispatched constructor. The variable's `variableType` may say Continuous — the formula type takes precedence; reuse the row's already-extracted missing-code/garbage settings so postprocess behaviour matches other variables. + 3. The `exclude_derived` block (`:471-472`): filter the `identify_derived_vars()` hits to exclude only those **without** a non-blank `mockFormula` in their detail rows (D6 carve-out). Keep `exclude_derived = TRUE` default semantics otherwise. The `has_mock_formula` check must use the same databaseStart-filtered details view as construction (`.filter_recodeflow_details()`), never the raw `variable == v` subset — otherwise a variable with a mockFormula in one cycle but not another gets kept for a cycle where construction can't find a formula, crashing generation instead of staying excluded (#39 review finding). + +- [x] **Step 3: Orchestrator changes** (`R/create_mock_data.R`): + 1. Unsupported-check (`:12-18`): replace the blanket `has_formula → TRUE` with: `if (variable$type == "formula") return(FALSE)` (supported), and keep `has_formula && type != "formula"` → TRUE (a stray formula on a non-formula variable remains a fallback trigger — defensive). + 2. Pipeline (`:98-102`): insert the stage — + +```r + baseline <- generate_mock_data_native(spec, n = n, seed = seed) + staged <- evaluate_mock_formulas(baseline, spec, seed = seed) + postprocess_mock_data(staged, spec, seed = seed) +``` + + (Update the comment above it to name all three sub-streams.) + +- [x] **Step 4: Diagnostics (D5)** — in `.postprocess_empty_diagnostics()` (`R/mock_spec_postprocess.R`), when building each variable's entry, add for formula variables: `derived = TRUE`, `formula = variable$formula`, `depends_on = variable$depends_on`. Read the function first and match its list-building style exactly. + +- [x] **Step 5: Run tests → PASS. Full suite — the #48 stage-index and pinned-value tests MUST still pass untouched (they prove no output shift). `devtools::check()` clean. Commit** ("Wire mockFormula through the recodeflow adapter and orchestrator (#39)" + body covering the D6 carve-out, the unsupported-check inversion, diagnostics fields). + +### Task 4: Docs, NEWS, stale-claims sweep + +**Files:** +- Modify: `NEWS.md`; `R/` roxygen touch-ups from review; `vignettes/design-philosophy-v04.qmd`; `vignettes/migrating-from-v03-v04.qmd`; `development/adr/v05-formula-evaluator.md` (status line) + +- [x] **Step 1: NEWS** (feature bullet under the development-version heading, NOT in the breaking-change section — this is additive): + +```markdown +## Formula-derived variables (#39, Phase A) + +- Variables can now be generated from algebraic expressions over other + generated variables via a new `mockFormula` column in `variable_details` + (e.g. `weight / (height^2)`), or the new `mock_formula()` direct API. + Formulas are evaluated in dependency order by `evaluate_mock_formulas()`, + in a restricted environment exposing only the generated columns and a + fixed allow-list of base functions. `DerivedVar::`/`Func::` semantics are + unchanged: derived variables without a `mockFormula` remain excluded from + generation, and `Func::` dispatch is not yet supported. +``` + +- [x] **Step 2: Stale-claims sweep** (the #45/#48 reviews both caught rendered-false docs — do not repeat that): + - `vignettes/design-philosophy-v04.qmd` "What is deliberately deferred": formula variables are no longer fully deferred — rewrite that sentence to say algebraic `mockFormula` generation landed in v0.5 (Phase A) while `Func::` dispatch and a general evaluator remain deferred. + - `vignettes/migrating-from-v03-v04.qmd`: the fallback-conditions section names formula variables as a legacy-fallback trigger — verify and update: variables with `mockFormula` now route natively; a stray `formula` field on a non-formula variable still falls back. Run `quarto render vignettes/migrating-from-v03-v04.qmd --to html` and confirm no chunk renders a claim the code contradicts (delete generated output after). + - Grep vignettes + README for `mockFormula`, "formula", "derived" claims: `grep -rn "formula" vignettes/*.qmd README.md | grep -iv "mockFormula"` — inspect hits that assert formulas are unsupported/deferred; fix only genuinely contradicted statements. + - `development/adr/v05-formula-evaluator.md`: append to the Status line: "Implemented in PR # (Phase A)." + +- [x] **Step 3: `devtools::document()`; full suite; `devtools::check()`; commit** ("Document formula-derived variables and refresh deferred-work claims (#39)"). + +--- + +## Self-review (performed at plan date) + +- **ADR coverage:** D1(a) Task 3 Step 2; D2(a) Tasks 2–3 (post-baseline stage, orchestrator order baseline→formula→postprocess); D3 Task 1 allow-list + Task 2 restricted env (+ sandbox tests); D4 Phase A only (no Func:: anywhere; D6 keeps Func:: rows excluded); D5 Task 3 Step 4; D6 Task 3 Steps 1–2. ADR test-strategy items 1–6 all appear across Task 1/2/3 tests. +- **No-break guarantee:** enforced three ways — the #48 frozen-index and pinned-value tests (must pass untouched), the Task 3 formula-free no-op test, and the native skip changing nothing for formula-free specs. +- **Placeholder scan:** Task 3 Step 1's metadata fixtures are directed to copy the existing integration test's exact column structure (the #37 review taught that fixture shapes must come from the suite, not be invented) — the `` markers name the source pattern and the exact distinguishing values; all other code is complete. Two bounded contingencies (eager-vs-lazy validation in Task 1 Step 6; `as.list`+enclos vs `list2env` in Task 2 Step 4) give both branches concretely. +- **Type consistency:** `type = "formula"`, `.is_formula_variable()`, `.formula_allowlist`, `evaluate_mock_formulas(data, spec, seed)`, diagnostics fields `derived`/`formula`/`depends_on` used identically across tasks; `stage = "formula"` matches `.MOCK_STAGES` from #38. +- **Known risk carried:** `.postprocess_variable()`'s type dispatch (`:154`) was only inspected, not executed, for formula variables — Task 3's missing-codes test exercises it; if it misroutes (e.g., factor formula var), that surfaces there, in-task. diff --git a/development/plans/2026-09-24-survival-dates.md b/development/plans/2026-09-24-survival-dates.md new file mode 100644 index 0000000..e26fa24 --- /dev/null +++ b/development/plans/2026-09-24-survival-dates.md @@ -0,0 +1,2660 @@ +# Metadata-Driven Survival Dates Implementation Plan (#40) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `create_mock_data()` generates entry, event, death, loss-to-follow-up and administrative-censoring dates from metadata alone, by computing `type = "survival"` variables from their anchor dates in a post-baseline stage. + +**Architecture:** A new spec type `survival` (rtype `date`). Both backends skip derived types (formula, survival) in baseline generation. A new exported `generate_survival_dates(data, spec, seed)` processes survival variables one at a time in dependency order (draw, then the before-anchor and `censored_by` rules) under `.with_mock_seed(stage = "survival")`. `create_mock_data()` runs baseline → survival → formulas → postprocess. The recodeflow adapter builds survival variables from `variables.csv` rows that set `anchor`. The legacy engine is unchanged apart from a deprecation warning, and the legacy dispatcher stops on anchored metadata. + +**Tech stack:** base R (no new dependency), testthat 3e (3.2.3), roxygen2. + +**Spec:** [development/adr/v05-survival-dates.md](../adr/v05-survival-dates.md) (ACCEPTED 2026-09-24, D1–D10; amended the same day after an external design review: chained censoring rejected, D8's status/time example corrected, data layers named). Issues: #40; concerns #54, #55, #56, #57 and #15; prerequisite #58 (PR #59). + +**Base:** branch `v05-survival-dates` at `2548b51`: `v05-formula-evaluator` (#52, which carries #51) + the ADR commits + PR #59 (#58 range missing codes and minimal-example fixture corrections) merged in. Do not start until `git log --oneline -1` shows `2548b51` or a descendant. + +**Prototype evidence (2026-09-24):** the Task 1 characterization tests were run against the legacy engine and pass; the Task 3 draw routine, rules and ordering were prototyped and pass 11 parity checks (competing risk both directions, exact event count, whole days inside the window, Gompertz #54 parity, exponential bounds, `n = 0`, flooring to zero events, `NA` anchors, draw order, cycle error). The Task 3 pinned values were computed independently by emulating `.with_mock_seed()` for stage index 4, not captured from the implementation. + +## Global Constraints + +- **Zero seeded-output change for specs without survival variables.** The #48 pins in `tests/testthat/test-seed-contract.R` (postprocess stage pin, pinned reference values) must pass untouched. If one fails, the code is wrong: fix the code, never the pin. The only permitted edit to that file's existing tests is extending the frozen-stage vector (Task 3) and dropping anchored rows in the two legacy-path tests (Task 4). +- **Survival draws use only `.MOCK_STAGES["survival"]`**, appended as `4L`. Never renumber indices 0–3. +- **Exact port (ADR D4).** The statistics and rules in Task 3 Step 3 come from `R/create_date_var.R:295-346` and `R/create_wide_survival_data.R:347,363`. Do not fix anything the ADR lists under Concerns (#54, #55, #56, #15), even where it looks wrong. Tests that pin those behaviours say so in a comment. +- **recodeflow semantics untouched.** `anchor` and `censored_by` are MockData extension columns on `variables.csv`. +- **CSV fixtures change only through R data-frame operations** (`read.csv`/`write.csv(row.names = FALSE, na = "")`), never sed, awk or hand edits (user CLAUDE.md). `write.csv` reproduces the minimal example byte for byte (verified). +- **No backward-compatibility obligation (0.x)**, but every new error names the fix, and every new error message is asserted by a test with a regex on a distinctive phrase. +- **Commits:** imperative subject in the repo's style with a `(#40)` suffix, wrapped body; no AI attribution (user CLAUDE.md overrides). Commit locally; do not push. +- **Test counts:** report all testthat columns, including `error`. Summing only `failed` hid #50. Use the snippet in Task 1 Step 1. +- **Environment:** the renv library lives under `~/Library/Caches` and macOS purges it. If `library(devtools)` fails, run `SDKROOT=$(xcrun --sdk macosx --show-sdk-path) Rscript -e 'renv::restore(prompt = FALSE)'` first. `devtools::check()` fails on this Mac's toolchain; use `R CMD build` then `R CMD check --as-cran --no-manual` on the tarball (Task 7). +- **Style for prose (NEWS, vignettes, roxygen):** Canadian Press spelling with -ize, sentence-case headings, "per cent" in running prose, no Unicode dashes in source (`~/github/ai-infrastructure/context/global_style.md`). + +## Review Focus + +1. **Legacy route with anchored metadata** (`validate = FALSE`, or another variable forcing the fallback): expect an error that names the survival variables. Test: Task 4, `legacy generator stops on anchored metadata instead of dropping survival dates`. +2. **Anchor not enabled** (role filtering drops the entry date but keeps its survival dates): expect a validation error naming the anchor, surfaced by `create_mock_data()`. Test: Task 4, `an anchor that is not enabled fails with a message naming it`. +3. **Character anchor column in standalone use** (a user reads dates from CSV and calls `generate_survival_dates()` directly): expect an error naming the column and its class. Test: Task 3, `generate_survival_dates rejects a non-Date anchor column`. +4. **Small n with a small event_prop** (n = 3, event_prop = 0.2, so `floor()` gives zero events): expect an all-`NA` `Date` column and `n_events = 0` in diagnostics, with no error or warning. Test: Task 3, `zero events from flooring is an all-NA column, not an error`. +5. **Mutual `censored_by`** (A censored by B, B censored by A): expect a dependency-cycle error at validation. Test: Task 2, `mutual censored_by is reported as a dependency cycle`. + +--- + +### Task 1: Characterize the legacy survival engine + +Pins what the port must reproduce, before any code changes. These tests describe current legacy behaviour and stay green throughout; the legacy engine is not changed (Task 6 adds a warning, which the Task 6 test helper pre-empts). + +**Files:** +- Create: `tests/testthat/test-survival-characterization.R` + +**Interfaces:** +- Consumes: `create_wide_survival_data()` (unchanged) +- Produces: nothing used by later tasks; the scenarios are mirrored against the new stage in Task 3. + +- [x] **Step 1: Record the baseline** + +```bash +cd /Users/dmanuel/github/mock-data && Rscript -e ' +df <- as.data.frame(devtools::test(reporter = "silent", stop_on_failure = FALSE)) +cat(sprintf("FAIL %d | ERROR %d | WARN %d | SKIP %d | PASS %d\n", + sum(df$failed), sum(df$error), sum(df$warning), sum(df$skipped), sum(df$passed))) +bad <- df[df$failed > 0 | df$error, c("file", "test")]; if (nrow(bad)) print(bad)' +``` + +Expected at `2548b51` with simstudy installed: `FAIL 0 | ERROR 0 | WARN 35 | SKIP 3 | PASS 811`. Record the actual line; every later task compares against it. + +- [x] **Step 2: Write the characterization tests** + +```r +# tests/testthat/test-survival-characterization.R +# Characterization of the legacy survival engine (create_wide_survival_data()), +# pinned before the #40 port. ADR: development/adr/v05-survival-dates.md (D4). +# These describe current behaviour, including behaviour filed as concerns +# (#54, #56); they are the reference the metadata-driven stage must match. + +legacy_fixture <- function(event_window, death_window, + event_prop = 1, death_prop = 1) { + list( + variables = data.frame( + variable = c("entry", "event", "death"), + variableType = c("Date", "Date", "Date"), + rType = c("date", "date", "date"), + role = c("enabled", "enabled", "enabled"), + distribution = c("uniform", "uniform", "uniform"), + followup_min = c(NA, event_window[1], death_window[1]), + followup_max = c(NA, event_window[2], death_window[2]), + event_prop = c(NA, event_prop, death_prop), + sourceFormat = c("analysis", "analysis", "analysis"), + stringsAsFactors = FALSE + ), + variable_details = data.frame( + variable = c("entry", "event", "death"), + recStart = c("[2001-01-01,2001-12-31]", + "[2001-01-01,2040-12-31]", + "[2001-01-01,2040-12-31]"), + recEnd = c("copy", "copy", "copy"), + proportion = c(1, 1, 1), + stringsAsFactors = FALSE + ) + ) +} + +run_legacy <- function(fx, n = 200, seed = 1) { + suppressWarnings(create_wide_survival_data( + var_entry_date = "entry", + var_event_date = "event", + var_death_date = "death", + databaseStart = "test", + variables = fx$variables, + variable_details = fx$variable_details, + n = n, + seed = seed + )) +} + +days_from_entry <- function(result, column) { + as.numeric(result[[column]] - result$entry) +} + +test_that("legacy: an event later than death is set to NA (competing risk)", { + result <- run_legacy(legacy_fixture(c(1000, 2000), c(0, 500))) + expect_true(all(is.na(result$event))) + expect_false(any(is.na(result$death))) +}) + +test_that("legacy: an event earlier than death is kept", { + result <- run_legacy(legacy_fixture(c(0, 500), c(1000, 2000))) + expect_false(any(is.na(result$event))) +}) + +test_that("legacy: exactly floor(n * event_prop) rows receive an event", { + result <- run_legacy(legacy_fixture(c(100, 200), c(100, 200), + event_prop = 0.3, death_prop = 0), + n = 200) + expect_identical(sum(!is.na(result$event)), 60L) + expect_true(all(is.na(result$death))) +}) + +test_that("legacy: follow-up days are whole days inside the window", { + result <- run_legacy(legacy_fixture(c(100, 200), c(5000, 6000))) + days <- days_from_entry(result, "event") + days <- days[!is.na(days)] + expect_true(all(days == floor(days))) + expect_true(all(days >= 100 & days <= 200)) +}) + +test_that("legacy: on the minimal example no date precedes the entry date", { + vars <- system.file("extdata", "minimal-example", "variables.csv", package = "MockData") + dets <- system.file("extdata", "minimal-example", "variable_details.csv", package = "MockData") + if (!nzchar(vars) || !nzchar(dets)) skip("minimal-example fixtures not installed") + variables <- read.csv(vars, stringsAsFactors = FALSE, check.names = FALSE) + variable_details <- read.csv(dets, stringsAsFactors = FALSE, check.names = FALSE) + result <- suppressWarnings(create_wide_survival_data( + var_entry_date = "interview_date", + var_event_date = "primary_event_date", + var_death_date = "death_date", + var_ltfu = "ltfu_date", + var_admin_censor = "admin_censor_date", + databaseStart = "minimal-example", + variables = variables, + variable_details = variable_details, + n = 2000, + seed = 1 + )) + for (column in c("primary_event_date", "death_date", "ltfu_date", "admin_censor_date")) { + ok <- !is.na(result[[column]]) + expect_true(all(result[[column]][ok] >= result$interview_date[ok]), info = column) + } + # #54: with the packaged Gompertz parameters every non-garbage death lands + # exactly at followup_min (365 days). Garbage deaths are 2025 or later. + # Update this expectation deliberately when #54 is resolved. + days <- as.numeric(result$death_date - result$interview_date) + clean <- !is.na(days) & result$death_date < as.Date("2025-01-01") + expect_true(any(clean)) + expect_true(all(days[clean] == 365)) +}) +``` + +- [x] **Step 3: Run them — expected PASS (characterization)** + +Run: `Rscript -e 'devtools::load_all(quiet = TRUE); testthat::test_file("tests/testthat/test-survival-characterization.R")'` +Expected: 5 tests, 13 expectations, all PASS. If any fails, stop: the legacy engine differs from what the plan assumes; report rather than editing the test. + +- [x] **Step 4: Commit** + +```bash +git add tests/testthat/test-survival-characterization.R +git commit -m "Characterize the legacy survival engine before the port (#40)" -m "Pins the create_wide_survival_data() behaviour the metadata-driven stage +must reproduce: the death-censors-event rule in both directions, the exact +floor(n * event_prop) event count, whole-day follow-up inside the window, +no date before entry on the minimal example, and the #54 Gompertz clamp." +``` + +--- + +### Task 2: Survival spec layer + +**Files:** +- Create: `R/mock_spec_survival.R` +- Modify: `R/mock_spec_formula.R:87-126` (generalize ordering) +- Modify: `R/mock_spec.R` (constructor after `mock_spec_formula()`, which ends near line 746; validator branch before the unsupported-type `else` near line 992; spec-level checks in `validate_mock_spec()` after the formula block near line 1046) +- Test: `tests/testthat/test-survival-vars.R` (new) + +**Interfaces:** +- Consumes: `.new_mock_spec_variable()`, `.is_formula_variable()`, `.formula_dependencies()`, `.is_blank()` (defined in `R/mock_spec_recodeflow.R:34`), `` `%||%` `` (`R/mock_spec.R:45`) +- Produces: + - `mock_spec_survival(name, anchor, followup_min, followup_max, event_prop, distribution = "uniform", censored_by = NULL, shape = NULL, rate = NULL, source_format = "analysis", missing_codes = character(0), missing_proportions = numeric(0), garbage_rules = list(), provenance = "direct", model_hint = "native-postprocess")` → `mock_spec_variable` with `type = "survival"`, `rtype = "date"`, fields `anchor`, `censored_by`, `followup_min`, `followup_max`, `event_prop`, `shape`, `rate`, `depends_on = c(anchor, censored_by)` + - `.is_survival_variable(variable)` → logical; `.is_derived_variable(variable)` → TRUE for `formula` or `survival` + - `.survival_dependencies(variable)` → character + - `.validate_survival_variable(variable)` → character vector of errors + - `.validate_survival_referents(spec)` → character vector of errors (includes the chained-censoring rejection) + - `.order_dependent_variables(names_in_scope, dependencies, label)` → character (stops with `"