From dd7edae56cdcdf535440e3a18e968a7233b3802d Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 08:32:18 +0200 Subject: [PATCH 01/57] Fix package-name casing and add CRAN submission files - Correct library(mockData) -> library(MockData) in example README (case-sensitive; example would fail as written) - Standardize GitHub links to canonical Big-Life-Lab/MockData slug - Add cran-comments.md for first CRAN submission - Ignore R CMD check artefacts and rendered vignette output in .Rbuildignore and .gitignore --- .Rbuildignore | 7 ++++++ .gitignore | 3 +++ cran-comments.md | 34 ++++++++++++++++++++++++++ inst/extdata/minimal-example/README.md | 2 +- vignettes/getting-started.qmd | 2 +- vignettes/reference-config.qmd | 6 ++--- vignettes/tutorial-dates.qmd | 4 +-- 7 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 cran-comments.md diff --git a/.Rbuildignore b/.Rbuildignore index 0d39674..abace2b 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -1,6 +1,8 @@ ^renv$ ^renv\.lock$ ^.*\.Rproj$ +^.*\.Rcheck$ +^.*\.tar\.gz$ ^\.Rproj\.user$ ^\.DS_Store$ ^\.git$ @@ -23,7 +25,12 @@ ^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$ diff --git a/.gitignore b/.gitignore index 34bc269..51629cc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ docs/ # Quarto/HTML output Generate_mock_data.html Generate_mock_data_files/ +vignettes/.quarto/ +vignettes/*_files/ +vignettes/*.html diff --git a/cran-comments.md b/cran-comments.md new file mode 100644 index 0000000..6e74b6a --- /dev/null +++ b/cran-comments.md @@ -0,0 +1,34 @@ +# CRAN Comments + +## Local check + +Checked with: + +- R 4.4.2 +- macOS 26.5, aarch64-apple-darwin20 +- `R CMD check --as-cran --no-manual` + +Result: + +- 0 errors +- 0 warnings +- 3 notes + +Notes: + +- New submission. +- `simstudy` was not installed in the local check environment, so the local + check used `_R_CHECK_FORCE_SUGGESTS_=false`. `simstudy` is listed in + `Suggests` and is used only for the optional backend. +- The local check reported `unable to verify current time`. + +## 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, 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/inst/extdata/minimal-example/README.md b/inst/extdata/minimal-example/README.md index ce92456..1bc669e 100644 --- a/inst/extdata/minimal-example/README.md +++ b/inst/extdata/minimal-example/README.md @@ -190,7 +190,7 @@ v_005,,,0.03,[2099-01-01,2099-12-31] # primary_event: 3% future dates ### Basic mock data generation ```r -library(mockData) +library(MockData) # Load metadata variables <- read.csv("inst/extdata/minimal-example/variables.csv", diff --git a/vignettes/getting-started.qmd b/vignettes/getting-started.qmd index 06f47cc..f26a40a 100644 --- a/vignettes/getting-started.qmd +++ b/vignettes/getting-started.qmd @@ -262,7 +262,7 @@ knitr::kable(details_display) - `catLabel`: Category label or range description - `proportion`: Probability of each category (categorical variables only) -See [inst/extdata/minimal-example/](https://github.com/Big-Life-Lab/mockData/tree/main/inst/extdata/minimal-example) for the complete files and v0.2.1 schema documentation. +See [inst/extdata/minimal-example/](https://github.com/Big-Life-Lab/MockData/tree/main/inst/extdata/minimal-example) for the complete files and v0.2.1 schema documentation. ## Working with the generated data diff --git a/vignettes/reference-config.qmd b/vignettes/reference-config.qmd index 8d37d7e..0f6c8cb 100644 --- a/vignettes/reference-config.qmd +++ b/vignettes/reference-config.qmd @@ -95,8 +95,8 @@ For complete column documentation, see sections below. This vignette uses inline R code to generate documentation directly from the actual example data. Column counts, CSV examples, and dataset summaries are calculated dynamically from: -- [inst/extdata/minimal-example/variables.csv](https://github.com/Big-Life-Lab/mockData/tree/main/inst/extdata/minimal-example/variables.csv) -- [inst/extdata/minimal-example/variable_details.csv](https://github.com/Big-Life-Lab/mockData/tree/main/inst/extdata/minimal-example/variable_details.csv) +- [inst/extdata/minimal-example/variables.csv](https://github.com/Big-Life-Lab/MockData/blob/main/inst/extdata/minimal-example/variables.csv) +- [inst/extdata/minimal-example/variable_details.csv](https://github.com/Big-Life-Lab/MockData/blob/main/inst/extdata/minimal-example/variable_details.csv) This approach ensures the documentation stays synchronized with the package and serves as integration testing during package builds. ::: @@ -2013,7 +2013,7 @@ variable_types <- table(variables_csv$variableType) rtypes <- table(variables_csv$rType) ``` -See [inst/extdata/minimal-example/](https://github.com/Big-Life-Lab/mockData/tree/main/inst/extdata/minimal-example) for a complete working example with `r n_variables` variables (`r paste(names(variable_types), ":", variable_types, collapse = ", ")`) and `r n_detail_rows` detail rows demonstrating: +See [inst/extdata/minimal-example/](https://github.com/Big-Life-Lab/MockData/tree/main/inst/extdata/minimal-example) for a complete working example with `r n_variables` variables (`r paste(names(variable_types), ":", variable_types, collapse = ", ")`) and `r n_detail_rows` detail rows demonstrating: - All `r vars_counts$extensions` variable-level extension columns (plus `r vars_counts$core` core recodeflow columns) - All `r details_counts$extensions` detail-level extension columns (plus `r details_counts$core` core recodeflow columns) diff --git a/vignettes/tutorial-dates.qmd b/vignettes/tutorial-dates.qmd index f1ba7bc..831a9d0 100644 --- a/vignettes/tutorial-dates.qmd +++ b/vignettes/tutorial-dates.qmd @@ -557,7 +557,7 @@ old_dates_sample <- head(sort(diag_data$diagnosis_date[diag_data$diagnosis_date - **Training analysts:** Show examples of real-world data quality issues - **Data cleaning scripts:** Test date range checks and filtering logic -**Note:** For complete examples of garbage date generation integrated into metadata files, see the [minimal-example configuration files](https://github.com/Big-Life-Lab/mockData/tree/main/inst/extdata/minimal-example). +**Note:** For complete examples of garbage date generation integrated into metadata files, see the [minimal-example configuration files](https://github.com/Big-Life-Lab/MockData/tree/main/inst/extdata/minimal-example). ## Checking generated dates @@ -628,4 +628,4 @@ In this tutorial, you learned: **Reference:** - [Configuration reference](reference-config.html) - Complete metadata schema specification -- [Advanced topics](advanced-topics.html) - Technical details on distributions and workflows \ No newline at end of file +- [Advanced topics](advanced-topics.html) - Technical details on distributions and workflows From 05bddc151055e51ab14d63dc9e3eba7944e9c003 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 09:07:30 +0200 Subject: [PATCH 02/57] Add post-v0.4.0 development plan Sequences all post-release work across three phases plus a deferred register, cross-linked to issues #35-#43. Phase 1 (v0.4.x hardening) is execution-ready with TDD steps verified against the current tree; Phase 2/3 workstreams are ADR-gated. Records the edge-case behaviour probe, key code locations, and the recoverable formula-evaluator spike ref as the plan's evidence base. --- development/post-v040-development-plan.md | 601 ++++++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 development/post-v040-development-plan.md diff --git a/development/post-v040-development-plan.md b/development/post-v040-development-plan.md new file mode 100644 index 0000000..b4ef271 --- /dev/null +++ b/development/post-v040-development-plan.md @@ -0,0 +1,601 @@ +# Post-v0.4.0 Development Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement Phase 1 task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Phase 2 and 3 tasks are **design-first**: their deliverable is an ADR, and implementation is gated on maintainer approval of that ADR — do not write feature code for a Phase 2/3 workstream until its ADR is merged. + +**Goal:** Sequence and specify all post-v0.4.0 work — tactical hardening now, the v0.5 feature train (formulas, seed contract, survival pairs) next, the Table 1 adapter after — so any capable model or developer can execute it without re-deriving context. + +**Architecture:** MockData v0.4 is a layered pipeline: input adapters (direct API, recodeflow metadata) → normalized `mock_spec` → backends (native, optional simstudy) → post-processing (missing codes, garbage, coercion) with a `mockdata_diagnostics` attribute. Phase 1 hardens the orchestrator entry layer without touching output semantics. Phase 2 lands the two output-changing features plus the seed contract in a single coordinated minor release. Phase 3 adds a third input adapter. + +**Tech Stack:** R (>= 4.2.0), testthat (edition 3), roxygen2, renv, Quarto vignettes via pkgdown, GitHub Actions CI. + +**Plan date:** 2026-07-03. **Baseline:** `dev` branch at v0.4.0 + CRAN-readiness prep (`cran-comments.md`; no near-term submission planned). + +## Global Constraints + +Every task's requirements implicitly include this section. + +- R version floor **>= 4.2.0**; renv lockfile baseline 4.4.2. +- After any `DESCRIPTION` dependency change: `renv::snapshot()` and commit `renv.lock`. Snapshot type is `"all"` — do not change it. +- CI locale is `en_US.UTF-8`, never `en_CA` (Ubuntu runners lack it; silently breaks date parsing — see `.claude/AI.md`). +- Always `roxygen2::roxygenize()` before `pkgdown::build_site()`. Quarto vignettes require `minimal: true`; callouts unsupported. +- Never commit `*.Rcheck/`, `MockData_*.tar.gz`, `.tmp/`. +- Commit messages: plain imperative sentences matching repo history (e.g. "Add native exponential distribution"); **no** conventional-commit prefixes; **no** AI credit; review commits with the maintainer before pushing. +- **Release-stability constraint (Phase 1):** CRAN submission is not imminent — `cran-comments.md` was prepared as a readiness assessment, not an active submission. v0.4.x patches nonetheless stay additive: no public API surface changes, no seeded-output changes, error-relaxations allowed, each with a NEWS entry. This keeps a CRAN submission possible at any time and protects downstream consumers. +- **Downstream constraint:** cchsflow and chmsflow (CRAN-published) consume the recodeflow metadata schema. Any change to shared-column semantics requires coordination — MockData-extension columns are the safe channel. +- **Seed policy:** exactly one seeded-output break is budgeted, in v0.5, covered by one NEWS migration note (precedent: v0.3→v0.4 divergence). No other task may alter seeded output. +- TDD throughout: failing test → minimal code → green → commit. Run `devtools::test()` (full) or `testthat::test_file("tests/testthat/")` (single) each cycle. +- Test fixtures: always `system.file(...)` + skip guard, never source-tree-relative paths (issue #33 item 7 lesson). + +## How to Use This Plan (two tiers) + +- **Phase 1 (Tasks 1–4)** is execution-ready: complete code in every step, verified against the tree at plan date. Execute directly. +- **Phase 2 (Tasks 5–8) and Phase 3 (Task 9)** are design-first: the deliverable is an ADR whose decision points are enumerated below with evidence and a recommendation. After the maintainer approves an ADR, write a fresh implementation plan for that workstream (superpowers:writing-plans; save to `development/plans/YYYY-MM-DD-.md`) and execute it. Do not skip the ADR gate. + +## Evidence Base + +Verified 2026-07-03 by direct probe (`devtools::load_all()` + minimal-example fixtures) and code reading. + +### Edge-case probe results + +| Call | Behaviour at plan date | Verdict | +|---|---|---| +| `generate_mock_data_native(mock_spec(), n = 10)` | 10 × 0 data frame | correct — pin (Task 1) | +| `generate_mock_data_native(mock_spec(), n = 0)` | 0 × 0 data frame | correct — pin | +| `generate_mock_data_native(<1-var spec>, n = 0)` | 0 × 1, typed column | correct — pin | +| native, `n = -1` / `1.5` / `NA` | error "n must be a non-negative whole number." | correct — pin | +| `postprocess_mock_data(<0-row baseline>, spec)` | 0-row, schema preserved | correct — pin | +| `create_mock_data(db, vars, dets, n = 0)` | error "n must be at least 1" | **inconsistent** — fix (Task 2) | +| `create_mock_data(db, vars, dets, n = 1.5)` | accepted (guard is `n < 1`) | **bug** — fix (Task 2) | +| `create_mock_data()` without `databaseStart` | raw R missing-argument error | **UX gap** — fix (Task 3) | +| `create_mock_data(db, vars[0, ], dets, n = 5)` | error "No variables matched the requested role/database filters." | correct — pin | +| `create_mock_data(db, vars[1, ], dets, n = 5)` | 5 × 1 | correct — pin | +| `create_mock_data(db, vars, NULL, n = 5)` | 5 × **11** (vs 5 × **6** with details) | discrepancy — record on #14, do **not** fix in Phase 1 | +| native `distribution = "exponential"` | error "Native backend does not yet support..." | parity gap — fix (Task 4) | + +### Key code locations + +- `n < 1` guard: `R/create_mock_data.R:240-242`. +- v0.4 seed split (`seed`, `seed + 1L`): `R/create_mock_data.R:85-90`. Legacy global `set.seed`: `:325-327`. +- Native continuous dispatch (uniform/normal only, else stop): `R/mock_spec_native.R:189-211`. Truncated normal helper: `:75`. +- Legacy exponential support: `R/create_con_var.R:279-281` (`rexp`, unbounded). +- Adapter already stores `rate`/`shape`/`followup_min`/`followup_max`/`event_prop` on spec variables: `R/mock_spec_recodeflow.R:365-369`. +- Spec variable constructor already has unused `formula` and `depends_on` fields plus `...` passthrough: `R/mock_spec.R:94-145`. +- Validator normal-distribution block (pattern for exponential rule): `R/mock_spec.R:790-799`. +- Formula-evaluator spike (referent validation, `str2lang` dependency extraction, topological ordering, cycle detection): recover with `git show f4f9b41:development/v04-simstudy-spike/prototype.R` (merged PR #27, later removed from tree). + +### Issue map + +| Issue | Workstream | Phase | +|---|---|---| +| [#35](https://github.com/Big-Life-Lab/MockData/issues/35) | Edge-case contract tests | 1 / Task 1 | +| [#36](https://github.com/Big-Life-Lab/MockData/issues/36) | Entry-validation alignment | 1 / Tasks 2–3 | +| [#37](https://github.com/Big-Life-Lab/MockData/issues/37) | Native exponential parity | 1 / Task 4 | +| [#38](https://github.com/Big-Life-Lab/MockData/issues/38) | Seed-contract ADR | 2 / Task 5 | +| [#39](https://github.com/Big-Life-Lab/MockData/issues/39) | Formula-derived variables | 2 / Task 6 | +| [#40](https://github.com/Big-Life-Lab/MockData/issues/40) | Survival pairs in batch | 2 / Task 7 | +| [#41](https://github.com/Big-Life-Lab/MockData/issues/41) | Table 1 adapter | 3 / Task 9 | +| [#42](https://github.com/Big-Life-Lab/MockData/issues/42) | Correlations (deferred) | register | +| [#43](https://github.com/Big-Life-Lab/MockData/issues/43) | LinkML (deferred, ecosystem) | register | + +Related pre-existing: #13 (umbrella reassessment — outcome is this plan), #14 (fallback semantics — evidence recorded by Task 1), #17, #22 (fulfilled by #38), #23 (direction fulfilled by #40), #24 (enabled by #37), #33 (tactical debt, complementary to Phase 1), #25 (spike — suggest close). + +### Execution order + +``` +Phase 1 (v0.4.x, now): Task 1 → Task 2 → Task 3 → Task 4 +Phase 2 (v0.5): Task 5 (ADR) ─┬→ Task 8 (release train) + Task 6 (ADR) ─┤ [Tasks 5+6 land together: one seed break] + Task 7 (ADR) ─┘ [Task 7 may land same release; no seed impact] +Phase 3 (v0.6): Task 9 (ADR → adapter) +Register (triggered): #42 after Task 5 lands + concrete need; #43 on ecosystem demand +``` + +--- + +## Phase 1 — v0.4.x hardening (execution-ready) + +No public API surface changes; no seeded-output changes. Each task is one PR-sized unit. + +### Task 1: Edge-case contract tests (#35) + +**Files:** +- Create: `tests/testthat/test-edge-case-contract.R` + +**Interfaces:** +- Consumes: exported `mock_spec()`, `mock_continuous()`, `generate_mock_data_native()`, `postprocess_mock_data()`, `create_mock_data()`; fixture `inst/extdata/minimal-example/`. +- Produces: nothing for later tasks — pure characterization. Tasks 2–3 extend this same file. + +- [ ] **Step 1: Write the contract tests** (all should pass immediately — they pin verified current behaviour) + +```r +# tests/testthat/test-edge-case-contract.R +# Characterization tests pinning the edge-case input contract (issue #35). +# Probe evidence: development/post-v040-development-plan.md, Evidence Base. + +minimal_example <- function() { + 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") + } + list( + variables = read.csv(vars, stringsAsFactors = FALSE, check.names = FALSE), + variable_details = read.csv(dets, stringsAsFactors = FALSE, check.names = FALSE) + ) +} + +test_that("native backend returns typed zero-row output for n = 0", { + spec <- mock_continuous("age", range = c(18, 80)) + result <- generate_mock_data_native(spec, n = 0) + expect_s3_class(result, "data.frame") + expect_identical(nrow(result), 0L) + expect_named(result, "age") + expect_type(result$age, "double") +}) + +test_that("native backend returns n rows and zero columns for an empty spec", { + expect_identical(dim(generate_mock_data_native(mock_spec(), n = 10)), c(10L, 0L)) + expect_identical(dim(generate_mock_data_native(mock_spec(), n = 0)), c(0L, 0L)) +}) + +test_that("native backend rejects invalid n with a clear message", { + spec <- mock_continuous("age", range = c(18, 80)) + expect_error(generate_mock_data_native(spec, n = -1), + "non-negative whole number") + expect_error(generate_mock_data_native(spec, n = 1.5), + "non-negative whole number") + expect_error(generate_mock_data_native(spec, n = NA), + "non-negative whole number") +}) + +test_that("postprocess_mock_data preserves zero-row shape and names", { + spec <- mock_continuous("age", range = c(18, 80)) + baseline <- generate_mock_data_native(spec, n = 0) + result <- postprocess_mock_data(baseline, spec) + expect_identical(nrow(result), 0L) + expect_named(result, "age") +}) + +test_that("create_mock_data fails loudly when no variables match filters", { + fx <- minimal_example() + expect_error( + suppressMessages(create_mock_data( + "minimal-example", fx$variables[0, ], fx$variable_details, n = 5 + )), + "No variables matched" + ) +}) + +test_that("create_mock_data handles single-row variables metadata", { + fx <- minimal_example() + result <- suppressMessages(create_mock_data( + "minimal-example", fx$variables[1, ], fx$variable_details, n = 5 + )) + expect_identical(nrow(result), 5L) + expect_identical(ncol(result), 1L) +}) + +test_that("create_mock_data generates the minimal example (sanity anchor)", { + fx <- minimal_example() + result <- suppressMessages(create_mock_data( + "minimal-example", fx$variables, fx$variable_details, n = 20, seed = 1 + )) + expect_identical(nrow(result), 20L) + expect_true(all(c("age", "smoking") %in% names(result))) + expect_true(all(names(result) %in% fx$variables$variable)) +}) +``` + +- [ ] **Step 2: Run the file** + +Run: `Rscript -e 'devtools::load_all(quiet = TRUE); testthat::test_file("tests/testthat/test-edge-case-contract.R")'` +Expected: all PASS, 0 failures. If any cell fails, current behaviour has drifted from the plan-date probe — stop and re-verify against the Evidence Base before changing either test or code. + +- [ ] **Step 3: Record the fallback discrepancy on issue #14** (evidence, not a fix) + +Run: `gh issue comment 14 --repo Big-Life-Lab/MockData --body "Concrete evidence (2026-07-03 probe, plan Task 1): create_mock_data(\"minimal-example\", vars, dets, n = 5) returns 6 columns; the same call with variable_details = NULL returns 11 columns via the legacy fallback. Fallback filtering semantics differ from the details path."` + +- [ ] **Step 4: Full suite, then commit** + +Run: `Rscript -e 'devtools::test()'` — expected: green, no new skips. + +```bash +git add tests/testthat/test-edge-case-contract.R +git commit -m "Pin edge-case input contract with characterization tests + +Closes #35. Evidence recorded on #14." +``` + +### Task 2: Allow n = 0 and validate n properly in create_mock_data() (#36) + +**Files:** +- Modify: `R/create_mock_data.R:240-242` +- Modify: `tests/testthat/test-edge-case-contract.R` (extend) +- Modify: `NEWS.md` (add "MockData (development version)" heading if absent) + +**Interfaces:** +- Consumes: guard block at `R/create_mock_data.R:240-242` exactly as shown in Evidence Base. +- Produces: unified n-validation message `"n must be a non-negative whole number."` — Task 3 appends to the same test file; the message string is relied on by tests. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/testthat/test-edge-case-contract.R`: + +```r +test_that("create_mock_data accepts n = 0 and returns a full-schema empty frame", { + fx <- minimal_example() + result <- suppressMessages(create_mock_data( + "minimal-example", fx$variables, fx$variable_details, n = 0 + )) + expect_identical(nrow(result), 0L) + expect_true(all(c("age", "smoking") %in% names(result))) +}) + +test_that("create_mock_data rejects fractional, negative, and NA n clearly", { + fx <- minimal_example() + for (bad_n in list(1.5, -1, NA)) { + expect_error( + suppressMessages(create_mock_data( + "minimal-example", fx$variables, fx$variable_details, n = bad_n + )), + "non-negative whole number" + ) + } +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `Rscript -e 'devtools::load_all(quiet = TRUE); testthat::test_file("tests/testthat/test-edge-case-contract.R")'` +Expected: FAIL — n = 0 errors "n must be at least 1"; n = 1.5 does not error. + +- [ ] **Step 3: Replace the guard** + +In `R/create_mock_data.R`, replace exactly: + +```r + if (n < 1) { + stop("n must be at least 1") + } +``` + +with: + +```r + if (!is.numeric(n) || length(n) != 1 || is.na(n) || + n < 0 || n != trunc(n)) { + stop("n must be a non-negative whole number.", call. = FALSE) + } +``` + +- [ ] **Step 4: Run the tests again** + +Expected: the two new tests pass. **Bounded contingency:** if the n = 0 test now fails *deeper* in the pipeline, the spec-layer contract (verified: 0-row generation works) means the failure is orchestrator glue — fix the failing helper to propagate a zero-row frame, keeping the fix minimal. If the failure is on the **legacy** path only (`validate = FALSE`), instead add after the new guard: + +```r + if (n == 0 && !validate) { + stop("n = 0 requires validate = TRUE (v0.4 pipeline).", call. = FALSE) + } +``` + +and pin that with: + +```r +test_that("legacy path states its n = 0 limitation clearly", { + fx <- minimal_example() + expect_error( + suppressMessages(create_mock_data( + "minimal-example", fx$variables, fx$variable_details, n = 0, validate = FALSE + )), + "requires validate = TRUE" + ) +}) +``` + +- [ ] **Step 5: NEWS entry** + +Under a `# MockData (development version)` heading at the top of `NEWS.md`: + +```markdown +- `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, and `NA` values of `n` with a clear message. The + validation now matches `generate_mock_data_native()`. +``` + +- [ ] **Step 6: Full suite, roxygenize (no roxygen changes expected, cheap safety), commit** + +Run: `Rscript -e 'devtools::test()'` — expected green. + +```bash +git add R/create_mock_data.R tests/testthat/test-edge-case-contract.R NEWS.md +git commit -m "Allow n = 0 in create_mock_data() and align n validation with the spec layer + +Relates to #36." +``` + +### Task 3: Friendly upfront databaseStart check (#36) + +**Files:** +- Modify: `R/create_mock_data.R` (top of function body, before `.load_metadata_df()` calls) +- Modify: `tests/testthat/test-edge-case-contract.R` (extend) + +**Interfaces:** +- Consumes: `create_mock_data()` signature — `databaseStart` is the first positional parameter with no default. +- Produces: error message beginning `"databaseStart is required"`. + +- [ ] **Step 1: Write the failing test** + +```r +test_that("missing databaseStart produces a named, friendly error", { + fx <- minimal_example() + expect_error( + create_mock_data(variables = fx$variables, + variable_details = fx$variable_details, n = 5), + "databaseStart is required" + ) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Expected: FAIL — current error is R's raw `argument "databaseStart" is missing, with no default`. + +- [ ] **Step 3: Add the check** + +At the top of the `create_mock_data()` function body (immediately after the opening brace, before `# ========== LOAD METADATA ==========`): + +```r + 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 + ) + } +``` + +- [ ] **Step 4: Run tests — expected PASS. Full suite — expected green.** + +- [ ] **Step 5: NEWS + commit** + +Append to the development-version NEWS block: + +```markdown +- Calling `create_mock_data()` without `databaseStart` now fails upfront with + a message naming the argument, instead of a raw missing-argument error. +``` + +```bash +git add R/create_mock_data.R tests/testthat/test-edge-case-contract.R NEWS.md +git commit -m "Check databaseStart upfront in create_mock_data() + +Closes #36." +``` + +### Task 4: Native exponential distribution (#37) + +**Files:** +- Modify: `R/mock_spec.R` (`mock_continuous()` at :287, `mock_spec_continuous()` at :480, validator continuous block at :790-799) +- Modify: `R/mock_spec_native.R` (`.generate_native_continuous()` at :189; new helper next to `.native_truncated_normal()` at :75) +- Create: `tests/testthat/test-native-exponential.R` +- Modify: `NEWS.md` + +**Interfaces:** +- Consumes: `.new_mock_spec_variable()` `...` passthrough (extra named fields land on the variable list — this is how `rate` becomes `variable$rate`); validator pattern of the normal block; `.native_truncated_normal()` as the structural template. +- Produces: `mock_continuous(..., rate =)`, `mock_spec_continuous(..., rate =)`, `.native_truncated_exponential(n, rate, range, variable_name)`; validator error string `"exponential distribution requires rate > 0"`. + +- [ ] **Step 1: Write the failing tests** + +```r +# tests/testthat/test-native-exponential.R +test_that("native exponential generates within the declared range", { + spec <- mock_continuous("wait", range = c(0, 100), + distribution = "exponential", rate = 0.1) + result <- generate_mock_data_native(spec, n = 500, seed = 42) + expect_true(all(result$wait >= 0 & result$wait <= 100)) + expect_type(result$wait, "double") +}) + +test_that("native exponential is seed-reproducible", { + spec <- mock_continuous("wait", range = c(0, 100), + distribution = "exponential", rate = 0.1) + expect_identical( + generate_mock_data_native(spec, n = 50, seed = 7), + generate_mock_data_native(spec, n = 50, seed = 7) + ) +}) + +test_that("exponential without a positive rate is rejected", { + # Constructors document that they return a validated mock_spec; expect the + # error at construction. If construction is lazy in this version, assert on + # validate_mock_spec(spec, strict = FALSE)$errors instead. + expect_error( + mock_continuous("wait", range = c(0, 100), distribution = "exponential"), + "exponential distribution requires rate > 0" + ) +}) + +test_that("recodeflow-sourced rate flows to the native generator", { + spec <- mock_continuous("wait", range = c(0, 50), + distribution = "exponential", rate = 0.5) + result <- generate_mock_data_native(spec, n = 200, seed = 11) + # rate = 0.5 on [0, 50]: truncated mean ~ 2; loose two-sided sanity bound + expect_gt(mean(result$wait), 0.5) + expect_lt(mean(result$wait), 6) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `Rscript -e 'devtools::load_all(quiet = TRUE); testthat::test_file("tests/testthat/test-native-exponential.R")'` +Expected: FAIL — `unused argument (rate = 0.1)` from `mock_continuous()`. + +- [ ] **Step 3: Add `rate` to both constructors** + +In `mock_continuous()` (`R/mock_spec.R:287`), add the parameter after `sd = NA_real_`: + +```r + sd = NA_real_, + rate = NA_real_, +``` + +and forward it inside the `mock_spec_continuous(...)` call: + +```r + mean = mean, + sd = sd, + rate = rate, +``` + +In `mock_spec_continuous()` (`R/mock_spec.R:480`), same pattern — parameter after `sd = NA_real_`, and forward inside `.new_mock_spec_variable(...)`: + +```r + mean = mean, + sd = sd, + rate = rate, +``` + +(`.new_mock_spec_variable()` accepts `...`, so `rate` lands on the variable list as `variable$rate`.) + +Add roxygen `@param rate Rate parameter; required when `distribution = "exponential"`.` to both constructors' documentation blocks. + +- [ ] **Step 4: Add the validator rule** + +In `.validate_mock_spec_variable()` (`R/mock_spec.R`), directly after the closing brace of the `if (identical(variable$distribution, "normal")) {...}` block (:792-799): + +```r + 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." + )) + } + } +``` + +- [ ] **Step 5: Add the generator branch and helper** + +In `.generate_native_continuous()` (`R/mock_spec_native.R:189`), insert before the final `else { stop(...) }`: + +```r + } else if (distribution == "exponential") { + values <- .native_truncated_exponential( + n, + variable$rate, + variable$range, + variable$name + ) +``` + +New helper, placed immediately after `.native_truncated_normal()`: + +```r +#' @noRd +.native_truncated_exponential <- function(n, rate, range, variable_name) { + 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) +} +``` + +- [ ] **Step 6: Run the test file — expected PASS. Then `Rscript -e 'devtools::document()'` and full suite — expected green.** + +- [ ] **Step 7: NEWS + commit** + +```markdown +- The native backend now supports `distribution = "exponential"` (parity with + the legacy generator), removing a forced legacy-fallback for exponential + metadata. Unlike the legacy `rexp()`, native exponential values are + truncated to the declared `range`, consistent with the native normal + distribution. +``` + +```bash +git add R/mock_spec.R R/mock_spec_native.R tests/testthat/test-native-exponential.R NEWS.md man/ +git commit -m "Add exponential distribution to the native backend + +Closes #37. Enables the exponential half of #24 on the native path." +``` + +--- + +## Phase 2 — v0.5 feature train (design-first; ADR gate before implementation) + +Tasks 5 and 6 **must land in the same minor release**: both change seeded output, and the budget is one break with one NEWS migration note. Task 7 has no seed impact and may ride along. + +### Task 5: Seed-contract ADR (#38) + +**Deliverable:** `development/adr/v05-seed-contract.md`, approved by the maintainer. + +- [ ] **Step 1: Draft the ADR** with these sections and decision points: + - **Context:** three regimes (legacy global `set.seed` at `R/create_mock_data.R:325-327`; v0.4 `seed`/`seed + 1L` at `:85-90`; simstudy backend's own handling); fallback stream-switch is verbose-gated (#33 item 11). + - **Options:** (a) document the status quo; (b) L'Ecuyer-CMRG sub-streams via `parallel::nextRNGStream()`, one stream per stage; (c) hash-derived per-stage (or per-variable) integer seeds. **Recommendation: (b) at stage granularity** — principled, base-R only, and per-variable granularity is YAGNI until parallel generation is on the table. + - **Guarantee scope:** same seed + same spec + same package version ⇒ identical output; stability across minor versions explicitly *not* guaranteed when NEWS declares a break. + - **Migration:** single break, coordinated with Task 6; pinned-value regression tests (fulfils #22); decide whether the fallback stream-switch message becomes always-on (#33 item 11). +- [ ] **Step 2: Maintainer review — STOP until approved.** +- [ ] **Step 3: Write the implementation plan** (superpowers:writing-plans → `development/plans/`) and execute. + +### Task 6: Formula-evaluator ADR + implementation (#39) + +**Deliverable:** `development/adr/v05-formula-evaluator.md`, approved; then phased implementation. + +- [ ] **Step 1: Recover the spike:** `git show f4f9b41:development/v04-simstudy-spike/prototype.R > /tmp/spike-prototype.R` and read it. It contains `formula_dependencies()` (`all.vars(str2lang(...))`), topological ordering with cycle detection ("Formula dependency cycle or unresolved ordering among: ..."), and `validate_formula_referents()`. +- [ ] **Step 2: Draft the ADR** with these decision points: + - **Syntax entry:** (a) new MockData-extension column (e.g. `mockFormula`) vs (b) reuse `variableStart` `DerivedVar::`/`Func::`. **Recommendation: (a)** — cchsflow/chmsflow are CRAN downstreams; do not overload shared recodeflow semantics. `identify_derived_vars()` continues to detect-and-skip `DerivedVar::` entries that carry no `mockFormula`. + - **Evaluation environment:** restricted env containing only generated columns + an enumerated whitelist of base math/logic functions (`+ - * / ^ %% < <= > >= == != & | ! ifelse pmin pmax log exp sqrt abs round`); no filesystem, network, or global lookup. List the exact whitelist in the ADR. + - **Phasing:** Phase A — algebraic formulas over generated columns (spec fields `formula`/`depends_on` already exist, `R/mock_spec.R:94-145`). Phase B — `Func::` dispatch to the consuming package's namespace (define lookup order and failure semantics). + - **Diagnostics:** derived columns get a `derived = TRUE` entry in `mockdata_diagnostics` with their dependency list. +- [ ] **Step 3: Maintainer review — STOP until approved.** +- [ ] **Step 4: Implementation plan + execution.** Required tests: referent validation errors, cycle detection message, deterministic ordering, seed reproducibility under the Task 5 contract. + +### Task 7: Survival pairs in batch generation (#40) + +**Deliverable:** short design note (may be a section in the implementation plan rather than a full ADR), then implementation. + +- [ ] **Step 1: Decide the pairing convention** — recommendation: an explicit `anchor` extension column on the event variable's metadata row naming its entry-date variable. Metadata fields `followup_min`/`followup_max`/`event_prop` already flow (`R/mock_spec_recodeflow.R:367-369`). +- [ ] **Step 2: Define the multi-column generator contract** — a spec variable of type `survival` returns a 2-column data frame; the assembly loop accepts multi-column returns; `validate_mock_spec()` checks the anchor exists, is a date variable, and ordering constraints hold. +- [ ] **Step 3: Implementation plan + execution.** Required tests: event/censoring proportions with two-sided bounds (#23's direction); diagnostics attribute both columns to one spec entry; NEWS removes the standing known-issue entry. + +### Task 8: v0.5 release assembly + +- [ ] Tasks 5 + 6 merged together; one NEWS migration section covering the seeded-output break with before/after guidance. +- [ ] Pinned-value seed regression tests in place (#22 closed). +- [ ] `devtools::check()` clean; `roxygen2::roxygenize()` then `pkgdown::build_site()` clean; `renv::snapshot()` if dependencies moved. +- [ ] Version bump to 0.5.0; downstream heads-up to cchsflow/chmsflow maintainers if any metadata-extension columns were added. + +--- + +## Phase 3 — v0.6 + +### Task 9: Table 1 bootstrap adapter (#41) + +**Deliverable:** `development/adr/v06-table1-adapter.md` (short), then `mock_spec_from_table1()`. + +Strict-schema v1 only — tidy input data frame (`variable`, `type`, `level`, `prop`, `mean`, `sd`, `min`, `max`) → validated `mock_spec`. Explicit v1 exclusions (in the issue and the ADR): no median/IQR conversion, no stratified panels, no document/PDF parsing, and a vignette-level scope statement that outputs match *marginals* for testing and teaching — not synthetic data for inference or release (the `cran-comments.md` boundary). Design can begin any time; implementation targets v0.6. + +## Deferred register (do not schedule; activate on trigger) + +- **Correlated variables (#42):** trigger = seed ADR (#38) landed **and** a concrete derived-variable test that cannot get coverage from independent marginals. simstudy-backend-only; plausibility-not-fidelity documentation mandatory. +- **LinkML / schema-first (#43):** trigger = a second ecosystem consumer needs formal schema validation. First step is an org-level ADR in a recodeflow ecosystem repo — not MockData code. +- **Distribution registry:** no issue filed — YAGNI. The path, should demand appear, is more native built-ins (Task 4 pattern), not a public registration API. + +## Self-review (performed at plan date) + +- **Coverage:** all eight assessed improvement areas map to a task or register entry; issues #35–#43 cross-linked; pre-existing #13/#14/#22/#23/#24/#25/#33 reconciled. +- **Placeholder scan:** Phase 1 steps contain complete code verified against the tree; the two bounded contingencies (Task 2 Step 4, Task 4 Step 1's construction-vs-lazy validation note) provide exact code for both branches rather than deferring the decision. +- **Type consistency:** `rate` flows constructor → `...` → `variable$rate` → validator → `.native_truncated_exponential()`; message strings quoted in tests match the code that raises them. From 1ab2603621dfc71a2e18c19bc5d5218fe6488ce9 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 09:09:47 +0200 Subject: [PATCH 03/57] Pin edge-case input contract with characterization tests Closes #35. Evidence recorded on #14. --- tests/testthat/test-edge-case-contract.R | 78 ++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/testthat/test-edge-case-contract.R diff --git a/tests/testthat/test-edge-case-contract.R b/tests/testthat/test-edge-case-contract.R new file mode 100644 index 0000000..e4bce7f --- /dev/null +++ b/tests/testthat/test-edge-case-contract.R @@ -0,0 +1,78 @@ +# tests/testthat/test-edge-case-contract.R +# Characterization tests pinning the edge-case input contract (issue #35). +# Probe evidence: development/post-v040-development-plan.md, Evidence Base. + +minimal_example <- function() { + 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") + } + list( + variables = read.csv(vars, stringsAsFactors = FALSE, check.names = FALSE), + variable_details = read.csv(dets, stringsAsFactors = FALSE, check.names = FALSE) + ) +} + +test_that("native backend returns typed zero-row output for n = 0", { + spec <- mock_continuous("age", range = c(18, 80)) + result <- generate_mock_data_native(spec, n = 0) + expect_s3_class(result, "data.frame") + expect_identical(nrow(result), 0L) + expect_named(result, "age") + expect_type(result$age, "double") +}) + +test_that("native backend returns n rows and zero columns for an empty spec", { + expect_identical(dim(generate_mock_data_native(mock_spec(), n = 10)), c(10L, 0L)) + expect_identical(dim(generate_mock_data_native(mock_spec(), n = 0)), c(0L, 0L)) +}) + +test_that("native backend rejects invalid n with a clear message", { + spec <- mock_continuous("age", range = c(18, 80)) + expect_error(generate_mock_data_native(spec, n = -1), + "non-negative whole number") + expect_error(generate_mock_data_native(spec, n = 1.5), + "non-negative whole number") + expect_error(generate_mock_data_native(spec, n = NA), + "non-negative whole number") +}) + +test_that("postprocess_mock_data preserves zero-row shape and names", { + spec <- mock_continuous("age", range = c(18, 80)) + baseline <- generate_mock_data_native(spec, n = 0) + result <- postprocess_mock_data(baseline, spec) + expect_identical(nrow(result), 0L) + expect_named(result, "age") +}) + +test_that("create_mock_data fails loudly when no variables match filters", { + fx <- minimal_example() + expect_error( + suppressMessages(create_mock_data( + "minimal-example", fx$variables[0, ], fx$variable_details, n = 5 + )), + "No variables matched" + ) +}) + +test_that("create_mock_data handles single-row variables metadata", { + fx <- minimal_example() + result <- suppressMessages(create_mock_data( + "minimal-example", fx$variables[1, ], fx$variable_details, n = 5 + )) + expect_identical(nrow(result), 5L) + expect_identical(ncol(result), 1L) +}) + +test_that("create_mock_data generates the minimal example (sanity anchor)", { + fx <- minimal_example() + result <- suppressMessages(create_mock_data( + "minimal-example", fx$variables, fx$variable_details, n = 20, seed = 1 + )) + expect_identical(nrow(result), 20L) + expect_true(all(c("age", "smoking") %in% names(result))) + expect_true(all(names(result) %in% fx$variables$variable)) +}) From 122e398ff75fa120970c7ebf45c9302412b32bdb Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 09:19:28 +0200 Subject: [PATCH 04/57] Suppress deterministic fixture warnings in sanity-anchor test Review finding on #35: suppressMessages() does not catch warning() conditions. Matches the vignette's handling of the same call. Plan code block amended to match. --- development/post-v040-development-plan.md | 4 ++-- tests/testthat/test-edge-case-contract.R | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/development/post-v040-development-plan.md b/development/post-v040-development-plan.md index b4ef271..9d97a43 100644 --- a/development/post-v040-development-plan.md +++ b/development/post-v040-development-plan.md @@ -179,9 +179,9 @@ test_that("create_mock_data handles single-row variables metadata", { test_that("create_mock_data generates the minimal example (sanity anchor)", { fx <- minimal_example() - result <- suppressMessages(create_mock_data( + result <- suppressWarnings(suppressMessages(create_mock_data( "minimal-example", fx$variables, fx$variable_details, n = 20, seed = 1 - )) + ))) expect_identical(nrow(result), 20L) expect_true(all(c("age", "smoking") %in% names(result))) expect_true(all(names(result) %in% fx$variables$variable)) diff --git a/tests/testthat/test-edge-case-contract.R b/tests/testthat/test-edge-case-contract.R index e4bce7f..699293e 100644 --- a/tests/testthat/test-edge-case-contract.R +++ b/tests/testthat/test-edge-case-contract.R @@ -69,9 +69,9 @@ test_that("create_mock_data handles single-row variables metadata", { test_that("create_mock_data generates the minimal example (sanity anchor)", { fx <- minimal_example() - result <- suppressMessages(create_mock_data( + result <- suppressWarnings(suppressMessages(create_mock_data( "minimal-example", fx$variables, fx$variable_details, n = 20, seed = 1 - )) + ))) expect_identical(nrow(result), 20L) expect_true(all(c("age", "smoking") %in% names(result))) expect_true(all(names(result) %in% fx$variables$variable)) From 1ecbeff5c00cdd4b82ad0d8e2475373a1504c854 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 09:26:02 +0200 Subject: [PATCH 05/57] Allow n = 0 in create_mock_data() and align n validation with the spec layer Relates to #36. --- NEWS.md | 7 +++++++ R/create_mock_data.R | 5 +++-- tests/testthat/test-edge-case-contract.R | 21 +++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 5fbebda..0f8018a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,10 @@ +# MockData (development version) + +- `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, and `NA` values of `n` with a clear message. The + validation now matches `generate_mock_data_native()`. + # MockData 0.4.0 (2026-06-10) ## Breaking changes diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 7533f91..a983ba0 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -237,8 +237,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) || + n < 0 || n != trunc(n)) { + stop("n must be a non-negative whole number.", call. = FALSE) } if (!"variable" %in% names(variables)) { diff --git a/tests/testthat/test-edge-case-contract.R b/tests/testthat/test-edge-case-contract.R index 699293e..97900f9 100644 --- a/tests/testthat/test-edge-case-contract.R +++ b/tests/testthat/test-edge-case-contract.R @@ -76,3 +76,24 @@ test_that("create_mock_data generates the minimal example (sanity anchor)", { expect_true(all(c("age", "smoking") %in% names(result))) expect_true(all(names(result) %in% fx$variables$variable)) }) + +test_that("create_mock_data accepts n = 0 and returns a full-schema empty frame", { + fx <- minimal_example() + result <- suppressMessages(create_mock_data( + "minimal-example", fx$variables, fx$variable_details, n = 0 + )) + expect_identical(nrow(result), 0L) + expect_true(all(c("age", "smoking") %in% names(result))) +}) + +test_that("create_mock_data rejects fractional, negative, and NA n clearly", { + fx <- minimal_example() + for (bad_n in list(1.5, -1, NA)) { + expect_error( + suppressMessages(create_mock_data( + "minimal-example", fx$variables, fx$variable_details, n = bad_n + )), + "non-negative whole number" + ) + } +}) From 127abd0d02b7ef4dd031f00b2f80d424972782f6 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 09:29:31 +0200 Subject: [PATCH 06/57] Suppress deterministic fixture warnings in n = 0 contract test Same review finding and remediation as the Task 1 sanity anchor. Plan code block amended to match. --- development/post-v040-development-plan.md | 4 ++-- tests/testthat/test-edge-case-contract.R | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/development/post-v040-development-plan.md b/development/post-v040-development-plan.md index 9d97a43..1b64abb 100644 --- a/development/post-v040-development-plan.md +++ b/development/post-v040-development-plan.md @@ -226,9 +226,9 @@ Append to `tests/testthat/test-edge-case-contract.R`: ```r test_that("create_mock_data accepts n = 0 and returns a full-schema empty frame", { fx <- minimal_example() - result <- suppressMessages(create_mock_data( + result <- suppressWarnings(suppressMessages(create_mock_data( "minimal-example", fx$variables, fx$variable_details, n = 0 - )) + ))) expect_identical(nrow(result), 0L) expect_true(all(c("age", "smoking") %in% names(result))) }) diff --git a/tests/testthat/test-edge-case-contract.R b/tests/testthat/test-edge-case-contract.R index 97900f9..5c6fe1c 100644 --- a/tests/testthat/test-edge-case-contract.R +++ b/tests/testthat/test-edge-case-contract.R @@ -79,9 +79,9 @@ test_that("create_mock_data generates the minimal example (sanity anchor)", { test_that("create_mock_data accepts n = 0 and returns a full-schema empty frame", { fx <- minimal_example() - result <- suppressMessages(create_mock_data( + result <- suppressWarnings(suppressMessages(create_mock_data( "minimal-example", fx$variables, fx$variable_details, n = 0 - )) + ))) expect_identical(nrow(result), 0L) expect_true(all(c("age", "smoking") %in% names(result))) }) From f9c3601dc1df5275405d8c2ac4978b05fdd1de08 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 09:37:58 +0200 Subject: [PATCH 07/57] Check databaseStart upfront in create_mock_data() Closes #36. --- NEWS.md | 2 ++ R/create_mock_data.R | 8 ++++++++ tests/testthat/test-edge-case-contract.R | 9 +++++++++ 3 files changed, 19 insertions(+) diff --git a/NEWS.md b/NEWS.md index 0f8018a..25a762f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,8 @@ with the full generated schema (useful for schema tests), and rejects fractional, negative, and `NA` values of `n` with a clear message. The validation now matches `generate_mock_data_native()`. +- Calling `create_mock_data()` without `databaseStart` now fails upfront with + a message naming the argument, instead of a raw missing-argument error. # MockData 0.4.0 (2026-06-10) diff --git a/R/create_mock_data.R b/R/create_mock_data.R index a983ba0..a29f4f9 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -223,6 +223,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) diff --git a/tests/testthat/test-edge-case-contract.R b/tests/testthat/test-edge-case-contract.R index 5c6fe1c..8a62489 100644 --- a/tests/testthat/test-edge-case-contract.R +++ b/tests/testthat/test-edge-case-contract.R @@ -97,3 +97,12 @@ test_that("create_mock_data rejects fractional, negative, and NA n clearly", { ) } }) + +test_that("missing databaseStart produces a named, friendly error", { + fx <- minimal_example() + expect_error( + create_mock_data(variables = fx$variables, + variable_details = fx$variable_details, n = 5), + "databaseStart is required" + ) +}) From bb969e4d8d2b2f91e662feafbbca62ee56459589 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 09:57:26 +0200 Subject: [PATCH 08/57] Add exponential distribution to the native backend Closes #37. Enables the exponential half of #24 on the native path. --- NEWS.md | 5 ++++ R/create_mock_data.R | 2 +- R/mock_spec.R | 15 ++++++++++ R/mock_spec_native.R | 24 +++++++++++++++ R/mock_spec_recodeflow.R | 1 + tests/testthat/test-create-mock-data-v04.R | 8 +++-- tests/testthat/test-native-exponential.R | 35 ++++++++++++++++++++++ 7 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 tests/testthat/test-native-exponential.R diff --git a/NEWS.md b/NEWS.md index 25a762f..a49f42e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,11 @@ validation now matches `generate_mock_data_native()`. - Calling `create_mock_data()` without `databaseStart` now fails upfront with a message naming the argument, instead of a raw missing-argument error. +- The native backend now supports `distribution = "exponential"` (parity with + the legacy generator), removing a forced legacy-fallback for exponential + metadata. Unlike the legacy `rexp()`, native exponential values are + truncated to the declared `range`, consistent with the native normal + distribution. # MockData 0.4.0 (2026-06-10) diff --git a/R/create_mock_data.R b/R/create_mock_data.R index a29f4f9..73badba 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -19,7 +19,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) diff --git a/R/mock_spec.R b/R/mock_spec.R index 5efd59e..6b2776f 100644 --- a/R/mock_spec.R +++ b/R/mock_spec.R @@ -259,6 +259,7 @@ mock_spec <- function(..., #' @param distribution Distribution name. Defaults to `"uniform"`. #' @param mean,sd Optional distribution parameters. Required when #' `distribution = "normal"`. +#' @param rate Rate parameter; required when `distribution = "exponential"`. #' @param rtype R output type. Defaults to `"double"`. #' @param missing_codes Explicit missing-code values. #' @param missing_proportions Missing-code probabilities aligned to @@ -289,6 +290,7 @@ mock_continuous <- function(name, distribution = "uniform", mean = NA_real_, sd = NA_real_, + rate = NA_real_, rtype = "double", missing_codes = numeric(0), missing_proportions = numeric(0), @@ -305,6 +307,7 @@ mock_continuous <- function(name, distribution = distribution, mean = mean, sd = sd, + rate = rate, rtype = rtype, missing_codes = missing_codes, missing_proportions = missing_proportions, @@ -454,6 +457,7 @@ mock_date <- function(name, #' @param range Numeric vector of length two giving the inclusive valid range. #' @param distribution Distribution name. Defaults to `"uniform"`. #' @param mean,sd Optional distribution parameters. +#' @param rate Rate parameter; required when `distribution = "exponential"`. #' @param rtype R output type. Defaults to `"double"`. #' @param missing_codes Explicit missing-code values. #' @param missing_proportions Missing-code probabilities aligned to @@ -482,6 +486,7 @@ mock_spec_continuous <- function(name, distribution = "uniform", mean = NA_real_, sd = NA_real_, + rate = NA_real_, rtype = "double", missing_codes = numeric(0), missing_proportions = numeric(0), @@ -496,6 +501,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, @@ -797,6 +803,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.")) diff --git a/R/mock_spec_native.R b/R/mock_spec_native.R index 53c8286..2f7bd89 100644 --- a/R/mock_spec_native.R +++ b/R/mock_spec_native.R @@ -105,6 +105,23 @@ values } +#' @noRd +.native_truncated_exponential <- function(n, rate, range, variable_name) { + 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 +216,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 '", diff --git a/R/mock_spec_recodeflow.R b/R/mock_spec_recodeflow.R index 1c82bed..aa85c70 100644 --- a/R/mock_spec_recodeflow.R +++ b/R/mock_spec_recodeflow.R @@ -339,6 +339,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, diff --git a/tests/testthat/test-create-mock-data-v04.R b/tests/testthat/test-create-mock-data-v04.R index 781ffde..4b5d870 100644 --- a/tests/testthat/test-create-mock-data-v04.R +++ b/tests/testthat/test-create-mock-data-v04.R @@ -38,13 +38,17 @@ test_that("create_mock_data uses the v0.4 pipeline for strict supported metadata }) test_that("create_mock_data keeps legacy fallback for unsupported v0.4 backend features", { + # "lognormal" is not among the native backend's supported continuous + # distributions (uniform/normal/exponential), so this pins the v0.4 + # orchestrator's fallback-to-legacy behaviour for a still-unsupported + # distribution. (Exponential moved to the native path in #37; see + # test-native-exponential.R for its coverage.) variables <- data.frame( variable = "time_to_visit", variableType = "Continuous", rType = "double", role = "enabled", - distribution = "exponential", - rate = 0.5, + distribution = "lognormal", stringsAsFactors = FALSE ) variable_details <- data.frame( diff --git a/tests/testthat/test-native-exponential.R b/tests/testthat/test-native-exponential.R new file mode 100644 index 0000000..9f01525 --- /dev/null +++ b/tests/testthat/test-native-exponential.R @@ -0,0 +1,35 @@ +test_that("native exponential generates within the declared range", { + spec <- mock_continuous("wait", range = c(0, 100), + distribution = "exponential", rate = 0.1) + result <- generate_mock_data_native(spec, n = 500, seed = 42) + expect_true(all(result$wait >= 0 & result$wait <= 100)) + expect_type(result$wait, "double") +}) + +test_that("native exponential is seed-reproducible", { + spec <- mock_continuous("wait", range = c(0, 100), + distribution = "exponential", rate = 0.1) + expect_identical( + generate_mock_data_native(spec, n = 50, seed = 7), + generate_mock_data_native(spec, n = 50, seed = 7) + ) +}) + +test_that("exponential without a positive rate is rejected", { + # Constructors document that they return a validated mock_spec; expect the + # error at construction. If construction is lazy in this version, assert on + # validate_mock_spec(spec, strict = FALSE)$errors instead. + expect_error( + mock_continuous("wait", range = c(0, 100), distribution = "exponential"), + "exponential distribution requires rate > 0" + ) +}) + +test_that("recodeflow-sourced rate flows to the native generator", { + spec <- mock_continuous("wait", range = c(0, 50), + distribution = "exponential", rate = 0.5) + result <- generate_mock_data_native(spec, n = 200, seed = 11) + # rate = 0.5 on [0, 50]: truncated mean ~ 2; loose two-sided sanity bound + expect_gt(mean(result$wait), 0.5) + expect_lt(mean(result$wait), 6) +}) From ff12d2d08195e354f26124dc095ac2bcaafbc8e5 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 10:06:08 +0200 Subject: [PATCH 09/57] Add recodeflow integration test for native exponential Review finding on #37: the adapter rate-forwarding and allowlist fixes had no end-to-end coverage, and the direct-API test's name overstated its scope. --- tests/testthat/test-native-exponential.R | 47 +++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/testthat/test-native-exponential.R b/tests/testthat/test-native-exponential.R index 9f01525..f17bc09 100644 --- a/tests/testthat/test-native-exponential.R +++ b/tests/testthat/test-native-exponential.R @@ -25,7 +25,7 @@ test_that("exponential without a positive rate is rejected", { ) }) -test_that("recodeflow-sourced rate flows to the native generator", { +test_that("rate parameter shapes native exponential generation", { spec <- mock_continuous("wait", range = c(0, 50), distribution = "exponential", rate = 0.5) result <- generate_mock_data_native(spec, n = 200, seed = 11) @@ -33,3 +33,48 @@ test_that("recodeflow-sourced rate flows to the native generator", { expect_gt(mean(result$wait), 0.5) expect_lt(mean(result$wait), 6) }) + +test_that("recodeflow-sourced rate flows through create_mock_data to the native generator", { + # Regression pin for #37: mock_spec_recodeflow.R forwards `rate` from + # metadata and create_mock_data.R's v0.4 allowlist includes "exponential". + # Without both fixes, this call hard-errors or silently falls back to the + # legacy path (no mockdata_diagnostics attribute). + variables <- data.frame( + variable = "wait", + variableType = "Continuous", + rType = "double", + role = "enabled", + distribution = "exponential", + rate = 0.5, + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = "wait", + recStart = "[0, 50]", + recEnd = "copy", + proportion = 1, + stringsAsFactors = FALSE + ) + + result <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 200, + seed = 11, + validate = TRUE + ) + + expect_true(all(result$wait >= 0 & result$wait <= 50)) + expect_false(is.null(attr(result, "mockdata_diagnostics"))) + + repeat_result <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 200, + seed = 11, + validate = TRUE + ) + expect_identical(result, repeat_result) +}) From b749b11c96f87918beb5550f8fad894e5d6b900d Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 13:25:28 +0200 Subject: [PATCH 10/57] Move rate after rtype in continuous constructors and polish exponential docs Final-review findings: mid-signature insertion silently shifted positional rtype arguments; moving rate after rtype restores compatibility. Adds exponential roxygen examples, notes the simstudy hybrid path in NEWS, trims a resolved contingency comment, and syncs the plan document. --- NEWS.md | 3 ++- R/mock_spec.R | 22 ++++++++++++++++++---- development/post-v040-development-plan.md | 6 +++--- tests/testthat/test-native-exponential.R | 3 +-- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/NEWS.md b/NEWS.md index a49f42e..cb8c28e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,7 +10,8 @@ the legacy generator), removing a forced legacy-fallback for exponential metadata. Unlike the legacy `rexp()`, native exponential values are truncated to the declared `range`, consistent with the native normal - distribution. + distribution. Exponential specs also generate via the simstudy hybrid path, + which routes non-uniform continuous variables to the native generator. # MockData 0.4.0 (2026-06-10) diff --git a/R/mock_spec.R b/R/mock_spec.R index 6b2776f..a3e6be0 100644 --- a/R/mock_spec.R +++ b/R/mock_spec.R @@ -259,8 +259,8 @@ mock_spec <- function(..., #' @param distribution Distribution name. Defaults to `"uniform"`. #' @param mean,sd Optional distribution parameters. Required when #' `distribution = "normal"`. -#' @param rate Rate parameter; required when `distribution = "exponential"`. #' @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`. @@ -284,14 +284,21 @@ mock_spec <- function(..., #' ) #' validate_mock_spec(age_spec) #' +#' wait_spec <- mock_continuous( +#' "wait", +#' range = c(0, 100), +#' distribution = "exponential", +#' rate = 0.1 +#' ) +#' #' @export mock_continuous <- function(name, range, distribution = "uniform", mean = NA_real_, sd = NA_real_, - rate = NA_real_, rtype = "double", + rate = NA_real_, missing_codes = numeric(0), missing_proportions = numeric(0), garbage_rules = list(), @@ -457,8 +464,8 @@ mock_date <- function(name, #' @param range Numeric vector of length two giving the inclusive valid range. #' @param distribution Distribution name. Defaults to `"uniform"`. #' @param mean,sd Optional distribution parameters. -#' @param rate Rate parameter; required when `distribution = "exponential"`. #' @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`. @@ -480,14 +487,21 @@ 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, distribution = "uniform", mean = NA_real_, sd = NA_real_, - rate = NA_real_, rtype = "double", + rate = NA_real_, missing_codes = numeric(0), missing_proportions = numeric(0), garbage_rules = list(), diff --git a/development/post-v040-development-plan.md b/development/post-v040-development-plan.md index 1b64abb..d4794da 100644 --- a/development/post-v040-development-plan.md +++ b/development/post-v040-development-plan.md @@ -435,10 +435,10 @@ Expected: FAIL — `unused argument (rate = 0.1)` from `mock_continuous()`. - [ ] **Step 3: Add `rate` to both constructors** -In `mock_continuous()` (`R/mock_spec.R:287`), add the parameter after `sd = NA_real_`: +In `mock_continuous()` (`R/mock_spec.R:287`), add the parameter after `rtype = "double"`: ```r - sd = NA_real_, + rtype = "double", rate = NA_real_, ``` @@ -450,7 +450,7 @@ and forward it inside the `mock_spec_continuous(...)` call: rate = rate, ``` -In `mock_spec_continuous()` (`R/mock_spec.R:480`), same pattern — parameter after `sd = NA_real_`, and forward inside `.new_mock_spec_variable(...)`: +In `mock_spec_continuous()` (`R/mock_spec.R:480`), same pattern — parameter after `rtype = "double"`, and forward inside `.new_mock_spec_variable(...)`: ```r mean = mean, diff --git a/tests/testthat/test-native-exponential.R b/tests/testthat/test-native-exponential.R index f17bc09..06b809f 100644 --- a/tests/testthat/test-native-exponential.R +++ b/tests/testthat/test-native-exponential.R @@ -17,8 +17,7 @@ test_that("native exponential is seed-reproducible", { test_that("exponential without a positive rate is rejected", { # Constructors document that they return a validated mock_spec; expect the - # error at construction. If construction is lazy in this version, assert on - # validate_mock_spec(spec, strict = FALSE)$errors instead. + # error at construction. expect_error( mock_continuous("wait", range = c(0, 100), distribution = "exponential"), "exponential distribution requires rate > 0" From 361e269b9d86ede56fd531901be4d3889aa89f68 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 3 Jul 2026 14:09:14 +0200 Subject: [PATCH 11/57] Address PR review findings: guards, case normalization, contextual errors, docs Five-agent review plus Copilot comments on PR #45. Rejects non-finite n in both guards (closes #44). Returns empty output for n = 0 before the exponential no-mass guard. Normalizes distribution case at spec construction so validation, allowlist, and generators agree. Wraps v0.4 spec construction so metadata validation failures carry the diagnosis plus guidance on validate = FALSE. Adds boundary tests for rate <= 0, the no-mass guard, and n = Inf. Corrects stale docs: native backend details, reference-config distribution tables, migrating vignette fallback example (exponential -> lognormal), NEWS wording on simstudy routing and clip-vs-truncate semantics. --- NEWS.md | 19 +++++-- R/create_mock_data.R | 27 +++++++-- R/mock_spec.R | 8 ++- R/mock_spec_native.R | 10 +++- tests/testthat/test-edge-case-contract.R | 4 +- tests/testthat/test-native-exponential.R | 70 ++++++++++++++++++++++++ vignettes/migrating-from-v03-v04.qmd | 7 +-- vignettes/reference-config.qmd | 12 +++- 8 files changed, 137 insertions(+), 20 deletions(-) diff --git a/NEWS.md b/NEWS.md index cb8c28e..81d86d1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,16 +2,23 @@ - `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, and `NA` values of `n` with a clear message. The - validation now matches `generate_mock_data_native()`. + fractional, negative, `NA`, and non-finite values of `n` with a clear + message. The validation now matches `generate_mock_data_native()`. - Calling `create_mock_data()` without `databaseStart` now fails upfront with a message naming the argument, instead of a raw missing-argument error. - The native backend now supports `distribution = "exponential"` (parity with the legacy generator), removing a forced legacy-fallback for exponential - metadata. Unlike the legacy `rexp()`, native exponential values are - truncated to the declared `range`, consistent with the native normal - distribution. Exponential specs also generate via the simstudy hybrid path, - which routes non-uniform continuous variables to the native generator. + 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. +- 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`. # MockData 0.4.0 (2026-06-10) diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 73badba..655cfe8 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -59,11 +59,24 @@ 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) { + stop( + conditionMessage(e), "\n", + "Metadata validation failed while building the v0.4 specification. ", + "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.", + call. = FALSE + ) + } ) unsupported <- .create_mock_data_v04_unsupported_variables(spec) @@ -120,6 +133,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 @@ -245,7 +260,7 @@ create_mock_data <- function(databaseStart, # ========== VALIDATE INPUT ========== - if (!is.numeric(n) || length(n) != 1 || is.na(n) || + 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) } diff --git a/R/mock_spec.R b/R/mock_spec.R index a3e6be0..3b3df4c 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( @@ -290,6 +294,7 @@ mock_spec <- function(..., #' distribution = "exponential", #' rate = 0.1 #' ) +#' validate_mock_spec(wait_spec) #' #' @export mock_continuous <- function(name, @@ -907,7 +912,8 @@ validate_mock_spec <- function(spec, n = NULL, strict = TRUE) { } 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_native.R b/R/mock_spec_native.R index 2f7bd89..ffdc0eb 100644 --- a/R/mock_spec_native.R +++ b/R/mock_spec_native.R @@ -105,8 +105,15 @@ 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) @@ -302,7 +309,8 @@ #' @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. #' diff --git a/tests/testthat/test-edge-case-contract.R b/tests/testthat/test-edge-case-contract.R index 8a62489..d65339e 100644 --- a/tests/testthat/test-edge-case-contract.R +++ b/tests/testthat/test-edge-case-contract.R @@ -38,6 +38,8 @@ test_that("native backend rejects invalid n with a clear message", { "non-negative whole number") expect_error(generate_mock_data_native(spec, n = NA), "non-negative whole number") + expect_error(generate_mock_data_native(spec, n = Inf), + "non-negative whole number") }) test_that("postprocess_mock_data preserves zero-row shape and names", { @@ -88,7 +90,7 @@ test_that("create_mock_data accepts n = 0 and returns a full-schema empty frame" test_that("create_mock_data rejects fractional, negative, and NA n clearly", { fx <- minimal_example() - for (bad_n in list(1.5, -1, NA)) { + for (bad_n in list(1.5, -1, NA, Inf)) { expect_error( suppressMessages(create_mock_data( "minimal-example", fx$variables, fx$variable_details, n = bad_n diff --git a/tests/testthat/test-native-exponential.R b/tests/testthat/test-native-exponential.R index 06b809f..3474a83 100644 --- a/tests/testthat/test-native-exponential.R +++ b/tests/testthat/test-native-exponential.R @@ -77,3 +77,73 @@ test_that("recodeflow-sourced rate flows through create_mock_data to the native ) expect_identical(result, repeat_result) }) + +test_that("exponential rejects zero and negative rate", { + expect_error( + mock_continuous("wait", range = c(0, 100), distribution = "exponential", rate = 0), + "exponential distribution requires rate > 0" + ) + expect_error( + mock_continuous("wait", range = c(0, 100), distribution = "exponential", rate = -1), + "exponential distribution requires rate > 0" + ) +}) + +test_that("distribution case is normalized so validation applies", { + expect_error( + mock_continuous("wait", range = c(0, 100), distribution = "Exponential"), + "exponential distribution requires rate > 0" + ) +}) + +test_that("no-probability-mass guard fires for degenerate and far-tail ranges", { + degenerate <- mock_continuous("wait", range = c(5, 5), + distribution = "exponential", rate = 0.5) + expect_error( + generate_mock_data_native(degenerate, n = 10), + "no probability mass inside range" + ) + far_tail <- mock_continuous("wait", range = c(1000, 2000), + distribution = "exponential", rate = 10) + expect_error( + generate_mock_data_native(far_tail, n = 10), + "no probability mass inside range" + ) +}) + +test_that("n = 0 returns an empty column even when the range has no mass", { + degenerate <- mock_continuous("wait", range = c(5, 5), + distribution = "exponential", rate = 0.5) + result <- generate_mock_data_native(degenerate, n = 0) + expect_identical(nrow(result), 0L) + expect_named(result, "wait") +}) + +test_that("invalid exponential metadata fails with diagnosis and guidance", { + variables <- data.frame( + variable = "wait", + variableType = "Continuous", + rType = "double", + role = "enabled", + distribution = "exponential", + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = "wait", + recStart = "[0, 50]", + recEnd = "copy", + proportion = 1, + stringsAsFactors = FALSE + ) + err <- tryCatch( + suppressMessages(create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 5 + )), + error = function(e) conditionMessage(e) + ) + expect_match(err, "requires rate > 0") + expect_match(err, "validate = FALSE") +}) diff --git a/vignettes/migrating-from-v03-v04.qmd b/vignettes/migrating-from-v03-v04.qmd index 48744f9..9a281b6 100644 --- a/vignettes/migrating-from-v03-v04.qmd +++ b/vignettes/migrating-from-v03-v04.qmd @@ -197,8 +197,8 @@ is.null(attr(fallback_data, "mockdata_diagnostics")) ``` Unsupported v0.4 backend features also route to legacy dispatch. This example -uses an exponential continuous distribution, which remains available through the -legacy generator. +uses a lognormal continuous distribution, which is not yet supported by the +v0.4 native backend and remains available through the legacy generator. ```{r} exp_variables <- data.frame( @@ -206,8 +206,7 @@ exp_variables <- data.frame( variableType = "Continuous", rType = "double", role = "enabled", - distribution = "exponential", - rate = 0.5, + distribution = "lognormal", stringsAsFactors = FALSE ) diff --git a/vignettes/reference-config.qmd b/vignettes/reference-config.qmd index 0f6c8cb..48bcb2c 100644 --- a/vignettes/reference-config.qmd +++ b/vignettes/reference-config.qmd @@ -78,7 +78,7 @@ Essential columns for getting started: | `variable` | variables.csv | Yes | Output column name | `"age"` | | `variableType` | variables.csv | Yes | Categorical or Continuous (from recodeflow) | `"Continuous"` | | `rType` | variables.csv | No | R output type (integer, double, character, date, factor) | `"integer"` | -| `distribution` | variables.csv | Continuous/Date | normal, uniform, gompertz | `"normal"` | +| `distribution` | variables.csv | Continuous/Date | normal, uniform, exponential, gompertz | `"normal"` | | `mean`, `sd` | variables.csv | Normal dist | Distribution parameters | `50`, `15` | | `garbage_low_prop` | variables.csv | No | QA testing (low values) | `0.01` | | `garbage_high_prop` | variables.csv | No | QA testing (high values) | `0.03` | @@ -211,6 +211,7 @@ v006,death_date,NA,"[,]",NA,"[,]",0.02 - `"normal"` - Normal (Gaussian) distribution (requires `mean`, `sd`) - `"uniform"` - Uniform distribution over valid range +- `"exponential"` - Exponential distribution, truncated to the valid range (requires `rate`) **For date variables:** @@ -218,6 +219,10 @@ v006,death_date,NA,"[,]",NA,"[,]",0.02 - `"gompertz"` - Age-related hazard (requires `rate`, `shape`, `followup_min`, `followup_max`, `event_prop`) - `"exponential"` - Constant hazard (requires `rate`, `followup_min`, `followup_max`, `event_prop`) +The `followup_min`, `followup_max`, and `event_prop` fields apply only to the +date/survival use of `exponential`. For continuous variables, `exponential` +requires only `rate`. + **For categorical variables:** Set to `NA` (categories defined by proportions in variable_details.csv) **Examples:** @@ -797,6 +802,7 @@ See [Working with date variables](tutorial-dates.html#source-data-format-simulat |--------------|---------------------|----------|---------| | `normal` | `mean`, `sd` | Age, BMI, normally-distributed measurements | Age: mean=50, sd=15 | | `uniform` | None (uses recStart range) | Equal probability across range | Income brackets, uniform codes | +| `exponential` | `rate` | Exponential distribution, truncated to the valid range | Wait times, time-to-event measurements | **For date variables:** @@ -806,6 +812,10 @@ See [Working with date variables](tutorial-dates.html#source-data-format-simulat | `gompertz` | `rate`, `shape`, `followup_min`, `followup_max`, `event_prop` | Age-related events (death, dementia) | Mortality with increasing hazard by age | | `exponential` | `rate`, `followup_min`, `followup_max`, `event_prop` | Constant hazard events | Loss to follow-up | +Note: the `followup_min`, `followup_max`, and `event_prop` parameters above +apply only to the date/survival use of `exponential`. The continuous-variable +`exponential` distribution requires only `rate`. + **Complete examples:** ```csv From f59197f27e5d9749ceafdf15d70e622d1737e12b Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 6 Jul 2026 07:00:41 -0400 Subject: [PATCH 12/57] Add accepted v0.5 seed-contract ADR (#38) Records the seed/RNG-stream contract for v0.5: L'Ecuyer-CMRG stage sub-streams via parallel::nextRNGStream(), unified save/restore isolation across native/simstudy/legacy paths, RNG-kind pinning within the generation scope, and an always-on fallback stream-switch message. Co-released with the formula evaluator (#39) so v0.5 carries exactly one seeded-output break. Fulfils #22; resolves #33 item 11. Implementation is a separate ADR-gated plan. --- development/adr/v05-seed-contract.md | 266 +++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 development/adr/v05-seed-contract.md diff --git a/development/adr/v05-seed-contract.md b/development/adr/v05-seed-contract.md new file mode 100644 index 0000000..b103d02 --- /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. Implementation is a separate ADR-gated plan (not yet written). Any individual decision below remains open to maintainer revision before implementation begins. +**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. From 1ad2cb73287460209c57dd505f9e13159580b596 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 6 Jul 2026 14:46:28 -0400 Subject: [PATCH 13/57] Add #38 seed-contract implementation plan Three-task plan implementing the accepted v0.5 seed-contract ADR: L'Ecuyer-CMRG per-stage sub-streams, unified path isolation, always-on fallback message, and the budgeted seeded-output re-baseline. --- development/plans/2026-07-06-seed-contract.md | 466 ++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 development/plans/2026-07-06-seed-contract.md 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). From fd4cf47bd6ac59cfcb1460fa618b99a790744ef8 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 6 Jul 2026 14:52:36 -0400 Subject: [PATCH 14/57] 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. --- DESCRIPTION | 1 + R/create_mock_data.R | 15 ++++++------ R/mock_spec_native.R | 34 ++++++++++++++++++++++---- R/mock_spec_postprocess.R | 2 +- R/mock_spec_simstudy.R | 2 +- tests/testthat/test-seed-contract.R | 37 +++++++++++++++++++++++++++++ 6 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 tests/testthat/test-seed-contract.R diff --git a/DESCRIPTION b/DESCRIPTION index 19753c4..596ab22 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -25,6 +25,7 @@ VignetteBuilder: quarto Depends: R (>= 4.2.0) Imports: + parallel, stats Suggests: testthat (>= 3.0.0), diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 655cfe8..1998619 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -96,11 +96,10 @@ } 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) + # 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) } #' Create mock data from configuration files @@ -161,9 +160,9 @@ #' variable uses a feature not yet supported by the v0.4 native backend. Set #' `verbose = TRUE` to see which path was chosen. #' -#' 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 diff --git a/R/mock_spec_native.R b/R/mock_spec_native.R index ffdc0eb..9fd6d1e 100644 --- a/R/mock_spec_native.R +++ b/R/mock_spec_native.R @@ -5,8 +5,19 @@ # 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. 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) { +.with_mock_seed <- function(seed, expr, stage = "baseline") { if (is.null(seed)) { return(force(expr)) } @@ -14,14 +25,18 @@ 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) } - - # Generation should be reproducible without changing the caller's RNG stream. 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)) { @@ -29,7 +44,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") + stream <- .Random.seed + for (i in seq_len(.MOCK_STAGES[[stage]])) { + stream <- parallel::nextRNGStream(stream) + } + assign(".Random.seed", stream, envir = .GlobalEnv) + force(expr) } @@ -354,5 +378,5 @@ generate_mock_data_native <- function(spec, n, seed = NULL) { names(columns) <- names(spec$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..941aaff 100644 --- a/R/mock_spec_postprocess.R +++ b/R/mock_spec_postprocess.R @@ -390,5 +390,5 @@ postprocess_mock_data <- function(data, spec, seed = NULL, diagnostics = TRUE) { } output - }) + }, stage = "postprocess") } diff --git a/R/mock_spec_simstudy.R b/R/mock_spec_simstudy.R index a6b08fc..06c3131 100644 --- a/R/mock_spec_simstudy.R +++ b/R/mock_spec_simstudy.R @@ -244,5 +244,5 @@ generate_mock_data_simstudy <- function(spec, n, seed = NULL) { stringsAsFactors = FALSE, check.names = FALSE ) - }) + }, stage = "baseline") } diff --git a/tests/testthat/test-seed-contract.R b/tests/testthat/test-seed-contract.R new file mode 100644 index 0000000..921763b --- /dev/null +++ b/tests/testthat/test-seed-contract.R @@ -0,0 +1,37 @@ +# 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) +}) From e3d38da255211b6be3389ca9c0ec426ef43d350f Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 6 Jul 2026 15:02:24 -0400 Subject: [PATCH 15/57] Silence RNGkind-quality caution in the seed-contract test The RNGkind-independence test switched the ambient generator to Marsaglia-Multicarry, whose built-in "poor statistical properties" caution fired twice (the switch and the on.exit restore). Wrap the switch and its generation in suppressWarnings; the test's semantics (output identical across ambient kinds) are unchanged. --- tests/testthat/test-seed-contract.R | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/testthat/test-seed-contract.R b/tests/testthat/test-seed-contract.R index 921763b..55e722e 100644 --- a/tests/testthat/test-seed-contract.R +++ b/tests/testthat/test-seed-contract.R @@ -31,7 +31,9 @@ test_that("native output is independent of the caller's ambient 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) + suppressWarnings({ + RNGkind("Marsaglia-Multicarry") + b <- generate_mock_data_native(spec, n = 100, seed = 7) + }) expect_identical(a, b) }) From c50c70c3f334c55ecd2059d89e4d68565f21aa12 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 6 Jul 2026 15:06:49 -0400 Subject: [PATCH 16/57] 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. --- R/create_mock_data.R | 15 ++++++++------- tests/testthat/test-seed-contract.R | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 1998619..24fea16 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -295,6 +295,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 ========== @@ -343,13 +348,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...") @@ -370,6 +368,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 @@ -447,6 +447,7 @@ create_mock_data <- function(databaseStart, skipped_vars <- c(skipped_vars, var_name) } } + }) # ========== RETURN RESULT ========== diff --git a/tests/testthat/test-seed-contract.R b/tests/testthat/test-seed-contract.R index 55e722e..1256abd 100644 --- a/tests/testthat/test-seed-contract.R +++ b/tests/testthat/test-seed-contract.R @@ -37,3 +37,32 @@ test_that("native output is independent of the caller's ambient RNGkind", { }) expect_identical(a, b) }) + +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) +}) From 577ec9a7dcb0ee81f329e09699aa734ca7700259 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 6 Jul 2026 15:19:25 -0400 Subject: [PATCH 17/57] 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. --- NEWS.md | 12 ++++++++++++ R/mock_spec_native.R | 6 ++++-- R/mock_spec_postprocess.R | 6 ++++-- R/mock_spec_simstudy.R | 6 ++++-- tests/testthat/test-seed-contract.R | 26 ++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/NEWS.md b/NEWS.md index 81d86d1..798ac78 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,17 @@ # MockData (development version) +## 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). + - `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 diff --git a/R/mock_spec_native.R b/R/mock_spec_native.R index 9fd6d1e..33ed202 100644 --- a/R/mock_spec_native.R +++ b/R/mock_spec_native.R @@ -345,8 +345,10 @@ #' #' @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. #' @family mock generation APIs diff --git a/R/mock_spec_postprocess.R b/R/mock_spec_postprocess.R index 941aaff..d8802c9 100644 --- a/R/mock_spec_postprocess.R +++ b/R/mock_spec_postprocess.R @@ -306,8 +306,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. #' diff --git a/R/mock_spec_simstudy.R b/R/mock_spec_simstudy.R index 06c3131..57bac79 100644 --- a/R/mock_spec_simstudy.R +++ b/R/mock_spec_simstudy.R @@ -204,8 +204,10 @@ #' #' @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. #' @family mock generation APIs diff --git a/tests/testthat/test-seed-contract.R b/tests/testthat/test-seed-contract.R index 1256abd..f201b05 100644 --- a/tests/testthat/test-seed-contract.R +++ b/tests/testthat/test-seed-contract.R @@ -66,3 +66,29 @@ test_that("legacy path is reproducible for a given seed", { create_mock_data("minimal-example", variables, variable_details, n = 20, seed = 42, validate = FALSE))) expect_identical(a, b) }) + +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(0.626896562612263, 0.068286009645902, 0.426707671898230), + tolerance = 1e-8 + ) +}) From 5bc0b9712e1d403dbbdc5b6290f65be825c20629 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 6 Jul 2026 17:44:58 -0400 Subject: [PATCH 18/57] Pin normal.kind and sample.kind in the seed primitive (#38) Whole-branch review found that set.seed(kind = "L'Ecuyer-CMRG") pinned only the primary generator, so rnorm() and equal-probability sample() still leaked the caller's ambient normal.kind/sample.kind - partially breaking the ADR D3 'independent of ambient RNGkind()' guarantee. Pin all three components (a no-op under R >= 3.6 defaults, so pinned values are unchanged) and add a regression test that varies normal.kind and sample.kind. Scope the NEWS isolation claim to seeded generation calls. --- NEWS.md | 6 +++--- R/mock_spec_native.R | 2 +- tests/testthat/test-seed-contract.R | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 798ac78..2b4a671 100644 --- a/NEWS.md +++ b/NEWS.md @@ -8,9 +8,9 @@ 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). + own expected values against the version you use. For seeded calls, + generation no longer alters the caller's RNG state (previously the legacy + `validate = FALSE` path reset it). - `create_mock_data()` now accepts `n = 0`, returning a zero-row data frame with the full generated schema (useful for schema tests), and rejects diff --git a/R/mock_spec_native.R b/R/mock_spec_native.R index 33ed202..2b3f522 100644 --- a/R/mock_spec_native.R +++ b/R/mock_spec_native.R @@ -47,7 +47,7 @@ # 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") + 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) diff --git a/tests/testthat/test-seed-contract.R b/tests/testthat/test-seed-contract.R index f201b05..ba6d2ca 100644 --- a/tests/testthat/test-seed-contract.R +++ b/tests/testthat/test-seed-contract.R @@ -80,6 +80,20 @@ test_that("full orchestrated pipeline is reproducible for a given seed", { expect_identical(a, b) }) +test_that("native output is independent of ambient normal.kind and sample.kind", { + spec <- mock_spec( + mock_spec_continuous("y", range = c(0, 100), distribution = "normal", mean = 50, sd = 10), + mock_spec_categorical("g", levels = c("a", "b", "c")) + ) + old <- RNGkind() + on.exit(RNGkind(kind = old[1], normal.kind = old[2], sample.kind = old[3]), add = TRUE) + RNGkind(normal.kind = "Inversion", sample.kind = "Rejection") + a <- generate_mock_data_native(spec, n = 200, seed = 7) + suppressWarnings(RNGkind(normal.kind = "Box-Muller", sample.kind = "Rounding")) + b <- generate_mock_data_native(spec, n = 200, seed = 7) + 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 From 280916d5d351072b2a893ca58ecdcd1dbc5909e1 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 6 Jul 2026 17:46:20 -0400 Subject: [PATCH 19/57] Silence deprecated sample.kind caution in the RNGkind-independence test The on.exit restore of the ambient sample.kind = Rounding re-triggered R's caution during the second generation call; extend suppressWarnings to enclose it. Test semantics (output identical across ambient kinds) unchanged. --- tests/testthat/test-seed-contract.R | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/testthat/test-seed-contract.R b/tests/testthat/test-seed-contract.R index ba6d2ca..7c52d26 100644 --- a/tests/testthat/test-seed-contract.R +++ b/tests/testthat/test-seed-contract.R @@ -89,8 +89,10 @@ test_that("native output is independent of ambient normal.kind and sample.kind", on.exit(RNGkind(kind = old[1], normal.kind = old[2], sample.kind = old[3]), add = TRUE) RNGkind(normal.kind = "Inversion", sample.kind = "Rejection") a <- generate_mock_data_native(spec, n = 200, seed = 7) - suppressWarnings(RNGkind(normal.kind = "Box-Muller", sample.kind = "Rounding")) - b <- generate_mock_data_native(spec, n = 200, seed = 7) + suppressWarnings({ + RNGkind(normal.kind = "Box-Muller", sample.kind = "Rounding") + b <- generate_mock_data_native(spec, n = 200, seed = 7) + }) expect_identical(a, b) }) From 0f9bda8de733d407d8f2db20e74b79aeb2103b5c Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 13 Jul 2026 06:36:32 -0400 Subject: [PATCH 20/57] Address PR #48 review: warning leak, stage-index pin, doc accuracy (#38) Five-agent PR review findings. Suppress the RNGkind restore warning in production (previously only silenced in tests), validate the stage name before the seed early-return, and guard the legacy loop against a silent empty result. Pin the frozen .MOCK_STAGES indices and a postprocess-stage value so a future stage insertion can't silently shift seeded output. Add coverage for the always-on fallback message and simstudy isolation. Scope the NEWS 'all randomness' claim to the create_mock_data() pipeline (standalone create_* helpers are unchanged), fix a stale 'set global seed' roxygen bullet, and correct stale seed/seed+1L prose and a render-false worked example in the migrating and recodeflow vignettes. --- NEWS.md | 25 ++++++--- R/create_mock_data.R | 19 ++++++- R/mock_spec_native.R | 18 +++--- R/mock_spec_postprocess.R | 2 +- tests/testthat/test-create-mock-data-v04.R | 65 ++++++++++++++++++++++ tests/testthat/test-seed-contract.R | 25 +++++++++ tests/testthat/test-simstudy-backend.R | 20 +++++++ vignettes/migrating-from-v03-v04.qmd | 15 +++-- vignettes/recodeflow-metadata-v04.qmd | 9 ++- 9 files changed, 171 insertions(+), 27 deletions(-) diff --git a/NEWS.md b/NEWS.md index 2b4a671..634ba62 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,15 +2,22 @@ ## 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. For seeded calls, - generation no longer alters the caller's RNG state (previously the legacy - `validate = FALSE` path reset it). +- 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. Pin your own expected values against the version you use. + For seeded calls, generation no longer alters the caller's RNG state + (previously the legacy `validate = FALSE` path reset it). - `create_mock_data()` now accepts `n = 0`, returning a zero-row data frame with the full generated schema (useful for schema tests), and rejects diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 24fea16..3b28248 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -172,7 +172,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) @@ -449,6 +449,23 @@ create_mock_data <- function(databaseStart, } }) + # 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 ========== if (verbose) { diff --git a/R/mock_spec_native.R b/R/mock_spec_native.R index 2b3f522..e9425f5 100644 --- a/R/mock_spec_native.R +++ b/R/mock_spec_native.R @@ -18,6 +18,9 @@ #' @noRd .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)) } @@ -25,9 +28,6 @@ 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() @@ -36,7 +36,13 @@ 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]) + # 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)) { @@ -338,9 +344,7 @@ #' 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 +#' Formula variables are rejected loudly until the formula/dependency #' milestone promotes the spike evaluator into production. #' #' @param spec A `mock_spec` object. diff --git a/R/mock_spec_postprocess.R b/R/mock_spec_postprocess.R index d8802c9..da6c9f9 100644 --- a/R/mock_spec_postprocess.R +++ b/R/mock_spec_postprocess.R @@ -346,7 +346,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 diff --git a/tests/testthat/test-create-mock-data-v04.R b/tests/testthat/test-create-mock-data-v04.R index 4b5d870..8a09c5f 100644 --- a/tests/testthat/test-create-mock-data-v04.R +++ b/tests/testthat/test-create-mock-data-v04.R @@ -77,6 +77,71 @@ test_that("create_mock_data keeps legacy fallback for unsupported v0.4 backend f expect_null(attr(result, "mockdata_diagnostics")) }) +test_that("create_mock_data announces the always-on legacy fallback message", { + # Same unsupported-distribution scenario as the "lognormal" fallback test + # above, but asserting the always-on (non-verbose-gated) fallback message + # that previously had zero test coverage. + variables <- data.frame( + variable = "time_to_visit", + variableType = "Continuous", + rType = "double", + role = "enabled", + distribution = "lognormal", + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = "time_to_visit", + recStart = "[0, 10]", + recEnd = "copy", + proportion = 1, + stringsAsFactors = FALSE + ) + + expect_message( + create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 50, + seed = 202 + ), + "Falling back to the legacy generator" + ) +}) + +test_that("the always-on legacy fallback message does not fire for validate = FALSE", { + # validate = FALSE never reaches the v0.4 pipeline / fallback branch at + # all, so it must not emit the "Falling back to the legacy generator" + # message (it has its own explicit, verbose-gated announcement instead). + variables <- data.frame( + variable = "smoking", + variableType = "Categorical", + rType = "character", + role = "enabled", + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = "smoking", + recStart = c("1", "2"), + recEnd = c("copy", "copy"), + proportion = c(0.6, 0.4), + stringsAsFactors = FALSE + ) + + expect_no_message( + create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 20, + seed = 404, + validate = FALSE, + verbose = FALSE + ), + message = "Falling back to the legacy generator" + ) +}) + test_that("create_mock_data keeps legacy detail-level databaseStart filtering", { variables <- data.frame( variable = "smoking", diff --git a/tests/testthat/test-seed-contract.R b/tests/testthat/test-seed-contract.R index 7c52d26..6836db9 100644 --- a/tests/testthat/test-seed-contract.R +++ b/tests/testthat/test-seed-contract.R @@ -96,6 +96,31 @@ test_that("native output is independent of ambient normal.kind and sample.kind", expect_identical(a, b) }) +test_that("stage indices are frozen (renumbering would shift seeded output)", { + expect_identical( + MockData:::.MOCK_STAGES, + c(baseline = 0L, postprocess = 1L, formula = 2L, correlate = 3L) + ) +}) + +test_that("postprocess stage output is pinned (catches a stage-index shift)", { + spec <- mock_continuous( + "age", range = c(18, 80), + missing_codes = -99, missing_proportions = 0.3 + ) + baseline <- generate_mock_data_native(spec, n = 5, seed = 20260706) + pp <- postprocess_mock_data(baseline, spec, seed = 20260706) + # Reference captured under the v0.5 L'Ecuyer-CMRG contract, postprocess + # sub-stream (index 1). If this breaks, the postprocess stream shifted + # (e.g. a stage was inserted before it) or generation changed - treat as a + # deliberate, NEWS-documented break, not a silent one. + expect_equal( + pp$age, + c(-99, 22.2337325980459, -99, 50.837302129287, 46.4571529769078), + tolerance = 1e-8 + ) +}) + 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 diff --git a/tests/testthat/test-simstudy-backend.R b/tests/testthat/test-simstudy-backend.R index 93924b5..a435c1f 100644 --- a/tests/testthat/test-simstudy-backend.R +++ b/tests/testthat/test-simstudy-backend.R @@ -202,6 +202,26 @@ test_that("generate_mock_data_simstudy is reproducible", { expect_identical(first, second) }) +test_that("generate_mock_data_simstudy leaves the caller's RNG state and kind untouched", { + skip_if_not_installed("simstudy") + + set.seed(999) + before_state <- .Random.seed + before_kind <- RNGkind() + spec <- mock_spec( + mock_spec_continuous("age", range = c(18, 85), rtype = "integer"), + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ) + ) + generate_mock_data_simstudy(spec, n = 50, seed = 42) + expect_identical(.Random.seed, before_state) + expect_identical(RNGkind(), before_kind) +}) + test_that("generate_mock_data_simstudy handles empty specs and n = 0", { skip_if_not_installed("simstudy") diff --git a/vignettes/migrating-from-v03-v04.qmd b/vignettes/migrating-from-v03-v04.qmd index 9a281b6..87b2ac6 100644 --- a/vignettes/migrating-from-v03-v04.qmd +++ b/vignettes/migrating-from-v03-v04.qmd @@ -139,12 +139,15 @@ table(legacy_data$smoking) ## Understand seed differences -In v0.3, the public seed controlled the legacy generators. In v0.4, the wrapper -uses the public seed for baseline generation and `seed + 1L` for missing-code -and garbage-value post-processing. +In v0.3, the public seed controlled the legacy generators directly. In v0.4/v0.5, +the wrapper passes the same public `seed` to both baseline generation and +missing-code/garbage-value post-processing; internally, each stage draws from +its own independent L'Ecuyer-CMRG sub-stream derived from that single seed +(stage selection happens inside the package, not via `seed + 1L` as in earlier +v0.4 releases - see `NEWS.md` for the v0.5 reproducibility change). -That makes both stages reproducible, but it means exact values may differ from -v0.3 even when you pass the same seed. +That makes both stages reproducible from one seed, but it means exact values +may differ from v0.3 even when you pass the same seed. ```{r} strict_again <- create_mock_data( @@ -241,7 +244,7 @@ validate_mock_spec(spec, strict = TRUE) ```{r} baseline <- generate_mock_data_native(spec, n = 50, seed = 456) -postprocessed <- postprocess_mock_data(baseline, spec, seed = 457) +postprocessed <- postprocess_mock_data(baseline, spec, seed = 456) identical(strict_data, postprocessed) ``` diff --git a/vignettes/recodeflow-metadata-v04.qmd b/vignettes/recodeflow-metadata-v04.qmd index 51774fb..3519839 100644 --- a/vignettes/recodeflow-metadata-v04.qmd +++ b/vignettes/recodeflow-metadata-v04.qmd @@ -213,12 +213,15 @@ head(baseline) ``` ```{r} -postprocessed <- postprocess_mock_data(baseline, spec, seed = 124) +postprocessed <- postprocess_mock_data(baseline, spec, seed = 123) head(postprocessed) ``` -The wrapper uses the same idea: the public seed controls baseline generation, -and `seed + 1L` controls post-processing. +The wrapper uses the same idea: baseline generation and post-processing are +both passed the same public `seed`, and each stage internally draws from its +own independent L'Ecuyer-CMRG sub-stream derived from that seed (not +`seed + 1L`, as in earlier v0.4 releases - see `NEWS.md` for the v0.5 +reproducibility change). ## Database filtering From e4586c661c75996dbb38809e2c5ea1b8c39e971d Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Tue, 7 Jul 2026 03:12:05 -0400 Subject: [PATCH 21/57] Add accepted v0.5 formula-evaluator ADR (#39) Records the formula-derived-variable contract for v0.5: a mockFormula extension column on variable_details, evaluated as a post-baseline stage over generated data in a restricted sandbox (generated columns + a base-function allow-list), with dependency ordering and cycle detection recovered from the v0.4 spike. Post-baseline evaluation adds no seeded-output break beyond #38's, so #39 co-releases with #38 in v0.5 without spending a second break. Phase A (algebraic) only; Func:: dispatch deferred. Depends on #38. --- development/adr/v05-formula-evaluator.md | 251 +++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 development/adr/v05-formula-evaluator.md diff --git a/development/adr/v05-formula-evaluator.md b/development/adr/v05-formula-evaluator.md new file mode 100644 index 0000000..219652d --- /dev/null +++ b/development/adr/v05-formula-evaluator.md @@ -0,0 +1,251 @@ +# 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. +**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. + +**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. From 3d2549c3af949e8b0f5973c4d25b10c1221fb277 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 16 Jul 2026 17:11:05 -0400 Subject: [PATCH 22/57] Add #39 formula-evaluator implementation plan Four-task plan implementing the accepted v0.5 formula-evaluator ADR: formula spec type with construction-time sandbox validation, a post-baseline evaluate_mock_formulas() stage on the reserved formula sub-stream, mockFormula adapter wiring with the DerivedVar carve-out, and a stale-claims docs sweep. Additive: no seeded-output change, enforced by the #48 stage-index and pinned-value tests. --- .../plans/2026-07-16-formula-evaluator.md | 675 ++++++++++++++++++ 1 file changed, 675 insertions(+) create mode 100644 development/plans/2026-07-16-formula-evaluator.md 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..98d47ba --- /dev/null +++ b/development/plans/2026-07-16-formula-evaluator.md @@ -0,0 +1,675 @@ +# 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). + +- [ ] **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" + ) +}) +``` + +- [ ] **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"`. + +- [ ] **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 +} +``` + +- [ ] **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 + ) + variable$depends_on <- .formula_dependencies(variable) + 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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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")`.) + +- [ ] **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"). + +- [ ] **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. + +- [ ] **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) + } + ordered <- .order_formula_variables(spec) + if (length(ordered) == 0) { + return(data) + } + .validate_formula_referents(spec) + + 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. + +- [ ] **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. + +- [ ] **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) +}) +``` + +- [ ] **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. + +- [ ] **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.) + +- [ ] **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. + +- [ ] **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) + +- [ ] **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. +``` + +- [ ] **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)." + +- [ ] **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. From 9dba1c0fd4a1068c1fac8a21aeb6703b76422471 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 16 Jul 2026 17:15:20 -0400 Subject: [PATCH 23/57] 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. --- NAMESPACE | 2 + R/mock_spec.R | 139 +++++++++++++++++++++++++++++ R/mock_spec_formula.R | 104 +++++++++++++++++++++ tests/testthat/test-formula-vars.R | 54 +++++++++++ 4 files changed, 299 insertions(+) create mode 100644 R/mock_spec_formula.R create mode 100644 tests/testthat/test-formula-vars.R diff --git a/NAMESPACE b/NAMESPACE index 987afd8..e3f1089 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -31,10 +31,12 @@ 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(parse_range_notation) export(parse_variable_start) diff --git a/R/mock_spec.R b/R/mock_spec.R index 3b3df4c..72c212d 100644 --- a/R/mock_spec.R +++ b/R/mock_spec.R @@ -463,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. @@ -627,6 +694,48 @@ 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 + ) + variable$depends_on <- .formula_dependencies(variable) + variable +} + #' Check whether an object is a MockData specification #' #' @param x Object to check. @@ -851,6 +960,27 @@ 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 { errors <- c(errors, paste0("Variable '", variable$name, "' has unsupported type '", variable$type, "'.")) } @@ -908,6 +1038,15 @@ 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) + } } } diff --git a/R/mock_spec_formula.R b/R/mock_spec_formula.R new file mode 100644 index 0000000..b4ac698 --- /dev/null +++ b/R/mock_spec_formula.R @@ -0,0 +1,104 @@ +# ============================================================================== +# 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 +} diff --git a/tests/testthat/test-formula-vars.R b/tests/testthat/test-formula-vars.R new file mode 100644 index 0000000..a797f9b --- /dev/null +++ b/tests/testthat/test-formula-vars.R @@ -0,0 +1,54 @@ +# 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" + ) +}) From 0d91853ee42ef4c2e41334ad3a10273a2a080ca7 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 16 Jul 2026 17:28:20 -0400 Subject: [PATCH 24/57] Defer formula parse errors to validate_mock_spec (#39) Review finding: the constructor's eager depends_on computation threw on syntax errors, bypassing error accumulation and the validate = FALSE opt-out that every sibling constructor honours. Parse failures now surface only through validation (making the per-variable parse branch live), with tests for the validate = FALSE path and multi-error accumulation. Plan code block synced. --- R/mock_spec.R | 9 ++++- .../plans/2026-07-16-formula-evaluator.md | 9 ++++- tests/testthat/test-formula-vars.R | 34 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/R/mock_spec.R b/R/mock_spec.R index 72c212d..04cb0fb 100644 --- a/R/mock_spec.R +++ b/R/mock_spec.R @@ -732,7 +732,14 @@ mock_spec_formula <- function(name, provenance = provenance, model_hint = model_hint ) - variable$depends_on <- .formula_dependencies(variable) + # 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 } diff --git a/development/plans/2026-07-16-formula-evaluator.md b/development/plans/2026-07-16-formula-evaluator.md index 98d47ba..511aec6 100644 --- a/development/plans/2026-07-16-formula-evaluator.md +++ b/development/plans/2026-07-16-formula-evaluator.md @@ -277,7 +277,14 @@ mock_spec_formula <- function(name, provenance = provenance, model_hint = model_hint ) - variable$depends_on <- .formula_dependencies(variable) + # 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 } ``` diff --git a/tests/testthat/test-formula-vars.R b/tests/testthat/test-formula-vars.R index a797f9b..c7c65ee 100644 --- a/tests/testthat/test-formula-vars.R +++ b/tests/testthat/test-formula-vars.R @@ -52,3 +52,37 @@ test_that("an unparseable formula errors at validation", { "could not be parsed" ) }) + +test_that("mock_spec_formula() defers parse errors instead of throwing eagerly", { + # The constructor itself must not throw on bad syntax; depends_on falls + # back to character(0) and the parse error is reported by validation. + variable <- mock_spec_formula("bad", formula = "x +* 2") + expect_identical(variable$depends_on, character(0)) +}) + +test_that("validate = FALSE constructs a spec with a bad-syntax formula", { + expect_no_error( + spec <- mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("bad", formula = "x +* 2"), + validate = FALSE + ) + ) + + result <- validate_mock_spec(spec, strict = FALSE) + expect_false(result$valid) + expect_true(any(grepl("could not be parsed", result$errors))) +}) + +test_that("validate_mock_spec accumulates errors for multiple bad-syntax formulas", { + spec <- mock_spec( + mock_spec_formula("bad1", formula = "x +* 2"), + mock_spec_formula("bad2", formula = "y +* 3"), + validate = FALSE + ) + + result <- validate_mock_spec(spec, strict = FALSE) + expect_false(result$valid) + expect_true(any(grepl("bad1", result$errors) & grepl("could not be parsed", result$errors))) + expect_true(any(grepl("bad2", result$errors) & grepl("could not be parsed", result$errors))) +}) From 7e5a8f45b69ad44d30f2d5233916eee721174a87 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 16 Jul 2026 17:40:42 -0400 Subject: [PATCH 25/57] Add evaluate_mock_formulas() with sandboxed post-baseline evaluation (#39) generate_mock_data_native() now skips type = "formula" variables (they carry no distribution to sample from) instead of rejecting the whole spec; a formula-only spec still returns the usual n-row, zero-column frame. The new exported evaluate_mock_formulas(data, spec, seed) computes each formula variable afterwards, in dependency order, under .with_mock_seed(stage = "formula"): evaluation runs in a restricted environment (data columns via as.list(), enclosing on a fixed base-function allow-list whose parent is emptyenv()) so a formula can never reach the caller's frame or globalenv, matching the sandbox contract from the ADR/plan. Also fixes a Task 1 review finding: .validate_formula_referents() and .order_formula_variables() re-parsed every formula and aborted on the first parse failure, which both duplicated the "could not be parsed" error and masked referent/cycle findings for other, parseable formulas in the same spec. Both now skip unparseable formulas (the per-variable validator already reports that error once) and keep checking the rest. --- NAMESPACE | 1 + R/mock_spec_formula.R | 126 ++++++++++++++++++++++++++++- R/mock_spec_native.R | 45 ++++++++--- tests/testthat/test-formula-vars.R | 113 ++++++++++++++++++++++++++ 4 files changed, 274 insertions(+), 11 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index e3f1089..e6166e4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -12,6 +12,7 @@ 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) diff --git a/R/mock_spec_formula.R b/R/mock_spec_formula.R index b4ac698..cea5840 100644 --- a/R/mock_spec_formula.R +++ b/R/mock_spec_formula.R @@ -53,6 +53,15 @@ 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( @@ -80,12 +89,25 @@ formula_names <- names(spec$variables)[ vapply(spec$variables, .is_formula_variable, logical(1)) ] - remaining <- formula_names + # 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) + + remaining <- parseable_names ordered <- character(0) while (length(remaining) > 0) { progressed <- FALSE for (name in remaining) { - deps <- intersect(.formula_dependencies(spec$variables[[name]]), formula_names) + deps <- intersect(.formula_dependencies(spec$variables[[name]]), parseable_names) if (all(deps %in% ordered)) { ordered <- c(ordered, name) remaining <- setdiff(remaining, name) @@ -102,3 +124,103 @@ } ordered } + +#' @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`. +#' @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) + } + ordered <- .order_formula_variables(spec) + if (length(ordered) == 0) { + return(data) + } + .validate_formula_referents(spec) + + 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 + ) + ) +} diff --git a/R/mock_spec_native.R b/R/mock_spec_native.R index e9425f5..7a29680 100644 --- a/R/mock_spec_native.R +++ b/R/mock_spec_native.R @@ -79,6 +79,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) == "")) @@ -333,8 +341,8 @@ #' #' `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 @@ -344,8 +352,14 @@ #' values, and diagnostics are intentionally handled by [postprocess_mock_data()] #' so that all backends share the same audit trail. #' -#' 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. @@ -354,10 +368,13 @@ #' 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 [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( @@ -377,11 +394,21 @@ generate_mock_data_native <- function(spec, n, seed = NULL) { .check_native_backend_scope(spec) .with_mock_seed(seed, { - if (length(spec$variables) == 0) { + # type = "formula" variables have no distribution to sample from; they + # are computed post-baseline by evaluate_mock_formulas(). 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_formula_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/tests/testthat/test-formula-vars.R b/tests/testthat/test-formula-vars.R index c7c65ee..fce6c4a 100644 --- a/tests/testthat/test-formula-vars.R +++ b/tests/testthat/test-formula-vars.R @@ -86,3 +86,116 @@ test_that("validate_mock_spec accumulates errors for multiple bad-syntax formula expect_true(any(grepl("bad1", result$errors) & grepl("could not be parsed", result$errors))) expect_true(any(grepl("bad2", result$errors) & grepl("could not be parsed", result$errors))) }) + +test_that("an unparseable formula does not mask referent errors on other formulas", { + # Folded-in review finding from Task 1: spec-level checks re-parsed every + # formula and failed fast, so one unparseable formula both (a) produced a + # duplicate "could not be parsed" error alongside the per-variable one and + # (b) masked referent/cycle findings for the OTHER, parseable formulas. + # Fix: .validate_formula_referents()/.order_formula_variables() must skip + # variables whose formula fails to parse. + spec <- mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("bad", formula = "x +* 2"), + mock_spec_formula("orphan", formula = "unknown_var + 1"), + validate = FALSE + ) + + result <- validate_mock_spec(spec, strict = FALSE) + expect_false(result$valid) + + parse_errors <- grepl("bad", result$errors) & grepl("could not be parsed", result$errors) + expect_identical(sum(parse_errors), 1L) # no duplicate parse entry + + referent_errors <- grepl("orphan", result$errors) & grepl("references unknown variable", result$errors) + expect_true(any(referent_errors)) +}) + +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 + # 'leaky' is not a spec variable -> referent validation rejects it at + # construction time (eager validation, confirmed in Task 1), before the + # sandbox is ever exercised. The sandbox property itself is pinned by the + # eval-environment test below. + expect_error( + mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("z", formula = "x + leaky") + ), + "references unknown variable" + ) +}) + +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") +}) From 612810cf94e90bafc6aa78c5ccd4837962d58248 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 16 Jul 2026 17:58:21 -0400 Subject: [PATCH 26/57] Strict-validate specs at evaluator entry and mirror the formula skip in simstudy (#39) Task 2 review: the accumulate-and-skip helpers let evaluate_mock_formulas() silently drop unparseable formula variables under validate = FALSE; the evaluator now strict-validates its spec at entry like postprocess_mock_data(), so bad formulas fail loudly before generation. The simstudy baseline path gains the same formula-variable skip as the native backend, restoring the documented two-step workflow. Pins the length-mismatch, single-row scalar, and formula-only-spec edges. --- R/mock_spec_formula.R | 18 ++++- R/mock_spec_simstudy.R | 31 ++++++-- .../plans/2026-07-16-formula-evaluator.md | 12 ++- tests/testthat/test-formula-vars.R | 75 +++++++++++++++++++ tests/testthat/test-simstudy-backend.R | 32 ++++++++ 5 files changed, 160 insertions(+), 8 deletions(-) diff --git a/R/mock_spec_formula.R b/R/mock_spec_formula.R index cea5840..13885ed 100644 --- a/R/mock_spec_formula.R +++ b/R/mock_spec_formula.R @@ -153,7 +153,10 @@ #' @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`. +#' `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. #' @@ -165,11 +168,22 @@ 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) } - .validate_formula_referents(spec) non_formula <- setdiff(names(spec$variables), names(spec$variables)[ vapply(spec$variables, .is_formula_variable, logical(1)) diff --git a/R/mock_spec_simstudy.R b/R/mock_spec_simstudy.R index 57bac79..3c47247 100644 --- a/R/mock_spec_simstudy.R +++ b/R/mock_spec_simstudy.R @@ -128,7 +128,14 @@ #' @noRd .native_only_variables <- function(spec) { - Filter(function(variable) !.simstudy_can_generate(variable), spec$variables) + # Mirror generate_mock_data_native()'s skip: type = "formula" variables have + # no distribution to sample from in either backend. They are computed + # post-baseline by evaluate_mock_formulas(), 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_formula_variable(variable) && !.simstudy_can_generate(variable) + }, spec$variables) } #' @noRd @@ -202,6 +209,11 @@ #' `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 seed. Generation uses an isolated @@ -209,10 +221,12 @@ #' 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") @@ -229,6 +243,13 @@ generate_mock_data_simstudy <- function(spec, n, seed = NULL) { simstudy_variables <- .simstudy_variables(spec) native_only_variables <- .native_only_variables(spec) + # type = "formula" variables 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_formula_variable, logical(1)) + ] .with_mock_seed(seed, { simstudy_data <- .generate_simstudy_baseline(simstudy_variables, n) @@ -237,12 +258,12 @@ 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 ) diff --git a/development/plans/2026-07-16-formula-evaluator.md b/development/plans/2026-07-16-formula-evaluator.md index 511aec6..c04d70d 100644 --- a/development/plans/2026-07-16-formula-evaluator.md +++ b/development/plans/2026-07-16-formula-evaluator.md @@ -498,11 +498,21 @@ 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) } - .validate_formula_referents(spec) non_formula <- setdiff(names(spec$variables), names(spec$variables)[ vapply(spec$variables, .is_formula_variable, logical(1)) diff --git a/tests/testthat/test-formula-vars.R b/tests/testthat/test-formula-vars.R index fce6c4a..fa1b93a 100644 --- a/tests/testthat/test-formula-vars.R +++ b/tests/testthat/test-formula-vars.R @@ -199,3 +199,78 @@ test_that("rtype coercion applies to formula outputs", { result <- evaluate_mock_formulas(baseline, spec) expect_s3_class(result$band, "factor") }) + +test_that("evaluate_mock_formulas errors rather than silently dropping an unparseable formula", { + # Task 2 review finding 1: .order_formula_variables() filters unparseable + # formulas out of `ordered` (correct for validate_mock_spec's accumulation), + # but the evaluator conflated "no formula variables" with "no *parseable* + # formula variables" and returned `data` unchanged with no error. Baseline + # is built directly (not via generate_mock_data_native()), because that + # generator's own entry-point strict validation would already reject this + # spec before we can reach evaluate_mock_formulas(). + spec <- mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("bad", formula = "x +* 2"), + validate = FALSE + ) + baseline <- data.frame(x = c(0.1, 0.2, 0.3)) + + expect_error( + evaluate_mock_formulas(baseline, spec), + "could not be parsed" + ) +}) + +test_that("evaluate_mock_formulas errors (not partial output) with a mixed bad + good formula spec", { + spec <- mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("bad", formula = "x +* 2"), + mock_spec_formula("good", formula = "x * 2"), + validate = FALSE + ) + baseline <- data.frame(x = c(0.1, 0.2, 0.3)) + + expect_error( + evaluate_mock_formulas(baseline, spec), + "could not be parsed" + ) +}) + +test_that("evaluate_mock_formulas errors clearly on a length-mismatched formula result", { + spec <- mock_spec( + mock_spec_continuous("x", range = c(0, 1)), + mock_spec_formula("bad_len", formula = "c(1, 2, 3)") + ) + baseline <- generate_mock_data_native(spec, n = 10, seed = 1) + + expect_error( + evaluate_mock_formulas(baseline, spec), + "returned length 3, expected 10" + ) +}) + +test_that("evaluate_mock_formulas works for nrow == 1 with a scalar formula result (no rep needed)", { + 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 = 1, seed = 1) + + result <- evaluate_mock_formulas(baseline, spec) + expect_identical(nrow(result), 1L) + expect_identical(result$z, baseline$x * 2) +}) + +test_that("evaluate_mock_formulas handles a formula-only spec via a constant, broadcasting formula", { + # A formula-only spec has no non-formula columns to reference, so its + # baseline is an n-row, zero-column data frame (see + # generate_mock_data_native()'s formula skip). A constant formula still + # broadcasts correctly over that 0-column frame (verified manually before + # pinning: length(values) == 1 && nrow(data) != 1 triggers the rep() path). + spec <- mock_spec(mock_spec_formula("k", formula = "1 + 1")) + baseline <- generate_mock_data_native(spec, n = 7, seed = 1) + expect_identical(dim(baseline), c(7L, 0L)) + + result <- evaluate_mock_formulas(baseline, spec) + expect_identical(result$k, rep(2, 7)) +}) diff --git a/tests/testthat/test-simstudy-backend.R b/tests/testthat/test-simstudy-backend.R index a435c1f..2f7a8e1 100644 --- a/tests/testthat/test-simstudy-backend.R +++ b/tests/testthat/test-simstudy-backend.R @@ -251,3 +251,35 @@ test_that("generate_mock_data_simstudy keeps deferred formula variables loud", { "Formula evaluation is not yet implemented" ) }) + +test_that("generate_mock_data_simstudy skips genuine formula variables like the native backend, restoring the two-step workflow", { + # Task 2 review finding 2: the simstudy baseline path had no formula filter, + # so a genuine type = "formula" variable died with "Native backend does not + # support variable type 'formula'" inside .generate_native_only_baseline(). + skip_if_not_installed("simstudy") + + 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_simstudy(spec, n = 20, seed = 11) + expect_false("bmi" %in% names(baseline)) + + result <- evaluate_mock_formulas(baseline, spec, seed = 11) + expect_identical(result$bmi, baseline$weight / (baseline$height^2)) +}) + +test_that(".native_only_variables() excludes formula variables regardless of simstudy availability", { + # Pins the fix's core logic in a helper that needs no simstudy install, per + # the review's request to pin it even where simstudy is absent. + spec <- mock_spec( + mock_spec_categorical("smoking", levels = c("never", "current")), + mock_spec_continuous("age_normal", range = c(18, 85), distribution = "normal", mean = 40, sd = 10), + mock_spec_formula("bmi", formula = "age_normal * 2") + ) + + result <- .native_only_variables(spec) + expect_setequal(names(result), "age_normal") +}) From b2ff0e0ff5fd39de2a92fb9969a57e6903e98447 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 16 Jul 2026 18:08:48 -0400 Subject: [PATCH 27/57] Wire mockFormula through the recodeflow adapter and orchestrator (#39) Adapter (R/mock_spec_recodeflow.R): - .details_mock_formula(details) reads the first non-blank mockFormula among a variable's detail rows ("" if absent/blank). - .recodeflow_to_spec_variable() constructs via mock_spec_formula() when a non-blank mockFormula is present, ahead of the kind-dispatched constructors, reusing the row's already-extracted rtype/missing-code/ garbage settings so postprocess behaves identically to other variables. - The exclude_derived block now keeps DerivedVar::/Func:: rows that carry a non-blank mockFormula (D6 carve-out); bare DerivedVar::/Func:: rows stay excluded exactly as before. Orchestrator (R/create_mock_data.R): - Inverted the unsupported-variable check: type == "formula" is now supported end-to-end. A stray non-empty `formula` field on a non-formula variable still triggers the legacy fallback (defensive). - Pipeline is now baseline -> evaluate_mock_formulas() -> postprocess; comment updated to name all three seed sub-streams. Diagnostics (R/mock_spec_postprocess.R): - .postprocess_empty_diagnostics() adds derived = TRUE, formula, and depends_on to each formula variable's diagnostics entry (D5). Tests: extended tests/testthat/test-formula-vars.R with the Task 3 brief's end-to-end mockFormula test, the D6 backward-compat exclusion test, the formula-missing-codes postprocess test, and the formula-free no-op pin. Full suite: FAIL 0 | WARN 35 | SKIP 12 | PASS 746 (738 baseline + 8 new, no new WARN/SKIP). #48 seed-contract pins: 13/13 pass untouched. devtools::check(): 0 errors, 0 warnings, 3 pre-existing notes. --- R/create_mock_data.R | 19 +++++-- R/mock_spec_postprocess.R | 13 ++++- R/mock_spec_recodeflow.R | 54 +++++++++++++++++++ tests/testthat/test-formula-vars.R | 85 ++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 5 deletions(-) diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 3b28248..0817207 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -10,6 +10,15 @@ #' @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) + } + formula <- variable$formula has_formula <- !is.null(formula) && !(is.character(formula) && length(formula) == 1 && (is.na(formula) || trimws(formula) == "")) @@ -96,10 +105,12 @@ } 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) + staged <- evaluate_mock_formulas(baseline, spec, seed = seed) + # Baseline generation, 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 three stages pass the + # same seed and select their own stage internally. + postprocess_mock_data(staged, spec, seed = seed) } #' Create mock data from configuration files diff --git a/R/mock_spec_postprocess.R b/R/mock_spec_postprocess.R index da6c9f9..cb19225 100644 --- a/R/mock_spec_postprocess.R +++ b/R/mock_spec_postprocess.R @@ -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,17 @@ 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 + } + + entry }) list( diff --git a/R/mock_spec_recodeflow.R b/R/mock_spec_recodeflow.R index aa85c70..a768f3b 100644 --- a/R/mock_spec_recodeflow.R +++ b/R/mock_spec_recodeflow.R @@ -245,6 +245,27 @@ ) } +#' @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("") + } + + trimws(values[1]) +} + #' @noRd .recodeflow_distribution <- function(var_row, details) { distribution <- tolower(.row_character(var_row, "distribution", "")) @@ -311,6 +332,26 @@ missing <- .recodeflow_missing(details) 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) { @@ -470,6 +511,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( + variable_details[variable_details$variable == v, , drop = FALSE] + )) + }, logical(1)) + derived <- derived[!has_mock_formula] + removed <- intersect(variables$variable, derived) if (length(removed) > 0) { message( diff --git a/tests/testthat/test-formula-vars.R b/tests/testthat/test-formula-vars.R index fa1b93a..18bb302 100644 --- a/tests/testthat/test-formula-vars.R +++ b/tests/testthat/test-formula-vars.R @@ -274,3 +274,88 @@ test_that("evaluate_mock_formulas handles a formula-only spec via a constant, br result <- evaluate_mock_formulas(baseline, spec) expect_identical(result$k, rep(2, 7)) }) + +# ------------------------------------------------------------------------ +# Task 3: adapter + orchestrator wiring, diagnostics, end-to-end (#39) +# ------------------------------------------------------------------------ + +test_that("mockFormula metadata generates a derived column end-to-end", { + variables <- data.frame( + variable = c("height", "weight", "bmi"), + variableType = c("Continuous", "Continuous", "Continuous"), + rType = c("double", "double", "double"), + role = c("enabled", "enabled", "enabled"), + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = c("height", "weight", "bmi"), + recStart = c("[1.4, 2.1]", "[45, 150]", "DerivedVar::[height, weight]"), + recEnd = c("copy", "copy", "Func::bmi_fun"), + proportion = c(1, 1, 1), + mockFormula = c("", "", "weight / (height^2)"), + stringsAsFactors = FALSE + ) + + result <- suppressMessages(create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 30, + seed = 4 + )) + + expect_true("bmi" %in% names(result)) + diag <- attr(result, "mockdata_diagnostics") + expect_false(is.null(diag)) + 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)", { + variables <- data.frame( + variable = c("height", "weight", "bmi"), + variableType = c("Continuous", "Continuous", "Continuous"), + rType = c("double", "double", "double"), + role = c("enabled", "enabled", "enabled"), + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = c("height", "weight", "bmi"), + recStart = c("[1.4, 2.1]", "[45, 150]", "DerivedVar::[height, weight]"), + recEnd = c("copy", "copy", "Func::bmi_fun"), + proportion = c(1, 1, 1), + stringsAsFactors = FALSE + ) + + result <- suppressMessages(create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = 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) +}) From af1fb0f617f85c123495a8147de1cdbb869cddee Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 16 Jul 2026 18:19:00 -0400 Subject: [PATCH 28/57] Scope the D6 mockFormula carve-out to the requested databaseStart (#39) Review finding: the exclude_derived carve-out read unfiltered variable_details while construction read databaseStart-filtered rows, so a variable with a mockFormula in one cycle but not another crashed generation for the cycle without it instead of staying excluded. Both decisions now use the same .filter_recodeflow_details() view, with a multi-database regression test pinning both directions. --- R/mock_spec_recodeflow.R | 2 +- .../plans/2026-07-16-formula-evaluator.md | 2 +- tests/testthat/test-formula-vars.R | 44 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/R/mock_spec_recodeflow.R b/R/mock_spec_recodeflow.R index a768f3b..140eef7 100644 --- a/R/mock_spec_recodeflow.R +++ b/R/mock_spec_recodeflow.R @@ -519,7 +519,7 @@ mock_spec_from_recodeflow <- function(variables, # behaviour unchanged. has_mock_formula <- vapply(derived, function(v) { !.is_blank(.details_mock_formula( - variable_details[variable_details$variable == v, , drop = FALSE] + .filter_recodeflow_details(variable_details, v, databaseStart) )) }, logical(1)) derived <- derived[!has_mock_formula] diff --git a/development/plans/2026-07-16-formula-evaluator.md b/development/plans/2026-07-16-formula-evaluator.md index c04d70d..87ac907 100644 --- a/development/plans/2026-07-16-formula-evaluator.md +++ b/development/plans/2026-07-16-formula-evaluator.md @@ -635,7 +635,7 @@ test_that("existing formula-free seeded output is unchanged by the formula stage - [ ] **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. + 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). - [ ] **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). diff --git a/tests/testthat/test-formula-vars.R b/tests/testthat/test-formula-vars.R index 18bb302..7502e6a 100644 --- a/tests/testthat/test-formula-vars.R +++ b/tests/testthat/test-formula-vars.R @@ -338,6 +338,50 @@ test_that("DerivedVar rows without mockFormula stay excluded (D6, backward compa expect_false("bmi" %in% names(result)) }) +test_that("D6 carve-out is scoped per databaseStart, not cross-cycle (#39 review finding)", { + # bmi has a mockFormula in cycle1 but not in cycle2. Requesting cycle2 must + # exclude bmi exactly as it would have before the mockFormula feature + # existed -- construction is databaseStart-scoped, so the exclude_derived + # carve-out must be too, or a cycle1-only formula leaks into cycle2's + # decision and construction crashes trying to build bmi as a continuous + # variable from a DerivedVar:: recStart. + variables <- data.frame( + variable = c("height", "weight", "bmi"), + variableType = c("Continuous", "Continuous", "Continuous"), + rType = c("double", "double", "double"), + role = c("enabled", "enabled", "enabled"), + databaseStart = c("", "", ""), + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = c("height", "weight", "bmi", "bmi"), + recStart = c( + "[1.4, 2.1]", "[45, 150]", + "DerivedVar::[height, weight]", "DerivedVar::[height, weight]" + ), + recEnd = c("copy", "copy", "Func::bmi_fun", "Func::bmi_fun"), + proportion = c(1, 1, 1, 1), + mockFormula = c("", "", "weight / (height^2)", ""), + databaseStart = c("", "", "cycle1", "cycle2"), + stringsAsFactors = FALSE + ) + + expect_no_error( + cycle2_spec <- mock_spec_from_recodeflow( + variables, variable_details, + databaseStart = "cycle2", role = "enabled" + ) + ) + expect_false("bmi" %in% names(cycle2_spec$variables)) + + cycle1_spec <- mock_spec_from_recodeflow( + variables, variable_details, + databaseStart = "cycle1", role = "enabled" + ) + expect_true("bmi" %in% names(cycle1_spec$variables)) + expect_identical(cycle1_spec$variables$bmi$type, "formula") +}) + test_that("formula variables receive their own missing codes in postprocess", { spec <- mock_spec( mock_spec_continuous("x", range = c(1, 9)), From a969417a46ee03caf14bed2f66fa72fa196f8856 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 16 Jul 2026 23:00:23 -0400 Subject: [PATCH 29/57] Document formula-derived variables and refresh deferred-work claims (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEWS gains the Phase A feature entry (mockFormula column, mock_formula() API, sandboxed dependency-ordered evaluation; DerivedVar::/Func:: semantics unchanged). Stale-claims sweep: design-philosophy's deliberately-deferred section now records that algebraic mockFormula generation landed in v0.5 while Func:: dispatch and a general evaluator remain deferred; advanced-topics and reference-config qualify their blanket derived-variables-are-never-generated claims with the mockFormula carve-out; the formula-evaluator ADR status notes the implementation. Verified the migration vignette's fallback-conditions prose against a fresh render — no formula-fallback claim to update. --- NEWS.md | 11 +++++++++++ development/adr/v05-formula-evaluator.md | 2 +- vignettes/advanced-topics.qmd | 2 +- vignettes/design-philosophy-v04.qmd | 9 ++++++--- vignettes/reference-config.qmd | 2 +- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/NEWS.md b/NEWS.md index 634ba62..ba45634 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,16 @@ # MockData (development version) +## 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. + ## Reproducibility (breaking change) - Seeded output changed once for the RNG-stream mechanism in this release. diff --git a/development/adr/v05-formula-evaluator.md b/development/adr/v05-formula-evaluator.md index 219652d..f53180f 100644 --- a/development/adr/v05-formula-evaluator.md +++ b/development/adr/v05-formula-evaluator.md @@ -1,6 +1,6 @@ # 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. +**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). **Date**: 2026-07-07 **Decision owner**: MockData maintainers **Issue**: #39 diff --git a/vignettes/advanced-topics.qmd b/vignettes/advanced-topics.qmd index b943cde..f67fab3 100644 --- a/vignettes/advanced-topics.qmd +++ b/vignettes/advanced-topics.qmd @@ -36,7 +36,7 @@ This guide covers advanced technical topics for MockData power users, including: ## Derived variables -Derived variables are calculated from other variables using custom functions. MockData identifies and skips derived variables during generation, leaving them for post-generation calculation. +Derived variables are calculated from other variables using custom functions. MockData identifies and skips derived variables during generation, leaving them for post-generation calculation — unless the variable also carries a `mockFormula` expression (v0.5+), in which case it is generated natively from that formula. See `vignette("design-philosophy-v04")` and `NEWS.md` for the formula-derived variables feature; the pattern below still applies to `DerivedVar::`/`Func::` variables without a `mockFormula`. ### Identifying derived variables diff --git a/vignettes/design-philosophy-v04.qmd b/vignettes/design-philosophy-v04.qmd index 8af0afe..9e25425 100644 --- a/vignettes/design-philosophy-v04.qmd +++ b/vignettes/design-philosophy-v04.qmd @@ -250,9 +250,12 @@ especially for `validate = FALSE`, `variable_details = NULL`, detail-level Several features are intentionally not solved in v0.4. -Formula-derived variables are detected and kept loud rather than silently -ignored. They need a dependency-aware evaluator, sandboxing rules, and clear -syntax. +Algebraic formula-derived variables landed in v0.5 (Phase A): a variable +carrying a `mockFormula` expression (e.g. `weight / (height^2)`) is generated +by evaluating that expression, in dependency order, over a restricted +sandbox exposing only the generated columns and a fixed allow-list of base +functions. `Func::` dispatch to a consuming package's own derivation +function (Phase B), and a more general evaluator, remain deferred. Multi-variable correlation and richer joint distributions are future work. `simstudy` is one possible engine for those features, but v0.4 does not claim to diff --git a/vignettes/reference-config.qmd b/vignettes/reference-config.qmd index 48bcb2c..cf13af1 100644 --- a/vignettes/reference-config.qmd +++ b/vignettes/reference-config.qmd @@ -1597,7 +1597,7 @@ bmi_fun <- function(height, weight) { mock_data$BMI_derived <- bmi_fun(mock_data$height, mock_data$weight) ``` -**Note:** Derived variables are NOT generated by create_mock_data(). See [Advanced topics: Derived variables](advanced-topics.html#derived-variables) for details. +**Note:** Derived variables are NOT generated by create_mock_data() unless they also carry a `mockFormula` expression (v0.5+); the example above has no `mockFormula`, so `BMI_derived` is excluded as shown. See [Advanced topics: Derived variables](advanced-topics.html#derived-variables) for details. --- From 779463dc3b870e5f7cbc3445f82ec18963c15c26 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Fri, 17 Jul 2026 15:08:26 -0400 Subject: [PATCH 30/57] Add defensive warnings for factor-to-numeric coercion and conflicting formulas (#39) Whole-branch review Minors: warn when a formula produces a factor but rType is numeric (cut()/factor() with the default double rType silently returned level codes), and when a variable's detail rows carry conflicting mockFormula values. Adds a Phase B ADR caveat on internal- generic S3 dispatch reaching globalenv methods. --- R/mock_spec_formula.R | 36 +++++++++++++++++-- R/mock_spec_recodeflow.R | 9 +++++ development/adr/v05-formula-evaluator.md | 8 +++++ tests/testthat/test-formula-vars.R | 44 ++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/R/mock_spec_formula.R b/R/mock_spec_formula.R index 13885ed..bd0d22e 100644 --- a/R/mock_spec_formula.R +++ b/R/mock_spec_formula.R @@ -225,9 +225,39 @@ evaluate_mock_formulas <- function(data, spec, seed = NULL) { #' @noRd .coerce_formula_rtype <- function(values, rtype, variable_name) { switch(rtype, - double = , - numeric = as.numeric(values), - integer = as.integer(values), + 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), diff --git a/R/mock_spec_recodeflow.R b/R/mock_spec_recodeflow.R index 140eef7..8e765a0 100644 --- a/R/mock_spec_recodeflow.R +++ b/R/mock_spec_recodeflow.R @@ -263,6 +263,15 @@ 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]) } diff --git a/development/adr/v05-formula-evaluator.md b/development/adr/v05-formula-evaluator.md index f53180f..21139b0 100644 --- a/development/adr/v05-formula-evaluator.md +++ b/development/adr/v05-formula-evaluator.md @@ -205,6 +205,14 @@ metadata with no `mockFormula` behaves identically to today. 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, diff --git a/tests/testthat/test-formula-vars.R b/tests/testthat/test-formula-vars.R index 7502e6a..bd87bf1 100644 --- a/tests/testthat/test-formula-vars.R +++ b/tests/testthat/test-formula-vars.R @@ -403,3 +403,47 @@ test_that("existing formula-free seeded output is unchanged by the formula stage baseline <- generate_mock_data_native(spec, n = 3, seed = 20260706) expect_identical(evaluate_mock_formulas(baseline, spec, seed = 20260706), baseline) }) + +test_that("warn when a formula produces a factor but rtype is numeric", { + # Whole-branch review Minor #1: factor coercion to numeric silently returns + # level codes. Warn to make this foreseeable footgun obvious. + spec <- mock_spec( + mock_spec_continuous("x", range = c(1, 10)), + mock_spec_formula("band", formula = "cut(x, c(0,3,7,10))", rtype = "double") + ) + baseline <- generate_mock_data_native(spec, n = 5, seed = 42) + + expect_warning( + result <- evaluate_mock_formulas(baseline, spec), + "formula produced a factor but rType is 'double'" + ) + # Verify coercion still happens (to level codes) + expect_true(is.numeric(result$band)) +}) + +test_that("warn when variable details have conflicting mockFormula values", { + # Whole-branch review Minor #2: conflicting mockFormula values silently use + # the first. Warn when distinct values are detected. + variables <- data.frame( + variable = c("x", "z"), + variableType = c("Continuous", "Continuous"), + rType = c("double", "double"), + role = c("enabled", "enabled"), + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = c("x", "z", "z"), + recStart = c("[1, 10]", "DerivedVar::[x]", "DerivedVar::[x]"), + recEnd = c("copy", "Func::foo", "Func::foo"), + proportion = c(1, 1, 1), + mockFormula = c("", "x * 2", "x * 3"), # conflicting: "x * 2" vs "x * 3" + stringsAsFactors = FALSE + ) + + expect_warning( + spec <- mock_spec_from_recodeflow(variables, variable_details), + "conflicting mockFormula values.*using the first: 'x \\* 2'" + ) + # Verify the first value was used + expect_identical(spec$variables$z$formula, "x * 2") +}) From 58bdfc778b096ad6000a6997e8d3c923528c6cd9 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 17 Sep 2026 20:13:13 -0400 Subject: [PATCH 31/57] Reconcile plan and ADR bookkeeping with the shipped #38/#39 work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four tasks of the #39 formula-evaluator plan shipped (commits 9dba1c0..779463d) but its step checkboxes were never ticked; tick them. Tick the post-v0.4.0 plan's Task 5 and Task 6 steps for the same reason. The seed-contract ADR status line still said its implementation plan was "not yet written" — it was written, executed, and merged via PR #48; the line now records that and points at follow-up #47. --- development/adr/v05-seed-contract.md | 2 +- .../plans/2026-07-16-formula-evaluator.md | 40 +++++++++---------- development/post-v040-development-plan.md | 14 +++---- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/development/adr/v05-seed-contract.md b/development/adr/v05-seed-contract.md index b103d02..b8e8bc3 100644 --- a/development/adr/v05-seed-contract.md +++ b/development/adr/v05-seed-contract.md @@ -1,6 +1,6 @@ # ADR: v0.5 Seed and RNG-Stream Contract -**Status**: ACCEPTED 2026-07-06 — full-bundle contract, co-released with #39 in v0.5. Implementation is a separate ADR-gated plan (not yet written). Any individual decision below remains open to maintainer revision before implementation begins. +**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) diff --git a/development/plans/2026-07-16-formula-evaluator.md b/development/plans/2026-07-16-formula-evaluator.md index 87ac907..e56dd30 100644 --- a/development/plans/2026-07-16-formula-evaluator.md +++ b/development/plans/2026-07-16-formula-evaluator.md @@ -64,7 +64,7 @@ Task 4: docs — NEWS, roxygen family, stale-vignette fixes, ADR status n - `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). -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** ```r # tests/testthat/test-formula-vars.R @@ -123,9 +123,9 @@ test_that("an unparseable formula errors at validation", { }) ``` -- [ ] **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 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"`. -- [ ] **Step 3: Create `R/mock_spec_formula.R` helpers** +- [x] **Step 3: Create `R/mock_spec_formula.R` helpers** ```r # ============================================================================== @@ -234,7 +234,7 @@ test_that("an unparseable formula errors at validation", { } ``` -- [ ] **Step 4: Constructors in `R/mock_spec.R`** +- [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()`: @@ -291,7 +291,7 @@ mock_spec_formula <- function(name, `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. -- [ ] **Step 5: Validator branches** +- [x] **Step 5: Validator branches** Per-variable (`R/mock_spec.R`, extend the type chain before the `:855` unsupported-type error): @@ -322,9 +322,9 @@ Per-variable (`R/mock_spec.R`, extend the type chain before the `:855` unsupport 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. -- [ ] **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 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. -- [ ] **Step 7: `devtools::document()`; full suite; commit** +- [x] **Step 7: `devtools::document()`; full suite; commit** Run full suite — expected: baseline counts + new passes, no new WARN/SKIP. @@ -350,7 +350,7 @@ recovered from the v0.4 spike." - 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. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** ```r test_that("evaluate_mock_formulas computes algebraic derivations correctly", { @@ -446,9 +446,9 @@ test_that("rtype coercion applies to formula outputs", { (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")`.) -- [ ] **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 2: Run — verify failures** (`could not find function "evaluate_mock_formulas"`; the native-skip expectation fails because `.generate_native_variable()` stops on type "formula"). -- [ ] **Step 3: Native skip** +- [x] **Step 3: Native skip** In `generate_mock_data_native()` (`R/mock_spec_native.R`, around `:383`), before the lapply, filter: @@ -461,7 +461,7 @@ In `generate_mock_data_native()` (`R/mock_spec_native.R`, around `:383`), before (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. -- [ ] **Step 4: The evaluator** (append to `R/mock_spec_formula.R`) +- [x] **Step 4: The evaluator** (append to `R/mock_spec_formula.R`) ```r #' @noRd @@ -571,7 +571,7 @@ evaluate_mock_formulas <- function(data, spec, seed = NULL) { 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. -- [ ] **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). +- [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 @@ -584,7 +584,7 @@ Note on `envir = as.list(data)`: eval with a list envir + `enclos` gives column **Interfaces:** - Consumes: Tasks 1–2. Produces: `create_mock_data()` end-to-end support; `mockFormula` metadata column. -- [ ] **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): +- [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", { @@ -632,12 +632,12 @@ test_that("existing formula-free seeded output is unchanged by the formula stage }) ``` -- [ ] **Step 2: Adapter changes** (`R/mock_spec_recodeflow.R`): +- [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). -- [ ] **Step 3: Orchestrator changes** (`R/create_mock_data.R`): +- [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 — @@ -649,16 +649,16 @@ test_that("existing formula-free seeded output is unchanged by the formula stage (Update the comment above it to name all three sub-streams.) -- [ ] **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 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. -- [ ] **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). +- [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) -- [ ] **Step 1: NEWS** (feature bullet under the development-version heading, NOT in the breaking-change section — this is additive): +- [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) @@ -673,13 +673,13 @@ test_that("existing formula-free seeded output is unchanged by the formula stage generation, and `Func::` dispatch is not yet supported. ``` -- [ ] **Step 2: Stale-claims sweep** (the #45/#48 reviews both caught rendered-false docs — do not repeat that): +- [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)." -- [ ] **Step 3: `devtools::document()`; full suite; `devtools::check()`; commit** ("Document formula-derived variables and refresh deferred-work claims (#39)"). +- [x] **Step 3: `devtools::document()`; full suite; `devtools::check()`; commit** ("Document formula-derived variables and refresh deferred-work claims (#39)"). --- diff --git a/development/post-v040-development-plan.md b/development/post-v040-development-plan.md index d4794da..7e28681 100644 --- a/development/post-v040-development-plan.md +++ b/development/post-v040-development-plan.md @@ -542,26 +542,26 @@ Tasks 5 and 6 **must land in the same minor release**: both change seeded output **Deliverable:** `development/adr/v05-seed-contract.md`, approved by the maintainer. -- [ ] **Step 1: Draft the ADR** with these sections and decision points: +- [x] **Step 1: Draft the ADR** with these sections and decision points: - **Context:** three regimes (legacy global `set.seed` at `R/create_mock_data.R:325-327`; v0.4 `seed`/`seed + 1L` at `:85-90`; simstudy backend's own handling); fallback stream-switch is verbose-gated (#33 item 11). - **Options:** (a) document the status quo; (b) L'Ecuyer-CMRG sub-streams via `parallel::nextRNGStream()`, one stream per stage; (c) hash-derived per-stage (or per-variable) integer seeds. **Recommendation: (b) at stage granularity** — principled, base-R only, and per-variable granularity is YAGNI until parallel generation is on the table. - **Guarantee scope:** same seed + same spec + same package version ⇒ identical output; stability across minor versions explicitly *not* guaranteed when NEWS declares a break. - **Migration:** single break, coordinated with Task 6; pinned-value regression tests (fulfils #22); decide whether the fallback stream-switch message becomes always-on (#33 item 11). -- [ ] **Step 2: Maintainer review — STOP until approved.** -- [ ] **Step 3: Write the implementation plan** (superpowers:writing-plans → `development/plans/`) and execute. +- [x] **Step 2: Maintainer review — STOP until approved.** +- [x] **Step 3: Write the implementation plan** (superpowers:writing-plans → `development/plans/`) and execute. ### Task 6: Formula-evaluator ADR + implementation (#39) **Deliverable:** `development/adr/v05-formula-evaluator.md`, approved; then phased implementation. -- [ ] **Step 1: Recover the spike:** `git show f4f9b41:development/v04-simstudy-spike/prototype.R > /tmp/spike-prototype.R` and read it. It contains `formula_dependencies()` (`all.vars(str2lang(...))`), topological ordering with cycle detection ("Formula dependency cycle or unresolved ordering among: ..."), and `validate_formula_referents()`. -- [ ] **Step 2: Draft the ADR** with these decision points: +- [x] **Step 1: Recover the spike:** `git show f4f9b41:development/v04-simstudy-spike/prototype.R > /tmp/spike-prototype.R` and read it. It contains `formula_dependencies()` (`all.vars(str2lang(...))`), topological ordering with cycle detection ("Formula dependency cycle or unresolved ordering among: ..."), and `validate_formula_referents()`. +- [x] **Step 2: Draft the ADR** with these decision points: - **Syntax entry:** (a) new MockData-extension column (e.g. `mockFormula`) vs (b) reuse `variableStart` `DerivedVar::`/`Func::`. **Recommendation: (a)** — cchsflow/chmsflow are CRAN downstreams; do not overload shared recodeflow semantics. `identify_derived_vars()` continues to detect-and-skip `DerivedVar::` entries that carry no `mockFormula`. - **Evaluation environment:** restricted env containing only generated columns + an enumerated whitelist of base math/logic functions (`+ - * / ^ %% < <= > >= == != & | ! ifelse pmin pmax log exp sqrt abs round`); no filesystem, network, or global lookup. List the exact whitelist in the ADR. - **Phasing:** Phase A — algebraic formulas over generated columns (spec fields `formula`/`depends_on` already exist, `R/mock_spec.R:94-145`). Phase B — `Func::` dispatch to the consuming package's namespace (define lookup order and failure semantics). - **Diagnostics:** derived columns get a `derived = TRUE` entry in `mockdata_diagnostics` with their dependency list. -- [ ] **Step 3: Maintainer review — STOP until approved.** -- [ ] **Step 4: Implementation plan + execution.** Required tests: referent validation errors, cycle detection message, deterministic ordering, seed reproducibility under the Task 5 contract. +- [x] **Step 3: Maintainer review — STOP until approved.** +- [x] **Step 4: Implementation plan + execution.** Required tests: referent validation errors, cycle detection message, deterministic ordering, seed reproducibility under the Task 5 contract. ### Task 7: Survival pairs in batch generation (#40) From 43db46bbeda1b176cea376dfbb8baacce993df48 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 17 Sep 2026 20:14:09 -0400 Subject: [PATCH 32/57] Add R CMD check workflow and capture simstudy in the lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI previously ran only the pkgdown build (on main) and Copilot review; the test suite had never run anywhere but a developer laptop. The new R-CMD-check workflow runs on pushes to main/dev and PRs into them, using setup-r-dependencies so Suggests (including simstudy) are installed and the optional backend's tests execute rather than skip. simstudy was declared in Suggests but absent from renv.lock: it had never been installed locally, so snapshot(type = "all") never saw it, and every renv::restore() reproduced an environment where the simstudy backend could not be tested (10 tests skipped). The lockfile now carries simstudy 0.9.2 and its 11 dependencies; no existing package versions change. With it installed the suite runs FAIL 0 | WARN 35 | SKIP 3 | PASS 789. Also ignore the per-developer .superpowers/ scratch directory in both .gitignore and .Rbuildignore — it surfaced as a hidden-directory NOTE in a local R CMD check. --- .Rbuildignore | 1 + .github/workflows/R-CMD-check.yaml | 53 ++++ .gitignore | 3 + renv.lock | 405 ++++++++++++++++++++++++++++- 4 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/R-CMD-check.yaml diff --git a/.Rbuildignore b/.Rbuildignore index abace2b..6305600 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -34,3 +34,4 @@ ^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 51629cc..36eedeb 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ Generate_mock_data_files/ vignettes/.quarto/ vignettes/*_files/ vignettes/*.html + +# Superpowers plugin scratch (per-developer) +.superpowers/ diff --git a/renv.lock b/renv.lock index e6a6d32..14e588b 100644 --- a/renv.lock +++ b/renv.lock @@ -9,6 +9,23 @@ ] }, "Packages": { + "BH": { + "Package": "BH", + "Version": "1.90.0-1", + "Source": "Repository", + "Type": "Package", + "Title": "Boost C++ Header Files", + "Date": "2025-12-13", + "Authors@R": "c(person(\"Dirk\", \"Eddelbuettel\", role = c(\"aut\", \"cre\"), email = \"edd@debian.org\", comment = c(ORCID = \"0000-0001-6419-907X\")), person(\"John W.\", \"Emerson\", role = \"aut\"), person(\"Michael J.\", \"Kane\", role = \"aut\", comment = c(ORCID = \"0000-0003-1899-6662\")))", + "Description": "Boost provides free peer-reviewed portable C++ source libraries. A large part of Boost is provided as C++ template code which is resolved entirely at compile-time without linking. This package aims to provide the most useful subset of Boost libraries for template use among CRAN packages. By placing these libraries in this package, we offer a more efficient distribution system for CRAN as replication of this code in the sources of other packages is avoided. As of release 1.84.0-0, the following Boost libraries are included: 'accumulators' 'algorithm' 'align' 'any' 'atomic' 'beast' 'bimap' 'bind' 'circular_buffer' 'compute' 'concept' 'config' 'container' 'date_time' 'detail' 'dynamic_bitset' 'exception' 'flyweight' 'foreach' 'functional' 'fusion' 'geometry' 'graph' 'heap' 'icl' 'integer' 'interprocess' 'intrusive' 'io' 'iostreams' 'iterator' 'lambda2' 'math' 'move' 'mp11' 'mpl' 'multiprecision' 'numeric' 'pending' 'phoenix' 'polygon' 'preprocessor' 'process' 'propery_tree' 'qvm' 'random' 'range' 'scope_exit' 'smart_ptr' 'sort' 'spirit' 'tuple' 'type_traits' 'typeof' 'unordered' 'url' 'utility' 'uuid'.", + "License": "BSL-1.0", + "URL": "https://github.com/eddelbuettel/bh, https://dirk.eddelbuettel.com/code/bh.html", + "BugReports": "https://github.com/eddelbuettel/bh/issues", + "NeedsCompilation": "no", + "Author": "Dirk Eddelbuettel [aut, cre] (ORCID: ), John W. Emerson [aut], Michael J. Kane [aut] (ORCID: )", + "Maintainer": "Dirk Eddelbuettel ", + "Repository": "CRAN" + }, "KernSmooth": { "Package": "KernSmooth", "Version": "2.23-24", @@ -170,6 +187,81 @@ "Maintainer": "Dirk Eddelbuettel ", "Repository": "CRAN" }, + "RcppArmadillo": { + "Package": "RcppArmadillo", + "Version": "15.2.4-1", + "Source": "Repository", + "Type": "Package", + "Title": "'Rcpp' Integration for the 'Armadillo' Templated Linear Algebra Library", + "Date": "2026-03-17", + "Authors@R": "c(person(\"Dirk\", \"Eddelbuettel\", role = c(\"aut\", \"cre\"), email = \"edd@debian.org\", comment = c(ORCID = \"0000-0001-6419-907X\")), person(\"Romain\", \"Francois\", role = \"aut\", comment = c(ORCID = \"0000-0002-2444-4226\")), person(\"Doug\", \"Bates\", role = \"aut\", comment = c(ORCID = \"0000-0001-8316-9503\")), person(\"Binxiang\", \"Ni\", role = \"aut\"), person(\"Conrad\", \"Sanderson\", role = \"aut\", comment = c(ORCID = \"0000-0002-0049-4501\")))", + "Description": "'Armadillo' is a templated C++ linear algebra library aiming towards a good balance between speed and ease of use. It provides high-level syntax and functionality deliberately similar to Matlab. It is useful for algorithm development directly in C++, or quick conversion of research code into production environments. It provides efficient classes for vectors, matrices and cubes where dense and sparse matrices are supported. Integer, floating point and complex numbers are supported. A sophisticated expression evaluator (based on template meta-programming) automatically combines several operations to increase speed and efficiency. Dynamic evaluation automatically chooses optimal code paths based on detected matrix structures. Matrix decompositions are provided through integration with LAPACK, or one of its high performance drop-in replacements (such as 'MKL' or 'OpenBLAS'). It can automatically use 'OpenMP' multi-threading (parallelisation) to speed up computationally expensive operations. . The 'RcppArmadillo' package includes the header files from the 'Armadillo' library; users do not need to install 'Armadillo' itself in order to use 'RcppArmadillo'. Starting from release 15.0.0, the minimum compilation standard is C++14 so 'Armadillo' version 14.6.3 is included as a fallback when an R package forces the C++11 standard. Package authors should set a '#define' to select the 'current' version, or select the 'legacy' version (also chosen as default) if they must. See 'GitHub issue #475' for details. . Since release 7.800.0, 'Armadillo' is licensed under Apache License 2; previous releases were under licensed as MPL 2.0 from version 3.800.0 onwards and LGPL-3 prior to that; 'RcppArmadillo' (the 'Rcpp' bindings/bridge to Armadillo) is licensed under the GNU GPL version 2 or later, as is the rest of 'Rcpp'.", + "License": "GPL (>= 2)", + "LazyLoad": "yes", + "Depends": [ + "R (>= 3.3.0)" + ], + "LinkingTo": [ + "Rcpp" + ], + "Imports": [ + "Rcpp (>= 1.0.12)", + "stats", + "utils", + "methods" + ], + "Suggests": [ + "tinytest", + "Matrix (>= 1.3.0)", + "pkgKitten", + "reticulate", + "slam" + ], + "URL": "https://github.com/RcppCore/RcppArmadillo, https://dirk.eddelbuettel.com/code/rcpp.armadillo.html", + "BugReports": "https://github.com/RcppCore/RcppArmadillo/issues", + "RoxygenNote": "6.0.1", + "NeedsCompilation": "yes", + "Author": "Dirk Eddelbuettel [aut, cre] (ORCID: ), Romain Francois [aut] (ORCID: ), Doug Bates [aut] (ORCID: ), Binxiang Ni [aut], Conrad Sanderson [aut] (ORCID: )", + "Maintainer": "Dirk Eddelbuettel ", + "Repository": "CRAN" + }, + "RcppEigen": { + "Package": "RcppEigen", + "Version": "0.3.4.0.2", + "Source": "Repository", + "Type": "Package", + "Title": "'Rcpp' Integration for the 'Eigen' Templated Linear Algebra Library", + "Date": "2024-08-23", + "Authors@R": "c(person(\"Doug\", \"Bates\", role = \"aut\", comment = c(ORCID = \"0000-0001-8316-9503\")), person(\"Dirk\", \"Eddelbuettel\", role = c(\"aut\", \"cre\"), email = \"edd@debian.org\", comment = c(ORCID = \"0000-0001-6419-907X\")), person(\"Romain\", \"Francois\", role = \"aut\", comment = c(ORCID = \"0000-0002-2444-4226\")), person(\"Yixuan\", \"Qiu\", role = \"aut\", comment = c(ORCID = \"0000-0003-0109-6692\")), person(\"Authors of\", \"Eigen\", role = \"cph\", comment = \"Authorship and copyright in included Eigen library as detailed in inst/COPYRIGHTS\"))", + "Copyright": "See the file COPYRIGHTS for various Eigen copyright details", + "Description": "R and 'Eigen' integration using 'Rcpp'. 'Eigen' is a C++ template library for linear algebra: matrices, vectors, numerical solvers and related algorithms. It supports dense and sparse matrices on integer, floating point and complex numbers, decompositions of such matrices, and solutions of linear systems. Its performance on many algorithms is comparable with some of the best implementations based on 'Lapack' and level-3 'BLAS'. The 'RcppEigen' package includes the header files from the 'Eigen' C++ template library. Thus users do not need to install 'Eigen' itself in order to use 'RcppEigen'. Since version 3.1.1, 'Eigen' is licensed under the Mozilla Public License (version 2); earlier version were licensed under the GNU LGPL version 3 or later. 'RcppEigen' (the 'Rcpp' bindings/bridge to 'Eigen') is licensed under the GNU GPL version 2 or later, as is the rest of 'Rcpp'.", + "License": "GPL (>= 2) | file LICENSE", + "LazyLoad": "yes", + "Depends": [ + "R (>= 3.6.0)" + ], + "LinkingTo": [ + "Rcpp" + ], + "Imports": [ + "Rcpp (>= 0.11.0)", + "stats", + "utils" + ], + "Suggests": [ + "Matrix", + "inline", + "tinytest", + "pkgKitten", + "microbenchmark" + ], + "URL": "https://github.com/RcppCore/RcppEigen, https://dirk.eddelbuettel.com/code/rcpp.eigen.html", + "BugReports": "https://github.com/RcppCore/RcppEigen/issues", + "NeedsCompilation": "yes", + "Author": "Doug Bates [aut] (), Dirk Eddelbuettel [aut, cre] (), Romain Francois [aut] (), Yixuan Qiu [aut] (), Authors of Eigen [cph] (Authorship and copyright in included Eigen library as detailed in inst/COPYRIGHTS)", + "Maintainer": "Dirk Eddelbuettel ", + "Repository": "CRAN" + }, "askpass": { "Package": "askpass", "Version": "1.2.1", @@ -195,6 +287,28 @@ "Maintainer": "Jeroen Ooms ", "Repository": "CRAN" }, + "backports": { + "Package": "backports", + "Version": "1.5.0", + "Source": "Repository", + "Type": "Package", + "Title": "Reimplementations of Functions Introduced Since R-3.0.0", + "Authors@R": "c( person(\"Michel\", \"Lang\", NULL, \"michellang@gmail.com\", role = c(\"cre\", \"aut\"), comment = c(ORCID = \"0000-0001-9754-0393\")), person(\"Duncan\", \"Murdoch\", NULL, \"murdoch.duncan@gmail.com\", role = c(\"aut\")), person(\"R Core Team\", role = \"aut\"))", + "Maintainer": "Michel Lang ", + "Description": "Functions introduced or changed since R v3.0.0 are re-implemented in this package. The backports are conditionally exported in order to let R resolve the function name to either the implemented backport, or the respective base version, if available. Package developers can make use of new functions or arguments by selectively importing specific backports to support older installations.", + "URL": "https://github.com/r-lib/backports", + "BugReports": "https://github.com/r-lib/backports/issues", + "License": "GPL-2 | GPL-3", + "NeedsCompilation": "yes", + "ByteCompile": "yes", + "Depends": [ + "R (>= 3.0.0)" + ], + "Encoding": "UTF-8", + "RoxygenNote": "7.3.1", + "Author": "Michel Lang [cre, aut] (), Duncan Murdoch [aut], R Core Team [aut]", + "Repository": "CRAN" + }, "base64enc": { "Package": "base64enc", "Version": "0.1-3", @@ -212,6 +326,71 @@ "License": "GPL-2 | GPL-3", "URL": "http://www.rforge.net/base64enc", "NeedsCompilation": "yes", + "Repository": "RSPM", + "Encoding": "UTF-8" + }, + "bigmemory": { + "Package": "bigmemory", + "Version": "4.6.4", + "Source": "Repository", + "Title": "Manage Massive Matrices with Shared Memory and Memory-Mapped Files", + "Authors@R": "c( person(given = \"Michael J.\", family = \"Kane\", role = c(\"aut\", \"cre\"), email = \"bigmemoryauthors@gmail.com\", comment = c(ORCID = \"0000-0003-1899-6662\")), person(given = \"John W.\", family = \"Emerson\", role = \"aut\", email = \"jayemerson@gmail.com\"), person(given = \"Peter\", family = \"Haverty\", role = \"aut\", email = \"haverty.peter@gene.com\"), person(given = \"Charles\", family = \"Determan\", role = \"aut\", email = \"cdetermanjr@gmail.com\") )", + "Depends": [ + "R (>= 3.2.0)" + ], + "Imports": [ + "bigmemory.sri", + "methods", + "utils", + "Rcpp", + "uuid (>= 1.0-2)" + ], + "Enhances": [ + "biganalytics", + "bigtabulate" + ], + "LinkingTo": [ + "BH", + "uuid (>= 1.0-2)", + "Rcpp" + ], + "Encoding": "UTF-8", + "Description": "Create, store, access, and manipulate massive matrices. Matrices are allocated to shared memory and may use memory-mapped files. Packages 'biganalytics', 'bigtabulate', 'synchronicity', and 'bigalgebra' provide advanced functionality.", + "License": "LGPL-3 | Apache License 2.0", + "URL": "https://github.com/kaneplusplus/bigmemory", + "BugReports": "https://github.com/kaneplusplus/bigmemory/issues", + "LazyLoad": "yes", + "Suggests": [ + "testthat", + "remotes" + ], + "RoxygenNote": "7.2.3", + "NeedsCompilation": "yes", + "Author": "Michael J. Kane [aut, cre] (), John W. Emerson [aut], Peter Haverty [aut], Charles Determan [aut]", + "Maintainer": "Michael J. Kane ", + "Repository": "CRAN" + }, + "bigmemory.sri": { + "Package": "bigmemory.sri", + "Version": "0.1.8", + "Source": "Repository", + "Type": "Package", + "Title": "A Shared Resource Interface for Bigmemory Project Packages", + "Authors@R": "c( person(given = \"Michael J.\", family = \"Kane\", role = c(\"aut\", \"cre\"), email = \"bigmemoryauthors@gmail.com\", comment = c(ORCID = \"0000-0003-1899-6662\")) )", + "Description": "A shared resource interface for the bigmemory and synchronicity packages.", + "Encoding": "UTF-8", + "Language": "en-US", + "License": "LGPL-3 | Apache License 2.0", + "Enhances": [ + "bigmemory" + ], + "Imports": [ + "methods" + ], + "RoxygenNote": "7.2.3", + "NeedsCompilation": "no", + "Author": "Michael J. Kane [aut, cre] ()", + "Maintainer": "Michael J. Kane ", "Repository": "CRAN" }, "bit": { @@ -807,6 +986,40 @@ "Maintainer": "Jeroen Ooms ", "Repository": "CRAN" }, + "data.table": { + "Package": "data.table", + "Version": "1.18.2.1", + "Source": "Repository", + "Title": "Extension of `data.frame`", + "Depends": [ + "R (>= 3.4.0)" + ], + "Imports": [ + "methods" + ], + "Suggests": [ + "bit64 (>= 4.0.0)", + "bit (>= 4.0.4)", + "R.utils (>= 2.13.0)", + "xts", + "zoo (>= 1.8-1)", + "yaml", + "knitr", + "markdown" + ], + "Description": "Fast aggregation of large data (e.g. 100GB in RAM), fast ordered joins, fast add/modify/delete of columns by group using no copies at all, list columns, friendly and fast character-separated-value read/write. Offers a natural and flexible syntax, for faster development.", + "License": "MPL-2.0 | file LICENSE", + "URL": "https://r-datatable.com, https://Rdatatable.gitlab.io/data.table, https://github.com/Rdatatable/data.table", + "BugReports": "https://github.com/Rdatatable/data.table/issues", + "VignetteBuilder": "knitr", + "Encoding": "UTF-8", + "ByteCompile": "TRUE", + "Authors@R": "c( person(\"Tyson\",\"Barrett\", role=c(\"aut\",\"cre\"), email=\"t.barrett88@gmail.com\", comment = c(ORCID=\"0000-0002-2137-1391\")), person(\"Matt\",\"Dowle\", role=\"aut\", email=\"mattjdowle@gmail.com\"), person(\"Arun\",\"Srinivasan\", role=\"aut\", email=\"asrini@pm.me\"), person(\"Jan\",\"Gorecki\", role=\"aut\", email=\"j.gorecki@wit.edu.pl\"), person(\"Michael\",\"Chirico\", role=\"aut\", email=\"michaelchirico4@gmail.com\", comment = c(ORCID=\"0000-0003-0787-087X\")), person(\"Toby\",\"Hocking\", role=\"aut\", email=\"toby.hocking@r-project.org\", comment = c(ORCID=\"0000-0002-3146-0865\")), person(\"Benjamin\",\"Schwendinger\",role=\"aut\", comment = c(ORCID=\"0000-0003-3315-8114\")), person(\"Ivan\", \"Krylov\", role=\"aut\", email=\"ikrylov@disroot.org\", comment = c(ORCID=\"0000-0002-0172-3812\")), person(\"Pasha\",\"Stetsenko\", role=\"ctb\"), person(\"Tom\",\"Short\", role=\"ctb\"), person(\"Steve\",\"Lianoglou\", role=\"ctb\"), person(\"Eduard\",\"Antonyan\", role=\"ctb\"), person(\"Markus\",\"Bonsch\", role=\"ctb\"), person(\"Hugh\",\"Parsonage\", role=\"ctb\"), person(\"Scott\",\"Ritchie\", role=\"ctb\"), person(\"Kun\",\"Ren\", role=\"ctb\"), person(\"Xianying\",\"Tan\", role=\"ctb\"), person(\"Rick\",\"Saporta\", role=\"ctb\"), person(\"Otto\",\"Seiskari\", role=\"ctb\"), person(\"Xianghui\",\"Dong\", role=\"ctb\"), person(\"Michel\",\"Lang\", role=\"ctb\"), person(\"Watal\",\"Iwasaki\", role=\"ctb\"), person(\"Seth\",\"Wenchel\", role=\"ctb\"), person(\"Karl\",\"Broman\", role=\"ctb\"), person(\"Tobias\",\"Schmidt\", role=\"ctb\"), person(\"David\",\"Arenburg\", role=\"ctb\"), person(\"Ethan\",\"Smith\", role=\"ctb\"), person(\"Francois\",\"Cocquemas\", role=\"ctb\"), person(\"Matthieu\",\"Gomez\", role=\"ctb\"), person(\"Philippe\",\"Chataignon\", role=\"ctb\"), person(\"Nello\",\"Blaser\", role=\"ctb\"), person(\"Dmitry\",\"Selivanov\", role=\"ctb\"), person(\"Andrey\",\"Riabushenko\", role=\"ctb\"), person(\"Cheng\",\"Lee\", role=\"ctb\"), person(\"Declan\",\"Groves\", role=\"ctb\"), person(\"Daniel\",\"Possenriede\", role=\"ctb\"), person(\"Felipe\",\"Parages\", role=\"ctb\"), person(\"Denes\",\"Toth\", role=\"ctb\"), person(\"Mus\",\"Yaramaz-David\", role=\"ctb\"), person(\"Ayappan\",\"Perumal\", role=\"ctb\"), person(\"James\",\"Sams\", role=\"ctb\"), person(\"Martin\",\"Morgan\", role=\"ctb\"), person(\"Michael\",\"Quinn\", role=\"ctb\"), person(given=\"@javrucebo\", role=\"ctb\", comment=\"GitHub user\"), person(\"Marc\",\"Halperin\", role=\"ctb\"), person(\"Roy\",\"Storey\", role=\"ctb\"), person(\"Manish\",\"Saraswat\", role=\"ctb\"), person(\"Morgan\",\"Jacob\", role=\"ctb\"), person(\"Michael\",\"Schubmehl\", role=\"ctb\"), person(\"Davis\",\"Vaughan\", role=\"ctb\"), person(\"Leonardo\",\"Silvestri\", role=\"ctb\"), person(\"Jim\",\"Hester\", role=\"ctb\"), person(\"Anthony\",\"Damico\", role=\"ctb\"), person(\"Sebastian\",\"Freundt\", role=\"ctb\"), person(\"David\",\"Simons\", role=\"ctb\"), person(\"Elliott\",\"Sales de Andrade\", role=\"ctb\"), person(\"Cole\",\"Miller\", role=\"ctb\"), person(\"Jens Peder\",\"Meldgaard\", role=\"ctb\"), person(\"Vaclav\",\"Tlapak\", role=\"ctb\"), person(\"Kevin\",\"Ushey\", role=\"ctb\"), person(\"Dirk\",\"Eddelbuettel\", role=\"ctb\"), person(\"Tony\",\"Fischetti\", role=\"ctb\"), person(\"Ofek\",\"Shilon\", role=\"ctb\"), person(\"Vadim\",\"Khotilovich\", role=\"ctb\"), person(\"Hadley\",\"Wickham\", role=\"ctb\"), person(\"Bennet\",\"Becker\", role=\"ctb\"), person(\"Kyle\",\"Haynes\", role=\"ctb\"), person(\"Boniface Christian\",\"Kamgang\", role=\"ctb\"), person(\"Olivier\",\"Delmarcell\", role=\"ctb\"), person(\"Josh\",\"O'Brien\", role=\"ctb\"), person(\"Dereck\",\"de Mezquita\", role=\"ctb\"), person(\"Michael\",\"Czekanski\", role=\"ctb\"), person(\"Dmitry\", \"Shemetov\", role=\"ctb\"), person(\"Nitish\", \"Jha\", role=\"ctb\"), person(\"Joshua\", \"Wu\", role=\"ctb\"), person(\"Iago\", \"Giné-Vázquez\", role=\"ctb\"), person(\"Anirban\", \"Chetia\", role=\"ctb\"), person(\"Doris\", \"Amoakohene\", role=\"ctb\"), person(\"Angel\", \"Feliz\", role=\"ctb\"), person(\"Michael\",\"Young\", role=\"ctb\"), person(\"Mark\", \"Seeto\", role=\"ctb\"), person(\"Philippe\", \"Grosjean\", role=\"ctb\"), person(\"Vincent\", \"Runge\", role=\"ctb\"), person(\"Christian\", \"Wia\", role=\"ctb\"), person(\"Elise\", \"Maigné\", role=\"ctb\"), person(\"Vincent\", \"Rocher\", role=\"ctb\"), person(\"Vijay\", \"Lulla\", role=\"ctb\"), person(\"Aljaž\", \"Sluga\", role=\"ctb\"), person(\"Bill\", \"Evans\", role=\"ctb\"), person(\"Reino\", \"Bruner\", role=\"ctb\"), person(given=\"@badasahog\", role=\"ctb\", comment=\"GitHub user\"), person(\"Vinit\", \"Thakur\", role=\"ctb\"), person(\"Mukul\", \"Kumar\", role=\"ctb\"), person(\"Ildikó\", \"Czeller\", role=\"ctb\"), person(\"Manmita\", \"Das\", role=\"ctb\") )", + "NeedsCompilation": "yes", + "Author": "Tyson Barrett [aut, cre] (ORCID: ), Matt Dowle [aut], Arun Srinivasan [aut], Jan Gorecki [aut], Michael Chirico [aut] (ORCID: ), Toby Hocking [aut] (ORCID: ), Benjamin Schwendinger [aut] (ORCID: ), Ivan Krylov [aut] (ORCID: ), Pasha Stetsenko [ctb], Tom Short [ctb], Steve Lianoglou [ctb], Eduard Antonyan [ctb], Markus Bonsch [ctb], Hugh Parsonage [ctb], Scott Ritchie [ctb], Kun Ren [ctb], Xianying Tan [ctb], Rick Saporta [ctb], Otto Seiskari [ctb], Xianghui Dong [ctb], Michel Lang [ctb], Watal Iwasaki [ctb], Seth Wenchel [ctb], Karl Broman [ctb], Tobias Schmidt [ctb], David Arenburg [ctb], Ethan Smith [ctb], Francois Cocquemas [ctb], Matthieu Gomez [ctb], Philippe Chataignon [ctb], Nello Blaser [ctb], Dmitry Selivanov [ctb], Andrey Riabushenko [ctb], Cheng Lee [ctb], Declan Groves [ctb], Daniel Possenriede [ctb], Felipe Parages [ctb], Denes Toth [ctb], Mus Yaramaz-David [ctb], Ayappan Perumal [ctb], James Sams [ctb], Martin Morgan [ctb], Michael Quinn [ctb], @javrucebo [ctb] (GitHub user), Marc Halperin [ctb], Roy Storey [ctb], Manish Saraswat [ctb], Morgan Jacob [ctb], Michael Schubmehl [ctb], Davis Vaughan [ctb], Leonardo Silvestri [ctb], Jim Hester [ctb], Anthony Damico [ctb], Sebastian Freundt [ctb], David Simons [ctb], Elliott Sales de Andrade [ctb], Cole Miller [ctb], Jens Peder Meldgaard [ctb], Vaclav Tlapak [ctb], Kevin Ushey [ctb], Dirk Eddelbuettel [ctb], Tony Fischetti [ctb], Ofek Shilon [ctb], Vadim Khotilovich [ctb], Hadley Wickham [ctb], Bennet Becker [ctb], Kyle Haynes [ctb], Boniface Christian Kamgang [ctb], Olivier Delmarcell [ctb], Josh O'Brien [ctb], Dereck de Mezquita [ctb], Michael Czekanski [ctb], Dmitry Shemetov [ctb], Nitish Jha [ctb], Joshua Wu [ctb], Iago Giné-Vázquez [ctb], Anirban Chetia [ctb], Doris Amoakohene [ctb], Angel Feliz [ctb], Michael Young [ctb], Mark Seeto [ctb], Philippe Grosjean [ctb], Vincent Runge [ctb], Christian Wia [ctb], Elise Maigné [ctb], Vincent Rocher [ctb], Vijay Lulla [ctb], Aljaž Sluga [ctb], Bill Evans [ctb], Reino Bruner [ctb], @badasahog [ctb] (GitHub user), Vinit Thakur [ctb], Mukul Kumar [ctb], Ildikó Czeller [ctb], Manmita Das [ctb]", + "Maintainer": "Tyson Barrett ", + "Repository": "CRAN" + }, "desc": { "Package": "desc", "Version": "1.4.3", @@ -1180,6 +1393,42 @@ "Maintainer": "Brodie Gaslam ", "Repository": "CRAN" }, + "fastglm": { + "Package": "fastglm", + "Version": "0.0.4", + "Source": "Repository", + "Type": "Package", + "Title": "Fast and Stable Fitting of Generalized Linear Models using 'RcppEigen'", + "Authors@R": "c( person(\"Jared\", \"Huling\", , \"jaredhuling@gmail.com\", c(\"aut\", \"cre\")), person(\"Douglas\", \"Bates\", , , c(\"cph\")), person(\"Dirk\", \"Eddelbuettel\", , , c(\"cph\")), person(\"Romain\", \"Francois\", , , c(\"cph\")), person(\"Yixuan\", \"Qiu\", , , c(\"cph\")) )", + "Maintainer": "Jared Huling ", + "Description": "Fits generalized linear models efficiently using 'RcppEigen'. The iteratively reweighted least squares implementation utilizes the step-halving approach of Marschner (2011) to help safeguard against convergence issues.", + "BugReports": "https://github.com/jaredhuling/fastglm/issues", + "License": "GPL (>= 2)", + "Encoding": "UTF-8", + "Imports": [ + "Rcpp (>= 0.12.13)", + "methods" + ], + "Depends": [ + "bigmemory" + ], + "LinkingTo": [ + "Rcpp", + "RcppEigen", + "BH", + "bigmemory" + ], + "RoxygenNote": "7.3.3", + "Suggests": [ + "knitr", + "rmarkdown", + "glm2" + ], + "VignetteBuilder": "knitr", + "NeedsCompilation": "yes", + "Author": "Jared Huling [aut, cre], Douglas Bates [cph], Dirk Eddelbuettel [cph], Romain Francois [cph], Yixuan Qiu [cph]", + "Repository": "CRAN" + }, "fastmap": { "Package": "fastmap", "Version": "1.2.0", @@ -2168,6 +2417,42 @@ "Maintainer": "Joe Cheng ", "Repository": "CRAN" }, + "mvnfast": { + "Package": "mvnfast", + "Version": "0.2.8", + "Source": "Repository", + "Type": "Package", + "Title": "Fast Multivariate Normal and Student's t Methods", + "Date": "2023-02-20", + "Authors@R": "c(person(\"Matteo\", \"Fasiolo\", email = \"matteo.fasiolo@gmail.com\", role = c(\"aut\", \"cre\")), person(\"Thijs\", \" van den Berg\", role = c(\"ctb\")))", + "Maintainer": "Matteo Fasiolo ", + "Description": "Provides computationally efficient tools related to the multivariate normal and Student's t distributions. The main functionalities are: simulating multivariate random vectors, evaluating multivariate normal or Student's t densities and Mahalanobis distances. These tools are very efficient thanks to the use of C++ code and of the OpenMP API.", + "License": "GPL (>= 2.0)", + "URL": "https://github.com/mfasiolo/mvnfast/", + "Imports": [ + "Rcpp" + ], + "Suggests": [ + "knitr", + "rmarkdown", + "testthat", + "mvtnorm", + "microbenchmark", + "MASS", + "plyr", + "RhpcBLASctl" + ], + "LinkingTo": [ + "Rcpp", + "RcppArmadillo", + "BH" + ], + "VignetteBuilder": "knitr", + "RoxygenNote": "7.2.2", + "NeedsCompilation": "yes", + "Author": "Matteo Fasiolo [aut, cre], Thijs van den Berg [ctb]", + "Repository": "CRAN" + }, "nlme": { "Package": "nlme", "Version": "3.1-166", @@ -2296,6 +2581,34 @@ "Maintainer": "Gábor Csárdi ", "Repository": "CRAN" }, + "pbv": { + "Package": "pbv", + "Version": "0.5-47", + "Source": "Repository", + "Type": "Package", + "Title": "Probabilities for Bivariate Normal Distribution", + "Date": "2023-11-30 15:01:12.000296", + "Author": "Alexander Robitzsch [aut,cre] ()", + "Maintainer": "Alexander Robitzsch ", + "Description": "Computes probabilities of the bivariate normal distribution in a vectorized R function (Drezner & Wesolowsky, 1990, ).", + "Depends": [ + "R (>= 3.1)" + ], + "Imports": [ + "Rcpp" + ], + "Enhances": [ + "pbivnorm" + ], + "LinkingTo": [ + "Rcpp", + "RcppArmadillo" + ], + "URL": "https://github.com/alexanderrobitzsch/pbv, https://sites.google.com/view/alexander-robitzsch/software", + "License": "GPL (>= 2)", + "NeedsCompilation": "yes", + "Repository": "CRAN" + }, "pillar": { "Package": "pillar", "Version": "1.11.1", @@ -3569,6 +3882,72 @@ "Maintainer": "Winston Chang ", "Repository": "CRAN" }, + "simstudy": { + "Package": "simstudy", + "Version": "0.9.2", + "Source": "Repository", + "Type": "Package", + "Title": "Simulation of Study Data", + "Date": "2026-02-06", + "Authors@R": "c(person(given = \"Keith\", family = \"Goldfeld\", role = c(\"aut\", \"cre\"), email = \"keith.goldfeld@nyulangone.org\", comment = c(ORCID = \"0000-0002-0292-8780\")), person(given = \"Jacob\", family = \"Wujciak-Jens\", role = \"aut\", email = \"jacob@wujciak.de\", comment = c(ORCID = \"0000-0002-7281-3989\")))", + "Description": "Simulates data sets in order to explore modeling techniques or better understand data generating processes. The user specifies a set of relationships between covariates, and generates data based on these specifications. The final data sets can represent data from randomized control trials, repeated measure (longitudinal) designs, and cluster randomized trials. Missingness can be generated using various mechanisms (MCAR, MAR, NMAR).", + "License": "GPL-3", + "URL": "https://github.com/kgoldfeld/simstudy, https://kgoldfeld.github.io/simstudy/, https://kgoldfeld.github.io/simstudy/dev/", + "BugReports": "https://github.com/kgoldfeld/simstudy/issues", + "Depends": [ + "R (>= 4.1.0)" + ], + "Imports": [ + "data.table", + "glue", + "methods", + "mvnfast", + "Rcpp", + "backports", + "fastglm", + "pbv (>= 0.5.47)" + ], + "Suggests": [ + "covr", + "dplyr", + "formatR", + "gee", + "ggplot2", + "grid", + "gridExtra", + "hedgehog", + "knitr", + "magrittr", + "Matrix", + "mgcv", + "ordinal", + "pracma", + "rmarkdown", + "scales", + "splines", + "survival", + "testthat", + "gtsummary", + "broom.helpers", + "survminer", + "katex", + "dirmult", + "rms", + "lmerTest" + ], + "LinkingTo": [ + "Rcpp", + "pbv (>= 0.5.47)", + "fastglm" + ], + "VignetteBuilder": "knitr", + "Encoding": "UTF-8", + "RoxygenNote": "7.3.3", + "NeedsCompilation": "yes", + "Author": "Keith Goldfeld [aut, cre] (ORCID: ), Jacob Wujciak-Jens [aut] (ORCID: )", + "Maintainer": "Keith Goldfeld ", + "Repository": "CRAN" + }, "sourcetools": { "Package": "sourcetools", "Version": "0.1.7-1", @@ -4209,6 +4588,24 @@ "Maintainer": "Kirill Müller ", "Repository": "CRAN" }, + "uuid": { + "Package": "uuid", + "Version": "1.2-2", + "Source": "Repository", + "Title": "Tools for Generating and Handling of UUIDs", + "Author": "Simon Urbanek [aut, cre, cph] (https://urbanek.org, ORCID: ), Theodore Ts'o [aut, cph] (libuuid)", + "Maintainer": "Simon Urbanek ", + "Authors@R": "c(person(\"Simon\", \"Urbanek\", role=c(\"aut\",\"cre\",\"cph\"), email=\"Simon.Urbanek@r-project.org\", comment=c(\"https://urbanek.org\", ORCID=\"0000-0003-2297-1732\")), person(\"Theodore\",\"Ts'o\", email=\"tytso@thunk.org\", role=c(\"aut\",\"cph\"), comment=\"libuuid\"))", + "Depends": [ + "R (>= 2.9.0)" + ], + "Description": "Tools for generating and handling of UUIDs (Universally Unique Identifiers).", + "License": "MIT + file LICENSE", + "URL": "https://www.rforge.net/uuid", + "BugReports": "https://github.com/s-u/uuid/issues", + "NeedsCompilation": "yes", + "Repository": "CRAN" + }, "vctrs": { "Package": "vctrs", "Version": "0.6.5", @@ -4562,9 +4959,10 @@ "R (>= 2.10.0)" ], "License": "GPL (>= 2)", - "Repository": "CRAN", + "Repository": "RSPM", "NeedsCompilation": "no", - "Author": "David B. Dahl [aut], David Scott [aut, cre], Charles Roosen [aut], Arni Magnusson [aut], Jonathan Swinton [aut], Ajay Shah [ctb], Arne Henningsen [ctb], Benno Puetz [ctb], Bernhard Pfaff [ctb], Claudio Agostinelli [ctb], Claudius Loehnert [ctb], David Mitchell [ctb], David Whiting [ctb], Fernando da Rosa [ctb], Guido Gay [ctb], Guido Schulz [ctb], Ian Fellows [ctb], Jeff Laake [ctb], John Walker [ctb], Jun Yan [ctb], Liviu Andronic [ctb], Markus Loecher [ctb], Martin Gubri [ctb], Matthieu Stigler [ctb], Robert Castelo [ctb], Seth Falcon [ctb], Stefan Edwards [ctb], Sven Garbade [ctb], Uwe Ligges [ctb]" + "Author": "David B. Dahl [aut], David Scott [aut, cre], Charles Roosen [aut], Arni Magnusson [aut], Jonathan Swinton [aut], Ajay Shah [ctb], Arne Henningsen [ctb], Benno Puetz [ctb], Bernhard Pfaff [ctb], Claudio Agostinelli [ctb], Claudius Loehnert [ctb], David Mitchell [ctb], David Whiting [ctb], Fernando da Rosa [ctb], Guido Gay [ctb], Guido Schulz [ctb], Ian Fellows [ctb], Jeff Laake [ctb], John Walker [ctb], Jun Yan [ctb], Liviu Andronic [ctb], Markus Loecher [ctb], Martin Gubri [ctb], Matthieu Stigler [ctb], Robert Castelo [ctb], Seth Falcon [ctb], Stefan Edwards [ctb], Sven Garbade [ctb], Uwe Ligges [ctb]", + "Encoding": "UTF-8" }, "yaml": { "Package": "yaml", @@ -4583,7 +4981,8 @@ "URL": "https://github.com/vubiostat/r-yaml/", "BugReports": "https://github.com/vubiostat/r-yaml/issues", "NeedsCompilation": "yes", - "Repository": "CRAN" + "Repository": "RSPM", + "Encoding": "UTF-8" }, "zip": { "Package": "zip", From f1ed15d8f7597623a250d20b9b0440012d150b25 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 17 Sep 2026 20:20:25 -0400 Subject: [PATCH 33/57] Prepare v0.5.0 release: version, NEWS structure, README status, cran-comments Bump Version to 0.5.0. Promote the NEWS development section to a 0.5.0 heading and restructure it the way 0.4.0 was: breaking changes first (the #38 seed-contract change, now with an explicit migrating paragraph telling users to regenerate pinned expected values once), then new features (#39 formula variables, n = 0, native exponential), then validation and error-message changes. No entry text was dropped. README: version badge and status line to 0.5.0; the intro records that 0.5 adds formula-derived variables and the seed contract; the deferred- ideas list no longer names formula variables (they landed) and names Func:: dispatch instead. cran-comments: refresh the local-check record (simstudy now installed so the Suggests note is gone; 2 notes remain) and mention the new CI check. --- DESCRIPTION | 2 +- NEWS.md | 44 ++++++++++++++++++++++++++------------------ README.md | 19 ++++++++++--------- cran-comments.md | 18 ++++++++++-------- 4 files changed, 47 insertions(+), 36 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 596ab22..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"), diff --git a/NEWS.md b/NEWS.md index ba45634..6e25d51 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,17 +1,6 @@ -# MockData (development version) +# MockData 0.5.0 -## 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. - -## Reproducibility (breaking change) +## Breaking changes - Seeded output changed once for the RNG-stream mechanism in this release. Within the `create_mock_data()` pipeline (`generate_mock_data_native()`, @@ -26,16 +15,30 @@ 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. Pin your own expected values against the version you use. - For seeded calls, generation no longer alters the caller's RNG state - (previously the legacy `validate = FALSE` path reset it). + 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. + +## 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 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()`. -- Calling `create_mock_data()` without `databaseStart` now fails upfront with - a message naming the argument, instead of a raw missing-argument error. - 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 @@ -44,6 +47,11 @@ 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, diff --git a/README.md b/README.md index f71d571..5fa5778 100644 --- a/README.md +++ b/README.md @@ -3,19 +3,20 @@ [![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`) 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 +24,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 diff --git a/cran-comments.md b/cran-comments.md index 6e74b6a..81de8d9 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -5,29 +5,31 @@ Checked with: - R 4.4.2 -- macOS 26.5, aarch64-apple-darwin20 -- `R CMD check --as-cran --no-manual` +- macOS 26.0, aarch64-apple-darwin20 +- `R CMD check --as-cran --no-manual` on the built tarball +- Suggests installed, including `simstudy` 0.9.2, so the optional backend's + tests ran rather than skipped Result: - 0 errors - 0 warnings -- 3 notes +- 2 notes Notes: - New submission. -- `simstudy` was not installed in the local check environment, so the local - check used `_R_CHECK_FORCE_SUGGESTS_=false`. `simstudy` is listed in - `Suggests` and is used only for the optional backend. - The local check reported `unable to verify current time`. +Continuous integration runs the same check on Ubuntu (`R-CMD-check` +workflow) for every push to `main`/`dev` and every pull request into them. + ## 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, and survival-style -variables for testing data pipelines and documentation examples. +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 From 99d1fd2016c5497fc859cd0ab70dab93fdf71ac3 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 17 Sep 2026 20:21:12 -0400 Subject: [PATCH 34/57] Add the formula-variable exports to the pkgdown reference index (#39) pkgdown::build_site() failed with three topics missing from the index: evaluate_mock_formulas, mock_formula and mock_spec_formula. Place the constructors beside their categorical/continuous/date siblings and the evaluator between the baseline generators and postprocess_mock_data, in pipeline order. The pkgdown workflow only runs on main, so this would have surfaced at release time otherwise. --- _pkgdown.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/_pkgdown.yml b/_pkgdown.yml index b3169e3..64be322 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -22,9 +22,11 @@ reference: - mock_continuous - mock_categorical - mock_date + - mock_formula - mock_spec_continuous - mock_spec_categorical - mock_spec_date + - mock_spec_formula - is_mock_spec - validate_mock_spec - mock_spec_model_hints @@ -37,6 +39,7 @@ reference: - mock_spec_from_recodeflow - generate_mock_data_native - generate_mock_data_simstudy + - evaluate_mock_formulas - postprocess_mock_data - title: Main generation functions From ffff4cc712be8d6779df5c5d8cee7fdc9af212f0 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 17 Sep 2026 20:42:18 -0400 Subject: [PATCH 35/57] Return the native zero-row schema from the simstudy backend for n = 0 (#50) simstudy::genData(0, def) builds its id table as data.table(x = 1:n), and 1:0 is c(1, 0) -- two rows -- so .evalWith() stops with "Both 'dtSim' and 'n' are set but are of different length: 2 != 0". This reproduces for both simstudy-eligible types (categorical and uniform continuous) on simstudy 0.9.2, regardless of R or data.table version. No draws happen at zero rows, so .generate_simstudy_baseline() now delegates n = 0 to the native generators, which already implement the typed zero-row contract from #35. A skip-guarded contract cell pins the simstudy output identical to the native output for a mixed spec, and the pre-existing test-simstudy-backend.R n = 0 test passes again. This was the one failure in the first R-CMD-check CI runs: the suite had never run with simstudy installed anywhere but a laptop with a temporary install, which is how #50 was first noticed. --- NEWS.md | 4 ++++ R/mock_spec_simstudy.R | 9 +++++++++ tests/testthat/test-edge-case-contract.R | 16 ++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/NEWS.md b/NEWS.md index 634ba62..188cec3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -33,6 +33,10 @@ 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. +- 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) - 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, diff --git a/R/mock_spec_simstudy.R b/R/mock_spec_simstudy.R index 57bac79..89f36b3 100644 --- a/R/mock_spec_simstudy.R +++ b/R/mock_spec_simstudy.R @@ -137,6 +137,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) diff --git a/tests/testthat/test-edge-case-contract.R b/tests/testthat/test-edge-case-contract.R index d65339e..555dca4 100644 --- a/tests/testthat/test-edge-case-contract.R +++ b/tests/testthat/test-edge-case-contract.R @@ -25,6 +25,22 @@ test_that("native backend returns typed zero-row output for n = 0", { expect_type(result$age, "double") }) +test_that("simstudy backend matches the native zero-row contract for n = 0", { + # Issue #50: simstudy::genData(0, def) builds its id table as + # data.table(x = 1:n), and 1:0 is c(1, 0) -- two rows -- so evaluation + # stops with a length mismatch. Both simstudy-eligible types are covered. + skip_if_not_installed("simstudy") + spec <- mock_spec( + mock_spec_categorical("smoking", levels = c("never", "former", "current")), + mock_spec_continuous("age", range = c(18, 80)) + ) + result <- generate_mock_data_simstudy(spec, n = 0, seed = 1) + expect_identical(result, generate_mock_data_native(spec, n = 0, seed = 1)) + expect_identical(nrow(result), 0L) + expect_named(result, c("smoking", "age")) + expect_type(result$age, "double") +}) + test_that("native backend returns n rows and zero columns for an empty spec", { expect_identical(dim(generate_mock_data_native(mock_spec(), n = 10)), c(10L, 0L)) expect_identical(dim(generate_mock_data_native(mock_spec(), n = 0)), c(0L, 0L)) From 682768b238883796b673b6ece9152ac5b14e1371 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Tue, 22 Sep 2026 22:08:56 -0400 Subject: [PATCH 36/57] Add proposed v0.5 survival-dates ADR (#40) Design for generating survival dates inside create_mock_data() from metadata, agreed with the maintainer: full parity with create_wide_survival_data() (entry, event, death, LTFU, admin censoring and both consistency rules), exact port of the legacy statistics, and a post-baseline anchored stage modelled on the #39 formula stage rather than a multi-column generator. Metadata gains `anchor` and `censored_by` on variables.csv; the seed contract gains an appended `survival` sub-stream. Two decisions await ratification: applying garbage and missing codes after the consistency rules (D5), and adding is.na to the formula allow-list so status and follow-up time can be derived with mockFormula (D8). Records six concerns found while preparing the design, to be filed as issues. Most consequential: with the packaged Gompertz parameters, 388 of 400 legacy death dates land exactly at followup_min, so the minimal example's competing-risk rule never fires. Context claims were verified against the running code on 2026-09-22. --- development/adr/v05-survival-dates.md | 179 ++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 development/adr/v05-survival-dates.md diff --git a/development/adr/v05-survival-dates.md b/development/adr/v05-survival-dates.md new file mode 100644 index 0000000..0921a5a --- /dev/null +++ b/development/adr/v05-survival-dates.md @@ -0,0 +1,179 @@ +# ADR: v0.5 Metadata-Driven Survival Dates + +**Status**: PROPOSED 2026-09-22. Design agreed with the maintainer in conversation (scope, fidelity policy, architecture, metadata convention, semantics). Two points need explicit ratification before implementation: D5 (contamination after the consistency rules) and D8 (adding `is.na` to the formula allow-list). +**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 (**ratify**) | +| 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` (**ratify**; amends the #39 ADR's D3) | +| D9 | Legacy path | `create_wide_survival_data()` warns once as deprecated and is kept as the characterization reference | + +## 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 + follow-up days for events; `NA` otherwise. +4. After all survival dates are drawn: any date earlier than its anchor becomes `NA`; then, for each variable with `censored_by`, rows where the censoring date is earlier become `NA`. + +Survival dates are drawn in spec `position` order. No ordering among them is needed, because every anchor is a baseline date. + +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 (ratify) + +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, but it alters legacy output and so needs explicit ratification under D4. + +### 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")`. + +### 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. + +### D8: Add `is.na` to the formula allow-list (ratify) + +The #39 allow-list has no missingness test, so a formula cannot derive an event indicator from a survival date. With `is.na` added, users can write, for example, `as.integer(!is.na(death_date))` for status and `as.numeric(pmin(death_date, admin_censor_date, na.rm = TRUE) - interview_date)` for follow-up time in days. 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. + +### 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. + +## 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` +- `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` + +`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_survival()` returns a one-variable `mock_spec`; `mock_spec_survival()` returns a composable variable. Arguments: `name`, `anchor`, `followup_min`, `followup_max`, `event_prop`, `distribution = "uniform"`, `censored_by = NULL`, `shape = NULL`, `rate = NULL`, plus the usual missing-code, garbage, provenance and model-hint arguments. + +**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 to file as issues (not fixed in #40) + +Found while preparing this design. Each is reproduced exactly by the port. + +1. **Gompertz output is degenerate with the packaged parameters.** 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.** `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.** 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.** `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** in `create_date_var()`: the last rows are the missing ones, so row order carries meaning. +6. **Inert range rows.** 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. + +## 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()`, `mock_survival()`, `mock_spec_survival()`. Additions to `_pkgdown.yml` in the same PR. +- The formula allow-list grows by one function (D8). +- Specs without survival variables: output unchanged. + +## 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. + +## 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).** 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. +- **Structural (causal) generation.** Out of scope. D1 and D7 keep one dependency field and one ordering mechanism for all derived variables, which is the base a DAG-driven stage would need. From c9b5ebabc2150c41a69fa4b5d371ff61d7476a43 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 24 Sep 2026 06:08:20 -0400 Subject: [PATCH 37/57] Accept the v0.5 survival-dates ADR with D5 and D8 ratified (#40) Maintainer ratified D5 (contamination after the consistency rules) and D8 (is.na in the formula allow-list) on 2026-09-24. Two adjustments keep future causal extensions open at no cost: survival dates are drawn in dependency order with position tie-breaking (identical to position order for every v0.5 spec), so chained dates later need only a validator change; and the ADR now assesses exposure-dependent hazards against the design, recording the one contained structural change they would need (merging the survival and formula stages into one dependency-ordered pass when a hazard depends on a derived exposure). Also records a verified semantic of the #39 stage order that D8 makes visible: derived columns describe true values, while postprocess contaminates each column independently, so a derived status can sit beside a date shown as missing. Documented as deliberate and pinned by tests. --- development/adr/v05-survival-dates.md | 36 +++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/development/adr/v05-survival-dates.md b/development/adr/v05-survival-dates.md index 0921a5a..4f3571a 100644 --- a/development/adr/v05-survival-dates.md +++ b/development/adr/v05-survival-dates.md @@ -1,6 +1,6 @@ # ADR: v0.5 Metadata-Driven Survival Dates -**Status**: PROPOSED 2026-09-22. Design agreed with the maintainer in conversation (scope, fidelity policy, architecture, metadata convention, semantics). Two points need explicit ratification before implementation: D5 (contamination after the consistency rules) and D8 (adding `is.na` to the formula allow-list). +**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. **Date**: 2026-09-22 **Decision owner**: MockData maintainers **Issue**: #40 (also advances #23, #17) @@ -14,10 +14,10 @@ | 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 (**ratify**) | +| 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` (**ratify**; amends the #39 ADR's D3) | +| 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 | ## Context @@ -89,7 +89,7 @@ The legacy statistics and rules are ported verbatim: 3. Date = anchor + follow-up days for events; `NA` otherwise. 4. After all survival dates are drawn: any date earlier than its anchor becomes `NA`; then, for each variable with `censored_by`, rows where the censoring date is earlier become `NA`. -Survival dates are drawn in spec `position` order. No ordering among them is needed, because every anchor is a baseline date. +Survival dates are drawn in dependency order (D7), ties broken by spec `position`. In v0.5 every anchor is a baseline date, so this equals position order and changes no output. It keeps chained dates (a survival date anchored on another, such as an event whose hazard changes at an exposure-change date) a validator-only change later. 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: @@ -98,11 +98,13 @@ Output cannot be bit-identical to `create_wide_survival_data()`, because the RNG | 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 (ratify) +### 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, but it alters legacy output and so needs explicit ratification under D4. +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. ### D6: Stage and seed @@ -112,9 +114,11 @@ It changes one behaviour. In the legacy engine, garbage that places a date befor 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. -### D8: Add `is.na` to the formula allow-list (ratify) +### 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, users can write, for example, `as.integer(!is.na(death_date))` for status and `as.numeric(pmin(death_date, admin_censor_date, na.rm = TRUE) - interview_date)` for follow-up time in days. 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. -The #39 allow-list has no missingness test, so a formula cannot derive an event indicator from a survival date. With `is.na` added, users can write, for example, `as.integer(!is.na(death_date))` for status and `as.numeric(pmin(death_date, admin_censor_date, na.rm = TRUE) - interview_date)` for follow-up time in days. 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. +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 @@ -157,6 +161,7 @@ Found while preparing this design. Each is reproduced exactly by the port. - New exports: `generate_survival_dates()`, `mock_survival()`, `mock_spec_survival()`. Additions to `_pkgdown.yml` in the same PR. - 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 @@ -164,7 +169,8 @@ Found while preparing this design. Each is reproduced exactly by the port. 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. +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 @@ -176,4 +182,14 @@ Found while preparing this design. Each is reproduced exactly by the port. ## Future directions - **Sidecar overlay (v0.6, separate ADR).** 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. -- **Structural (causal) generation.** Out of scope. D1 and D7 keep one dependency field and one ordering mechanism for all derived variables, which is the base a DAG-driven stage would need. +- **Observed-value derivation.** An option to compute formulas from contaminated values, so derived status and time agree with what the output shows (D8). An ordering option on the formula stage; no architectural change. +- **Structural (causal) generation.** Out of scope. The maintainer's motivating case is exposure-dependent survival, such as smoking changing the hazard. Assessed against this design on 2026-09-24: + + | Extension | What it needs | Change to the base? | + |---|---|---| + | Hazard varies with a baseline exposure | Survival parameters (for example a hazard ratio) given as sandboxed expressions over generated columns; `depends_on` records the exposure. The ported event assignment is count-based (`floor(n * event_prop)`), so per-person risk needs a per-person draw as a second path | No; additive | + | Hazard varies with a derived exposure (a formula variable) | Formula, then survival, then formula. The fixed stage order in D6 (survival before formulas) cannot express this; survival and formula variables would share one dependency-ordered pass | Yes, contained. D7 prepares it, and because the v0.5 validator forbids survival dates that depend on formulas, merging the passes shifts no existing seeded output | + | Exposure changes during follow-up | The change date is itself an anchored date; the event hazard is piecewise around it. Needs chained survival dates | No; D4 draws in dependency order, so only the validator rule changes | + | Several rows per person (counting-process data) | A second output shape | Yes; MockData produces one row per person. Wide data with change dates covers most analyses | + + D1 and D7 keep one dependency field and one ordering mechanism for all derived variables, which is the base a DAG-driven stage would need. From 097841cac4eefc053b8df5cfd8b851a3bee4b45e Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 24 Sep 2026 06:27:34 -0400 Subject: [PATCH 38/57] Correct the survival-dates ADR from plan preparation (#40) Writing the implementation plan against the code exposed four points where the accepted ADR was wrong or silent: - Draw order: censored_by is part of depends_on, so dependency order draws a censoring date before the date it censors; the claim that this equals spec order was false. Each survival variable is now drawn and then has its own rules applied, in dependency order, which reproduces the legacy rule results and keeps value = f(parents) per variable. - Whole days: legacy dates are whole days because create_date_var() round- trips through character; the port floors follow-up days explicitly. - Direct API: mock_survival() is dropped, because a one-variable spec can never validate (its anchor must be in the same spec). - D10 (new): when the legacy generator runs on metadata carrying anchor, create_mock_data() stops and names the survival variables instead of dropping them. Survival dates also accept sourceFormat "analysis" only. Concerns now cite their issues (#54-#57, #15), plus one found while reading for the plan: exponential survival dates ignore `rate` (#56). --- development/adr/v05-survival-dates.md | 36 ++++++++++++++++----------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/development/adr/v05-survival-dates.md b/development/adr/v05-survival-dates.md index 4f3571a..9f7c0e7 100644 --- a/development/adr/v05-survival-dates.md +++ b/development/adr/v05-survival-dates.md @@ -19,6 +19,7 @@ | 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 @@ -86,10 +87,10 @@ 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 + follow-up days for events; `NA` otherwise. -4. After all survival dates are drawn: any date earlier than its anchor becomes `NA`; then, for each variable with `censored_by`, rows where the censoring date is earlier become `NA`. +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 dates are drawn in dependency order (D7), ties broken by spec `position`. In v0.5 every anchor is a baseline date, so this equals position order and changes no output. It keeps chained dates (a survival date anchored on another, such as an event whose hazard changes at an exposure-change date) a validator-only change later. +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, so a date named in another's `censored_by` is final before it censors anything. 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. Mutual `censored_by` references are reported as a dependency cycle. Processing in dependency order also keeps chained dates (a survival date anchored on another, such as an event whose hazard changes at an exposure-change date) a validator-only change later. 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: @@ -124,6 +125,10 @@ Derived columns describe the true generated values. The #39 stage order computes `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`): @@ -134,31 +139,34 @@ Derived columns describe the true generated values. The #39 stage order computes - `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_survival()` returns a one-variable `mock_spec`; `mock_spec_survival()` returns a composable variable. Arguments: `name`, `anchor`, `followup_min`, `followup_max`, `event_prop`, `distribution = "uniform"`, `censored_by = NULL`, `shape = NULL`, `rate = NULL`, plus the usual missing-code, garbage, provenance and model-hint arguments. +**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 to file as issues (not fixed in #40) +## Concerns filed as issues (not fixed in #40) -Found while preparing this design. Each is reproduced exactly by the port. +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.** 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.** `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.** 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.** `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** in `create_date_var()`: the last rows are the missing ones, so row order carries meaning. -6. **Inert range rows.** 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. +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()`, `mock_survival()`, `mock_spec_survival()`. Additions to `_pkgdown.yml` in the same PR. +- 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. @@ -181,7 +189,7 @@ Found while preparing this design. Each is reproduced exactly by the port. ## Future directions -- **Sidecar overlay (v0.6, separate ADR).** 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. +- **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. - **Observed-value derivation.** An option to compute formulas from contaminated values, so derived status and time agree with what the output shows (D8). An ordering option on the formula stage; no architectural change. - **Structural (causal) generation.** Out of scope. The maintainer's motivating case is exposure-dependent survival, such as smoking changing the hazard. Assessed against this design on 2026-09-24: From 01e5607f86036302f0f9ceec62febe3f5f094e27 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 24 Sep 2026 09:13:13 -0400 Subject: [PATCH 39/57] Expand range-notation missing codes on the v0.4 path (#58) The recodeflow adapter took recStart literally, so a missing-code row such as "[997,999]" with NA::b (the usual CCHS and CHMS pattern for don't know, refusal and not stated) became the code "[997,999]". A continuous variable then failed in post-processing ("values that cannot be parsed"), and a categorical variable silently wrote the literal string into the data: 100 of 500 rows of the CHMS sample's alc_11. All 15 variables the adapter builds from the CHMS sample carried such a code. A shared .expand_range_missing_codes() now expands an integer range into its individual codes, splitting the row's proportion equally (the legacy generators draw uniformly within the range), in both adapter paths: the continuous/date .recodeflow_missing() and the categorical extract_proportions() branch. A bracketed code that is not an integer range fails with a message naming the variable, instead of passing through. Also correct two errors in the minimal example's metadata, found the same way: BMI's low garbage range had a stray parenthesis, and height's low-garbage proportion was 1.00 instead of 0.01. The example's Gompertz survival dates force the legacy generator, which tolerated all three problems and hid them; with this change every other variable in the example generates through the v0.4 pipeline, which #40 depends on. --- NEWS.md | 13 +++ R/mock_spec_recodeflow.R | 62 +++++++++-- inst/extdata/minimal-example/variables.csv | 4 +- tests/testthat/test-range-missing-codes.R | 121 +++++++++++++++++++++ tests/testthat/test-recodeflow-mock-spec.R | 2 +- 5 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 tests/testthat/test-range-missing-codes.R diff --git a/NEWS.md b/NEWS.md index 06fadca..f80ef11 100644 --- a/NEWS.md +++ b/NEWS.md @@ -48,6 +48,19 @@ 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. With these + and #58, every variable in the example except its survival dates now + generates through the v0.4 pipeline. Seeded output for the example + changes. - 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, diff --git a/R/mock_spec_recodeflow.R b/R/mock_spec_recodeflow.R index 8e765a0..0ba13ae 100644 --- a/R/mock_spec_recodeflow.R +++ b/R/mock_spec_recodeflow.R @@ -221,7 +221,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,9 +277,10 @@ 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 ) } @@ -338,7 +377,7 @@ 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 @@ -366,14 +405,19 @@ 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" @@ -431,7 +475,9 @@ #' 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 +#' 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 stores survival/date fields #' such as `rate`, `shape`, `followup_min`, `followup_max`, and `event_prop` on #' date variables for later backend milestones. diff --git a/inst/extdata/minimal-example/variables.csv b/inst/extdata/minimal-example/variables.csv index 9e5ce85..c8f2dbf 100644 --- a/inst/extdata/minimal-example/variables.csv +++ b/inst/extdata/minimal-example/variables.csv @@ -1,8 +1,8 @@ "uid","variable","label","variableType","rType","role","position","seed","garbage_low_prop","garbage_low_range","garbage_high_prop","garbage_high_range","distribution","mean","sd","rate","shape","followup_min","followup_max","event_prop","sourceFormat","mockDataVersion","mockDataLastUpdated","mockDataVersionNotes" "cchsflow_v0001","age","Age in years","Continuous","integer","enabled,predictor,table1",10,10,,"[;]",,"[;]","normal",50,15,,,,,,"","1.0.0","2025-11-09","Normal distribution (mean=50, sd=15)" "cchsflow_v0002","smoking","Smoking status","Categorical","factor","enabled,predictor,table1",20,20,,"",,"","",,,,,,,,"","1.0.0","2025-11-09","Categorical variable with proportions" -"cchsflow_v0003","BMI","Body mass index","Continuous","double","enabled,outcome,table1",30,30,0.02,"[-10;15])",0.01,"[60;150]","normal",27.5,5.2,,,,,,"","1.0.0","2025-11-09","Normal distribution with two-sided contamination" -"cchsflow_v0004","height","Height in meters","Continuous","double","enabled,predictor",40,40,1,"[0;1.4)",0.01,"(2.1;inf]","normal",1.7,0.1,,,,,,"","1.0.0","2025-11-13","Height for BMI calculation" +"cchsflow_v0003","BMI","Body mass index","Continuous","double","enabled,outcome,table1",30,30,0.02,"[-10;15]",0.01,"[60;150]","normal",27.5,5.2,,,,,,"","1.0.0","2025-11-09","Normal distribution with two-sided contamination" +"cchsflow_v0004","height","Height in meters","Continuous","double","enabled,predictor",40,40,0.01,"[0;1.4)",0.01,"(2.1;inf]","normal",1.7,0.1,,,,,,"","1.0.0","2025-11-13","Height for BMI calculation" "cchsflow_v0005","weight","Weight in kilograms","Continuous","double","enabled,predictor",50,50,,"[;]",,"[;]","normal",75,15,,,,,,"","1.0.0","2025-11-13","Weight for BMI calculation" "cchsflow_v0006","BMI_derived","BMI calculated from height and weight","Continuous","double","enabled,outcome,table1",60,60,,"[;]",,"[;]","",,,,,,,,"","1.0.0","2025-11-13","Derived variable: BMI = weight / (height^2)" "ices_v01","interview_date","Interview date (cohort entry)","Continuous","date","enabled,outcome",70,70,0,"[;]",0,"[;]","uniform",,,,,,,,"analysis","1.0.0","2025-11-09","Uniform date range (cohort entry)" diff --git a/tests/testthat/test-range-missing-codes.R b/tests/testthat/test-range-missing-codes.R new file mode 100644 index 0000000..5bdb7f3 --- /dev/null +++ b/tests/testthat/test-range-missing-codes.R @@ -0,0 +1,121 @@ +# tests/testthat/test-range-missing-codes.R +# Range-notation missing codes such as "[997,999]" on the v0.4 path (#58). +# recodeflow writes grouped missing codes (don't know / refusal / not stated) +# as integer ranges; the adapter must expand them into individual codes. + +range_continuous_metadata <- function(missing_start = "[997,999]") { + list( + variables = data.frame( + variable = "age", + variableType = "Continuous", + rType = "integer", + role = "enabled", + stringsAsFactors = FALSE + ), + variable_details = data.frame( + variable = "age", + recStart = c("[18,85]", "996", missing_start), + recEnd = c("copy", "NA::a", "NA::b"), + proportion = c(NA, 0.05, 0.09), + stringsAsFactors = FALSE + ) + ) +} + +range_categorical_metadata <- function() { + list( + variables = data.frame( + variable = "smoke", + variableType = "Categorical", + rType = "character", + role = "enabled", + stringsAsFactors = FALSE + ), + variable_details = data.frame( + variable = "smoke", + recStart = c("1", "2", "6", "[7, 9]"), + recEnd = c("1", "2", "NA::a", "NA::b"), + proportion = c(0.5, 0.3, 0.05, 0.15), + stringsAsFactors = FALSE + ) + ) +} + +test_that("the adapter expands an integer range missing code on a continuous variable", { + md <- range_continuous_metadata() + spec <- mock_spec_from_recodeflow(md$variables, md$variable_details) + age <- spec$variables$age + expect_identical(age$missing_codes, c("996", "997", "998", "999")) + expect_equal(age$missing_proportions, c(0.05, 0.03, 0.03, 0.03)) +}) + +test_that("the adapter expands an integer range missing code on a categorical variable", { + md <- range_categorical_metadata() + spec <- suppressWarnings(mock_spec_from_recodeflow(md$variables, md$variable_details)) + smoke <- spec$variables$smoke + expect_identical(smoke$missing_codes, c("6", "7", "8", "9")) + expect_equal(sum(smoke$missing_proportions[2:4]), sum(smoke$missing_proportions) - smoke$missing_proportions[1]) + expect_equal(smoke$missing_proportions[2], smoke$missing_proportions[3]) + expect_false(any(startsWith(smoke$missing_codes, "["))) +}) + +test_that("create_mock_data generates range missing codes on the v0.4 path (#58 reproduction)", { + md <- range_continuous_metadata() + expect_message( + result <- create_mock_data("study", md$variables, md$variable_details, + n = 3000, seed = 1, verbose = TRUE), + "Generating via v0.4 mock_spec pipeline" + ) + expect_true(all(c(997L, 998L, 999L) %in% result$age)) + valid <- result$age[result$age < 996] + expect_true(all(valid >= 18 & valid <= 85)) +}) + +test_that("categorical output never contains a literal bracketed code", { + md <- range_categorical_metadata() + result <- suppressWarnings(create_mock_data( + "study", md$variables, md$variable_details, n = 2000, seed = 1 + )) + expect_false(any(startsWith(as.character(result$smoke), "["))) + expect_true(all(c("7", "8", "9") %in% result$smoke)) +}) + +test_that("a non-integer range missing code fails with a message naming the fix", { + md <- range_continuous_metadata(missing_start = "[99.6,99.9]") + expect_error( + mock_spec_from_recodeflow(md$variables, md$variable_details), + "Variable 'age' has a range missing code '\\[99.6,99.9\\]' that is not an integer range" + ) +}) + +test_that("an unparseable bracketed missing code fails rather than passing through literally", { + md <- range_continuous_metadata(missing_start = "[996]") + expect_error( + mock_spec_from_recodeflow(md$variables, md$variable_details), + "has a range missing code '\\[996\\]' that is not an integer range" + ) +}) + +test_that("the minimal example's non-survival variables generate via the v0.4 pipeline", { + # The minimal example's Gompertz survival dates force the legacy generator, + # which hid that the rest of the example failed on the v0.4 path: BMI's + # "[997,999]" missing code (#58) and two fixture errors (BMI's garbage range + # typo, height's low-garbage proportion of 1.00). #40 depends on this. + 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) + survival <- c("primary_event_date", "death_date", "ltfu_date", "admin_censor_date") + variables <- variables[!variables$variable %in% survival, ] + + expect_message( + result <- suppressWarnings(create_mock_data( + "minimal-example", variables, variable_details, + n = 500, seed = 1, verbose = TRUE + )), + "Generating via v0.4 mock_spec pipeline" + ) + expect_true(all(c("age", "smoking", "BMI", "height", "weight") %in% names(result))) + expect_true(any(result$BMI %in% c(997, 998, 999))) +}) diff --git a/tests/testthat/test-recodeflow-mock-spec.R b/tests/testthat/test-recodeflow-mock-spec.R index 0b0e797..853aeba 100644 --- a/tests/testthat/test-recodeflow-mock-spec.R +++ b/tests/testthat/test-recodeflow-mock-spec.R @@ -84,7 +84,7 @@ test_that("mock_spec_from_recodeflow preserves garbage and survival fields", { spec <- mock_spec_from_recodeflow(variables, variable_details) expect_equal(spec$variables$BMI$garbage_rules$low$proportion, 0.02) - expect_equal(spec$variables$BMI$garbage_rules$low$range, "[-10;15])") + expect_equal(spec$variables$BMI$garbage_rules$low$range, "[-10;15]") expect_equal(spec$variables$BMI$garbage_rules$high$proportion, 0.01) expect_equal(spec$variables$BMI$garbage_rules$high$range, "[60;150]") From 0da65f6c92cadbc7e1418402b76fd55fe5766fb0 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 24 Sep 2026 09:23:22 -0400 Subject: [PATCH 40/57] Add the #40 survival-dates implementation plan Seven tasks: characterize the legacy engine; the survival spec layer (constructor, validators, shared dependency ordering); the post-baseline survival stage with backend skips, diagnostics and an appended seed sub-stream; recodeflow and orchestrator wiring with the legacy guard; is.na in the formula allow-list; deprecation of create_wide_survival_data(); and documentation with full verification. The characterization tests and the stage's draw, rules and ordering were prototyped against the code before writing (5/5 and 11/11 passing), and the survival stage's pinned values were computed independently by emulating the seed primitive for sub-stream 4. Based on v05-survival-dates at 2548b51, which includes the #58 prerequisite (PR #59). --- .../plans/2026-09-24-survival-dates.md | 2593 +++++++++++++++++ 1 file changed, 2593 insertions(+) create mode 100644 development/plans/2026-09-24-survival-dates.md 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..9146e7e --- /dev/null +++ b/development/plans/2026-09-24-survival-dates.md @@ -0,0 +1,2593 @@ +# 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). 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. + +- [ ] **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. + +- [ ] **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)) +}) +``` + +- [ ] **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. + +- [ ] **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 + - `.order_dependent_variables(names_in_scope, dependencies, label)` → character (stops with `"