From 9d23b2759dc4000ae3d0f33a5c7a4c2585d8170b Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 06:52:45 -0400 Subject: [PATCH 01/41] Add v0.4 mock_spec foundation --- NAMESPACE | 6 + R/mock_spec.R | 477 +++++++++ development/adr/v04-hybrid-backend.md | 115 +++ development/simstudy-v04.md | 128 +++ development/v04-simstudy-spike/README.md | 155 --- development/v04-simstudy-spike/prototype.R | 1071 -------------------- tests/testthat/test-mock-spec.R | 143 +++ 7 files changed, 869 insertions(+), 1226 deletions(-) create mode 100644 R/mock_spec.R create mode 100644 development/adr/v04-hybrid-backend.md create mode 100644 development/simstudy-v04.md delete mode 100644 development/v04-simstudy-spike/README.md delete mode 100644 development/v04-simstudy-spike/prototype.R create mode 100644 tests/testthat/test-mock-spec.R diff --git a/NAMESPACE b/NAMESPACE index c3b3e55..33c9e9a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -23,7 +23,12 @@ export(get_variables_by_role) export(has_garbage) export(identify_derived_vars) export(import_from_recodeflow) +export(is_mock_spec) export(make_garbage) +export(mock_spec) +export(mock_spec_categorical) +export(mock_spec_continuous) +export(mock_spec_date) export(parse_range_notation) export(parse_variable_start) export(read_mock_data_config) @@ -31,6 +36,7 @@ export(read_mock_data_config_details) export(sample_with_proportions) export(validate_mock_data_config) export(validate_mock_data_config_details) +export(validate_mock_spec) export(validate_mockdata_metadata) importFrom(stats,rexp) importFrom(stats,rnorm) diff --git a/R/mock_spec.R b/R/mock_spec.R new file mode 100644 index 0000000..69b8a0a --- /dev/null +++ b/R/mock_spec.R @@ -0,0 +1,477 @@ +# ============================================================================== +# MockData v0.4 Specification Layer +# ============================================================================== +# Normalized internal representation for direct APIs, recodeflow adapters, and +# optional generation backends. +# ============================================================================== + +.mock_spec_version <- "0.4.0" + +.mock_spec_model_hints <- c( + "auto", + "native", + "simstudy", + "native-postprocess", + "simstudy-or-native", + "simstudy-advanced", + "diagnostic-required" +) + +`%||%` <- function(x, y) { + if (is.null(x)) y else x +} + +.normalize_provenance <- function(provenance, source = NULL) { + if (is.null(provenance)) { + provenance <- list(adapter = "direct", source = source %||% "direct") + } else if (!is.list(provenance)) { + provenance <- list(adapter = as.character(provenance), source = source %||% as.character(provenance)) + } + + if (is.null(provenance$adapter) || is.na(provenance$adapter) || provenance$adapter == "") { + provenance$adapter <- "unknown" + } + if (is.null(provenance$source) || is.na(provenance$source) || provenance$source == "") { + provenance$source <- provenance$adapter + } + + provenance +} + +.validate_model_hint <- function(model_hint) { + if (length(model_hint) != 1 || is.na(model_hint) || !model_hint %in% .mock_spec_model_hints) { + stop( + "model_hint must be one of: ", + paste(.mock_spec_model_hints, collapse = ", "), + call. = FALSE + ) + } + + invisible(TRUE) +} + +.new_mock_spec_variable <- function(name, + type, + rtype, + distribution = NULL, + range = NULL, + levels = NULL, + proportions = NULL, + formula = NULL, + missing_codes = character(0), + missing_proportions = numeric(0), + garbage_rules = list(), + source_format = NULL, + depends_on = character(0), + provenance = NULL, + model_hint = "auto", + ...) { + if (!is.character(name) || length(name) != 1 || is.na(name) || trimws(name) == "") { + stop("mock_spec variable name must be a non-empty string.", call. = FALSE) + } + if (!is.character(type) || length(type) != 1 || is.na(type) || trimws(type) == "") { + stop("mock_spec variable type must be a non-empty string.", call. = FALSE) + } + + .validate_model_hint(model_hint) + + structure( + c( + list( + name = name, + type = tolower(type), + rtype = tolower(rtype), + distribution = distribution, + range = range, + levels = levels, + proportions = proportions, + formula = formula, + missing_codes = missing_codes, + missing_proportions = missing_proportions, + garbage_rules = garbage_rules, + source_format = source_format, + depends_on = depends_on, + provenance = .normalize_provenance(provenance), + model_hint = model_hint + ), + list(...) + ), + class = c("mock_spec_variable", "list") + ) +} + +.as_mock_spec_variable_list <- function(...) { + variables <- list(...) + + if (length(variables) == 1 && is.null(variables[[1]])) { + return(list()) + } + + if (length(variables) == 1 && is.list(variables[[1]]) && !inherits(variables[[1]], "mock_spec_variable")) { + variables <- variables[[1]] + } + + if (length(variables) == 0) { + return(list()) + } + + if (!all(vapply(variables, inherits, logical(1), what = "mock_spec_variable"))) { + stop("mock_spec() inputs must be mock_spec_variable objects.", call. = FALSE) + } + + names(variables) <- vapply(variables, `[[`, character(1), "name") + variables +} + +#' Create a MockData specification +#' +#' `mock_spec()` creates the normalized v0.4 specification object used by the +#' new architecture. Direct APIs and recodeflow adapters should both normalize +#' into this shape before validation and generation. +#' +#' @param ... `mock_spec_variable` objects, or a single list of them. `NULL` +#' creates an empty specification. +#' @param spec_version Character version of the specification shape. +#' @param provenance List or character describing where the spec came from. +#' @param model_hint Character backend hint. One of the supported MockData model +#' hints. +#' +#' @return S3 object of class `mock_spec`. +#' @export +mock_spec <- function(..., + spec_version = .mock_spec_version, + provenance = list(adapter = "direct", source = "direct"), + model_hint = "auto") { + .validate_model_hint(model_hint) + + structure( + list( + spec_version = spec_version, + provenance = .normalize_provenance(provenance), + model_hint = model_hint, + variables = .as_mock_spec_variable_list(...) + ), + class = c("mock_spec", "list") + ) +} + +#' Create a continuous variable specification +#' +#' @param name Variable 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 rtype R output type. 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 Provenance metadata. +#' @param model_hint Backend hint. +#' +#' @return A `mock_spec_variable` object. +#' @export +mock_spec_continuous <- function(name, + range, + distribution = "uniform", + mean = NA_real_, + sd = NA_real_, + rtype = "double", + missing_codes = numeric(0), + missing_proportions = numeric(0), + garbage_rules = list(), + provenance = "direct", + model_hint = "auto") { + .new_mock_spec_variable( + name = name, + type = "continuous", + rtype = rtype, + distribution = distribution, + range = range, + mean = mean, + sd = sd, + missing_codes = missing_codes, + missing_proportions = missing_proportions, + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = model_hint + ) +} + +#' Create a categorical variable specification +#' +#' @param name Variable name. +#' @param levels Character vector of valid levels or codes. +#' @param proportions Optional probabilities aligned to `levels`. +#' @param rtype R output type. Defaults to `"factor"`. +#' @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 Provenance metadata. +#' @param model_hint Backend hint. +#' +#' @return A `mock_spec_variable` object. +#' @export +mock_spec_categorical <- function(name, + levels, + proportions = NULL, + rtype = "factor", + missing_codes = character(0), + missing_proportions = numeric(0), + garbage_rules = list(), + provenance = "direct", + model_hint = "auto") { + .new_mock_spec_variable( + name = name, + type = "categorical", + rtype = rtype, + distribution = "categorical", + levels = levels, + proportions = proportions, + missing_codes = missing_codes, + missing_proportions = missing_proportions, + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = model_hint + ) +} + +#' Create a date variable specification +#' +#' @param name Variable name. +#' @param range Date vector of length two giving the inclusive valid date range. +#' @param rtype R output type. Defaults to `"date"`. +#' @param source_format Source-format hint. Defaults to `"analysis"`. +#' @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 Provenance metadata. +#' @param model_hint Backend hint. +#' +#' @return A `mock_spec_variable` object. +#' @export +mock_spec_date <- function(name, + range, + rtype = "date", + source_format = "analysis", + missing_codes = character(0), + missing_proportions = numeric(0), + garbage_rules = list(), + provenance = "direct", + model_hint = "native-postprocess") { + .new_mock_spec_variable( + name = name, + type = "date", + rtype = rtype, + distribution = "uniform", + range = range, + source_format = source_format, + missing_codes = missing_codes, + missing_proportions = missing_proportions, + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = model_hint + ) +} + +#' Check whether an object is a MockData specification +#' +#' @param x Object to check. +#' +#' @return Logical scalar. +#' @export +is_mock_spec <- function(x) { + inherits(x, "mock_spec") +} + +.new_mock_spec_validation_result <- function(valid = TRUE, + errors = character(0), + warnings = character(0), + info = character(0)) { + structure( + list( + valid = valid, + errors = errors, + warnings = warnings, + info = info + ), + class = c("mock_spec_validation_result", "list") + ) +} + +.validate_probability_vector <- function(values, label, allow_null = FALSE) { + errors <- character(0) + + if (is.null(values)) { + if (allow_null) { + return(errors) + } + return(paste0(label, " must not be NULL.")) + } + + if (!is.numeric(values)) { + errors <- c(errors, paste0(label, " must be numeric.")) + } else { + if (any(is.na(values))) { + errors <- c(errors, paste0(label, " must not contain NA values.")) + } + if (any(values < 0 | values > 1, na.rm = TRUE)) { + errors <- c(errors, paste0(label, " must be between 0 and 1.")) + } + } + + errors +} + +.validate_missing_spec <- function(variable) { + errors <- character(0) + + if (length(variable$missing_codes) == 0 && length(variable$missing_proportions) == 0) { + return(errors) + } + + if (length(variable$missing_codes) != length(variable$missing_proportions)) { + errors <- c(errors, paste0( + "Variable '", variable$name, + "' must have one missing proportion per missing code." + )) + } + + errors <- c(errors, .validate_probability_vector( + variable$missing_proportions, + paste0("Variable '", variable$name, "' missing_proportions"), + allow_null = FALSE + )) + + missing_sum <- sum(variable$missing_proportions, na.rm = TRUE) + if (missing_sum > 1) { + errors <- c(errors, paste0( + "Variable '", variable$name, + "' missing proportions must sum to <= 1." + )) + } + + errors +} + +.validate_range <- function(range, variable_name, expected_class = "numeric") { + errors <- character(0) + + if (is.null(range) || length(range) != 2) { + return(paste0("Variable '", variable_name, "' range must have length 2.")) + } + + if (expected_class == "Date") { + if (!inherits(range, "Date")) { + errors <- c(errors, paste0("Variable '", variable_name, "' range must be Date.")) + } + } else if (!is.numeric(range)) { + errors <- c(errors, paste0("Variable '", variable_name, "' range must be numeric.")) + } + + if (any(is.na(range))) { + errors <- c(errors, paste0("Variable '", variable_name, "' range must not contain NA values.")) + } else if (range[[1]] > range[[2]]) { + errors <- c(errors, paste0("Variable '", variable_name, "' range lower bound must be <= upper bound.")) + } + + errors +} + +.validate_mock_spec_variable <- function(variable) { + errors <- character(0) + + if (!inherits(variable, "mock_spec_variable")) { + return("All mock_spec variables must inherit from mock_spec_variable.") + } + + errors <- c(errors, .validate_missing_spec(variable)) + + if (variable$type == "continuous") { + errors <- c(errors, .validate_range(variable$range, variable$name, "numeric")) + if (identical(variable$distribution, "normal")) { + if (is.null(variable$mean) || length(variable$mean) != 1 || is.na(variable$mean)) { + errors <- c(errors, paste0("Variable '", variable$name, "' normal distribution requires mean.")) + } + if (is.null(variable$sd) || length(variable$sd) != 1 || is.na(variable$sd) || variable$sd <= 0) { + errors <- c(errors, paste0("Variable '", variable$name, "' normal distribution requires sd > 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.")) + } + if (!is.null(variable$proportions)) { + if (length(variable$levels) != length(variable$proportions)) { + errors <- c(errors, paste0("Variable '", variable$name, "' must have one proportion per level.")) + } + errors <- c(errors, .validate_probability_vector( + variable$proportions, + paste0("Variable '", variable$name, "' proportions"), + allow_null = FALSE + )) + prop_sum <- sum(variable$proportions, na.rm = TRUE) + if (abs(prop_sum - 1) > 0.001) { + errors <- c(errors, paste0("Variable '", variable$name, "' proportions must sum to 1.")) + } + } + } else if (variable$type == "date") { + errors <- c(errors, .validate_range(variable$range, variable$name, "Date")) + } else { + errors <- c(errors, paste0("Variable '", variable$name, "' has unsupported type '", variable$type, "'.")) + } + + errors +} + +#' Validate a MockData specification +#' +#' @param spec A `mock_spec` object. +#' @param n Optional number of rows expected for generation. If supplied, must +#' be a non-negative whole number. +#' @param strict Logical. If `TRUE`, invalid specs throw an error. If `FALSE`, +#' a validation result object is returned. +#' +#' @return A `mock_spec_validation_result` object when valid or `strict = FALSE`. +#' @export +validate_mock_spec <- function(spec, n = NULL, strict = TRUE) { + errors <- character(0) + warnings <- character(0) + info <- character(0) + + if (!is_mock_spec(spec)) { + errors <- c(errors, "spec must be a mock_spec object.") + } else { + if (is.null(spec$spec_version) || length(spec$spec_version) != 1 || is.na(spec$spec_version)) { + errors <- c(errors, "mock_spec must have a scalar spec_version.") + } + if (is.null(spec$variables) || !is.list(spec$variables)) { + errors <- c(errors, "mock_spec variables must be a list.") + } else { + variable_names <- names(spec$variables) + if (length(variable_names) != length(unique(variable_names))) { + errors <- c(errors, "mock_spec variable names must be unique.") + } + for (variable in spec$variables) { + errors <- c(errors, .validate_mock_spec_variable(variable)) + } + } + } + + if (!is.null(n)) { + if (!is.numeric(n) || length(n) != 1 || is.na(n) || n < 0 || n != floor(n)) { + errors <- c(errors, "n must be a non-negative whole number.") + } + } + + valid <- length(errors) == 0 + result <- .new_mock_spec_validation_result(valid, errors, warnings, info) + + if (!valid && isTRUE(strict)) { + stop(paste(errors, collapse = "\n"), call. = FALSE) + } + + result +} diff --git a/development/adr/v04-hybrid-backend.md b/development/adr/v04-hybrid-backend.md new file mode 100644 index 0000000..eca4420 --- /dev/null +++ b/development/adr/v04-hybrid-backend.md @@ -0,0 +1,115 @@ +# ADR: v0.4 Hybrid Backend Architecture + +**Status**: draft +**Date**: 2026-05-18 +**Decision owner**: MockData maintainers + +## Context + +MockData began as an experiment for generating mock testing data from +recodeflow-style metadata. It now supports categorical, continuous, date, +garbage-data, and survival-style examples. People are using it, and it is +becoming part of the recodeflow/cchsflow/chmsflow adoption path. + +The v0.3 architecture grew organically. The current generators filter metadata, +parse ranges, infer generation parameters, generate values, apply missing codes, +inject garbage, coerce types, and return columns. That made early development +fast, but it makes cross-variable structure, validation, diagnostics, and +backend selection harder to reason about. + +The v0.4 spike tested whether MockData can normalize user inputs into a +`mock_spec`, generate through either a native backend or `simstudy`, and keep +MockData-specific semantics as post-processing. Three review rounds converged on +the same conclusion: the hybrid architecture is ready for production refactor +planning. + +## Decision + +MockData v0.4 will move toward a hybrid backend architecture: + +- `mock_spec` is the normalized internal representation. +- Native MockData generation is the default backend and must work without + `simstudy`. +- `simstudy` is an optional advanced backend for features where it clearly helps, + including formula dependencies, correlations, survival durations, and mature + simulation mechanics. +- MockData remains responsible for recodeflow semantics, simple direct APIs, + validation, explicit missing-code conventions, garbage/invalid data, + diagnostics, date/source-format conversion, and calendar anchoring. +- `mock_spec` carries `spec_version`, `provenance`, and `model_hint` to preserve + adapter agnosticism. + +## License And Dependency Posture + +MockData remains MIT. `simstudy` is GPL-3, so it will initially be kept optional +in `Suggests` and accessed through `requireNamespace()`. + +Importing `simstudy` as a required dependency would require a conscious future +governance decision. + +## API And Deprecation + +Current public functions remain available in v0.4.0: + +- `create_mock_data()` +- `create_cat_var()` +- `create_con_var()` +- `create_date_var()` +- `create_wide_survival_data()` + +These functions should become wrappers around the new layered internals where +possible. They should not be removed in v0.4.0. + +Deprecation policy: + +- No removal before v0.5.0. +- Lifecycle deprecation warnings may be added during v0.4.x only after sibling + package maintainers have had a migration path. +- `NEWS.md` must include v0.4.0 migration notes and any deprecation timeline. + +## Non-Goals + +MockData will not market itself as synthetic data for inference, privacy release, +or population-valid statistical analysis. It generates mock data for code +development, QA, documentation, examples, and training. + +## Consequences + +Positive: + +- Recodeflow support remains central. +- Simple users can use direct APIs without learning `simstudy`. +- Advanced users can benefit from a mature simulation backend. +- Missing codes, garbage data, dates, and diagnostics stay MockData-owned. +- Native generation keeps the package usable where optional dependencies are not + installed. + +Tradeoffs: + +- The package needs a real internal spec model. +- Backends must be tested for parity where both support the same feature. +- Some spike contracts need production design: diagnostics, date offsets, + formula syntax, custom distribution registry, and correlation merging. +- Maintaining wrappers will add short-term complexity. + +## Implementation Direction + +Production refactor should proceed in layers: + +1. `mock_spec` constructors and validators. +2. Direct and recodeflow input adapters. +3. Formula/dependency evaluator. +4. Native backend. +5. Post-processing layer. +6. Promotion of spike assertions to `testthat`. +7. Optional `simstudy` backend. +8. Current API wrappers. + +## Open Follow-Up Decisions + +- Multi-group correlation merge strategy. +- Diagnostics object shape. +- Whether `mock_spec` is internal-only or partially user-facing in v0.4.0. +- How formula/dependency syntax enters from recodeflow or direct APIs. +- How Table 1 / summary specifications become a future adapter. + diff --git a/development/simstudy-v04.md b/development/simstudy-v04.md new file mode 100644 index 0000000..5772839 --- /dev/null +++ b/development/simstudy-v04.md @@ -0,0 +1,128 @@ +# MockData v0.4 Production Refactor Plan + +## 1. Write The ADR First + +Write a short architecture decision record before production code changes. + +The ADR should lock these decisions: + +- **Decision**: MockData adopts a hybrid backend architecture. +- **Core abstraction**: `mock_spec` is the normalized internal specification. +- **Forward compatibility**: `mock_spec` carries `spec_version`, `provenance`, + and `model_hint` so direct APIs, recodeflow adapters, and future adapters can + share one internal representation. +- **Default backend**: native MockData generation remains the default and must + work without `simstudy`. +- **Optional backend**: `simstudy` is an advanced backend, initially in + `Suggests`, gated with `requireNamespace()`. +- **License posture**: keep MockData MIT by keeping `simstudy` optional unless a + future governance decision changes that. +- **Version target**: v0.4.0. +- **NEWS commitment**: `NEWS.md` gets a v0.4.0 section with breaking changes, + the new spec model, migration notes, and deprecated functions. +- **Current API timeline**: existing public functions remain as wrappers through + v0.4.0. They may be marked lifecycle-deprecated in v0.4.x after sibling + packages have migrated, and removed no earlier than v0.5.0. +- **Non-goal**: MockData remains mock data for code development, QA, and + documentation. It is not marketed as synthetic data for inference or privacy + release. + +## 2. Implement In Layers + +Each layer should have focused tests before the next layer starts. + +1. **`mock_spec` core** + - Constructors and validators. + - Stable fields for names, types, ranges, levels, proportions, missing codes, + garbage rules, formulas, dates, and backend hints. + - Explicit handling for empty specs, `NULL` metadata, single-row specs, and + `n = 0`. + +2. **Input adapters** + - Direct function-argument APIs to `mock_spec`. + - Recodeflow `variables` + `variable_details` adapter to `mock_spec`. + - Preserve recodeflow semantics as first-class behavior. + +3. **Formula/dependency evaluator** + - Promote the spike pattern to core: formula referent validation, topological + ordering, cycle detection, and sandboxed evaluation in a generated-data + environment. + +4. **Native backend** + - Generate valid baseline values from `mock_spec`. + - Keep native support for the simple/core path without `simstudy`. + - Add multi-group correlation strategy, including merge behavior with + ordinary variables. + +5. **Post-processing layer** + - Missing codes. + - Garbage values. + - `rType` coercion. + - Date/source-format conversion. + - Diagnostics contract. + - Replace the legacy `var_row` garbage shim with typed garbage specs. + +6. **Spike assertion promotion** + - Promote the strongest spike assertions into `testthat`, especially: + categorical code/label preservation, missing-code collision diagnostics, + seed reproducibility, censor/event date invariants, recEnd-driven + missingness, formula dependency validation, and correlation contracts. + +7. **Optional `simstudy` backend** + - Translate supported `mock_spec` pieces to `simstudy` definitions. + - Keep `simstudy` optional with clear errors when unavailable. + - Test native/simstudy parity for column names, types, reproducibility, and + expected statistical contracts. + +8. **Orchestrator wrappers** + - Gradually replace current dispatch internals. + - Keep `create_mock_data()`, `create_cat_var()`, `create_con_var()`, and + `create_date_var()` alive as wrappers during transition. + +## 3. Keep The Current API Alive + +Existing public functions should remain available in v0.4.0: + +- `create_mock_data()` +- `create_cat_var()` +- `create_con_var()` +- `create_date_var()` +- `create_wide_survival_data()` + +These should call the new layered internals where possible. Migration should be +incremental so cchsflow, chmsflow, and recodeflow users do not need a +synchronized release. + +## 4. Carry-Forward Design Issues + +Settle in the ADR or the first design note: + +- Multi-group correlation merge strategy. +- Whether `mock_spec` is internal-only in v0.4.0 or partially user-facing. +- Diagnostics object shape and stability. +- `simstudy` dependency posture after governance review. +- Deprecation schedule for current public wrappers. + +Track as implementation issues: + +- Empty / `NULL` / single-row input behavior. +- Date `__offset` convention and whether it remains internal. +- Garbage `var_row` shim replacement. +- Distribution registry for custom backend functions. +- Seed discipline between baseline generation and post-processing. +- Native vs `simstudy` backend equivalence tests. +- Event/censoring rate tests with two-sided bounds. +- Table 1 / summary-spec source as a future adapter. + +## 5. Communication + +Before v0.4.0 lands, write a short communication note for cchsflow, chmsflow, +and recodeflow maintainers: + +- What changes. +- What does not change. +- Which functions remain available. +- What migration is optional in v0.4.0. +- When deprecation warnings may begin. +- How the mock-data framing remains distinct from synthetic-data release. + diff --git a/development/v04-simstudy-spike/README.md b/development/v04-simstudy-spike/README.md deleted file mode 100644 index 42ca5c1..0000000 --- a/development/v04-simstudy-spike/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# MockData v0.4 simstudy Spike - -This directory contains a disposable architecture spike for MockData v0.4. -It is intentionally outside the package build and is excluded by -`.Rbuildignore`. - -The working thesis: - -> MockData remains the recodeflow-native, MIT-licensed interface for practical -> mock data. `simstudy` is evaluated as an optional advanced engine for cases -> where it clearly improves robustness, performance, or modeling capability. - -## Run - -Install `simstudy` into a temporary library, then run: - -```r -lib <- "/private/tmp/mockdata-simstudy-lib" -dir.create(lib, recursive = TRUE, showWarnings = FALSE) -install.packages("simstudy", lib = lib, repos = "https://cloud.r-project.org") - -source("development/v04-simstudy-spike/prototype.R") -``` - -The prototype expects that temporary library path by default. It does not -modify `DESCRIPTION`, `renv.lock`, or the package library. - -To exercise the native fallback path without `simstudy`, point the temporary -library variable at an empty path: - -```sh -MOCKDATA_SIMSTUDY_LIB=/private/tmp/no-simstudy-lib \ - Rscript --vanilla development/v04-simstudy-spike/prototype.R -``` - -## What This Prototype Tests - -- Recodeflow-style metadata can normalize into a small internal `mock_spec`. -- The same `mock_spec` can translate to `simstudy::defData()` definitions for - age, smoking, interview date offsets, and one formula dependency. -- `simstudy` can preserve recodeflow categorical codes via categorical levels, - and both backends can also generate non-numeric categorical labels such as - `"never"`, `"former"`, and `"current"`. -- Truncated normal age generation can be handled with a `simstudy` custom - distribution while MockData still owns range parsing and rType coercion. -- MockData-style explicit missing codes and garbage values can remain - post-processing after baseline valid-value generation. -- Correlated height/weight generation is straightforward through - `simstudy::genCorData()` when correlation parameters are declared in - `mock_spec` and translated through the backend definition layer. -- Survival durations can be generated through `simstudy::defSurv()` / - `simstudy::genSurv()` and then anchored back to MockData-owned calendar - dates. -- The same `mock_spec` can drive a native MockData-style generation path when - `simstudy` is absent. -- Missing-code collisions are detectable if post-processing preserves assignment - diagnostics. This matters when a valid drawn value can equal an explicit - missing code. -- Seed reproducibility can be asserted across native and `simstudy` paths. -- Formula dependencies can be validated for missing referents and sorted so - formula variables are generated after their inputs. -- Truncated-normal boundary collapse now fails loudly instead of returning - `NaN`. - -## Early Read - -This first pass supports the hybrid design: - -- `simstudy` looks strong as a generation engine. -- MockData should still own recodeflow semantics, direct simple APIs, validation, - missing-code conventions, garbage data, source formats, and calendar anchoring. -- The normalized `mock_spec` abstraction is useful enough to keep testing. - -Open questions remain around dependency/license posture, error wrapping, -structural constraints, and whether ordinary simple variables should use native -MockData generation or route through `simstudy`. - -## License and Dependency Posture - -`simstudy` is GPL-3. MockData is currently MIT. This spike treats `simstudy` as -an optional advanced backend rather than a required package dependency. A future -architecture decision needs to explicitly decide whether: - -- MockData keeps `simstudy` in `Suggests` with a soft `requireNamespace()` gate. -- MockData imports `simstudy` and accepts the license/governance implications. -- MockData keeps a native engine and only borrows design ideas from `simstudy`. - -The current prototype supports the first option technically: the native fallback -path runs without loading `simstudy`. - -## Prototype Contracts Surfaced - -- `model_hint` is currently a small enum in the prototype rather than an - unrestricted string. -- `provenance` is stored as structured metadata and displayed compactly in the - printed spec table. -- `mockdata_diagnostics` is the prototype mechanism for preserving assignment - state after post-processing. This is what lets MockData distinguish a value - that was drawn as valid from the same value assigned as an explicit missing - code. -- Survival date columns use mutually exclusive `event_date` and `censor_date` - values. Event rows do not also receive a censoring date. - -## Review Gaps Addressed After First PR Review - -- Added `spec_version`, `provenance`, and `model_hint` fields. -- Added a fail-loud `from_linkml()` placeholder to keep a future third adapter - visible without pretending it is implemented. -- Added native backend generation from the same `mock_spec`. -- Added a missing-code collision case where `97` can be both a valid generated - value and an assigned missing code. -- Moved the height/weight correlation example into a `mock_spec` declaration. -- Added seed reproducibility assertions. - -## Review Gaps Addressed After Second PR Review - -- Fixed the survival censoring semantic bug where event rows also had - `censor_date` populated. -- Routed correlation parameters through `mock_spec` and the backend definition - layer, with both `simstudy` and native Cholesky-based generation paths. -- Added formula referent validation and dependency ordering. -- Added statistical-contract assertions for truncated normal moments, - categorical proportions, garbage rates, and correlation marginals. -- Added non-numeric categorical label coverage. - -## Remaining Questions Before Production Refactor - -- Whether `mockdata_diagnostics` should become a formal internal contract or a - different diagnostics object. -- How much of the prototype `mock_spec` should become user-facing for advanced - users. -- Whether formula/dependency syntax should come from recodeflow metadata, - direct MockData arguments, or a future third adapter. -- Whether `simstudy` remains a `Suggests` backend long term or becomes a - stronger package dependency after governance review. - -## Internal Contracts Not Yet Generalized - -The prototype deliberately encodes a few implementation contracts that are -useful evidence, but not production-ready API decisions: - -- Date variables use hidden `__offset` companion columns during generation, then - convert those offsets to calendar dates in post-processing. -- Custom `simstudy` distributions are resolved by global function name, as with - `mockdata_rtrunc_norm`; production code likely needs an explicit distribution - registry. -- Formula variables are added directly to `mock_spec` in the spike rather than - parsed from recodeflow metadata. -- Garbage post-processing still rebuilds a row-shaped `var_row` object to reuse - the v0.3 `apply_garbage()` helper. -- The prototype uses `seed + 1` for post-processing so baseline generation and - missing/garbage assignment are reproducible but distinct. -- Correlated variables currently use a separate backend path from ordinary - `defData()` generation; production code needs a merge strategy for multiple - correlation groups and ordinary variables. diff --git a/development/v04-simstudy-spike/prototype.R b/development/v04-simstudy-spike/prototype.R deleted file mode 100644 index da5bbd3..0000000 --- a/development/v04-simstudy-spike/prototype.R +++ /dev/null @@ -1,1071 +0,0 @@ -# MockData v0.4 simstudy architecture spike. -# -# This is deliberately a prototype, not package code. It tests whether a small -# normalized mock_spec can sit between recodeflow metadata and simstudy. - -local({ - spike_lib <- Sys.getenv("MOCKDATA_SIMSTUDY_LIB", "/private/tmp/mockdata-simstudy-lib") - if (dir.exists(spike_lib)) { - .libPaths(c(spike_lib, .libPaths())) - } -}) - -simstudy_available <- requireNamespace("simstudy", quietly = TRUE) - -source("R/mockdata-parsers.R", local = TRUE) -source("R/mockdata_helpers.R", local = TRUE) - -MODEL_HINTS <- c( - "hybrid", - "auto", - "native-postprocess", - "simstudy-or-native", - "simstudy-advanced", - "diagnostic-required" -) - -`%||%` <- function(x, y) { - if (is.null(x)) y else x -} - -mockdata_rtrunc_norm <- function(n, min, max, mu, s) { - if (any(!is.finite(min)) || any(!is.finite(max)) || any(min >= max)) { - stop("Truncated normal requires finite min < max.", call. = FALSE) - } - - f_min <- stats::pnorm(min, mean = mu, sd = s) - f_max <- stats::pnorm(max, mean = mu, sd = s) - if (any(!is.finite(f_min)) || any(!is.finite(f_max)) || any(f_min >= f_max)) { - stop("Truncated normal bounds collapse to an empty probability interval.", call. = FALSE) - } - - stats::qnorm(stats::runif(n, min = f_min, max = f_max), mean = mu, sd = s) -} - -new_mock_spec <- function(vars, - spec_version = "0.4-spike-1", - provenance = list(adapter = "mixed", source = "prototype"), - model_hint = "hybrid", - correlation_groups = list()) { - validate_model_hint(model_hint) - - structure( - vars, - class = "mock_spec", - spec_version = spec_version, - provenance = provenance, - model_hint = model_hint, - correlation_groups = correlation_groups - ) -} - -add_spec_metadata <- function(var, - spec_version = "0.4-spike-1", - provenance = "direct", - model_hint = "auto") { - validate_model_hint(model_hint) - - var$spec_version <- spec_version - var$provenance <- normalize_provenance(provenance) - var$model_hint <- model_hint - var -} - -normalize_provenance <- function(provenance) { - if (is.list(provenance)) { - return(provenance) - } - - list(adapter = provenance, source = provenance) -} - -format_provenance <- function(provenance) { - provenance <- normalize_provenance(provenance) - values <- unique(unname(unlist(provenance, use.names = FALSE))) - paste(values, collapse = "/") -} - -validate_model_hint <- function(model_hint) { - if (!model_hint %in% MODEL_HINTS) { - stop( - "Unknown model_hint: ", model_hint, - ". Expected one of: ", paste(MODEL_HINTS, collapse = ", "), - call. = FALSE - ) - } - - invisible(TRUE) -} - -mock_spec_continuous <- function(name, - range, - distribution = "uniform", - mean = NA_real_, - sd = NA_real_, - rtype = "double", - missing_codes = numeric(0), - missing_proportions = numeric(0), - garbage = list(), - source = "direct", - provenance = source, - model_hint = "auto", - correlation_group = NA_character_) { - add_spec_metadata(list( - name = name, - type = "continuous", - rtype = rtype, - distribution = distribution, - range = range, - mean = mean, - sd = sd, - levels = NULL, - proportions = NULL, - formula = NULL, - missing_codes = missing_codes, - missing_proportions = missing_proportions, - garbage = garbage, - source = source, - correlation_group = correlation_group - ), provenance = provenance, model_hint = model_hint) -} - -mock_spec_categorical <- function(name, - levels, - proportions = NULL, - rtype = "factor", - missing_codes = character(0), - missing_proportions = numeric(0), - garbage = list(), - source = "direct", - provenance = source, - model_hint = "auto") { - if (is.null(proportions)) { - proportions <- rep(1 / length(levels), length(levels)) - } - - add_spec_metadata(list( - name = name, - type = "categorical", - rtype = rtype, - distribution = "categorical", - range = NULL, - mean = NA_real_, - sd = NA_real_, - levels = levels, - proportions = proportions, - formula = NULL, - missing_codes = missing_codes, - missing_proportions = missing_proportions, - garbage = garbage, - source = source - ), provenance = provenance, model_hint = model_hint) -} - -mock_spec_date <- function(name, - range, - rtype = "date", - source_format = "analysis", - source = "direct", - provenance = source, - model_hint = "native-postprocess") { - add_spec_metadata(list( - name = name, - type = "date", - rtype = rtype, - distribution = "uniform", - range = range, - mean = NA_real_, - sd = NA_real_, - levels = NULL, - proportions = NULL, - formula = NULL, - missing_codes = character(0), - missing_proportions = numeric(0), - garbage = list(), - source_format = source_format, - source = source - ), provenance = provenance, model_hint = model_hint) -} - -mock_spec_binary_formula <- function(name, formula, rtype = "integer") { - add_spec_metadata(list( - name = name, - type = "binary_formula", - rtype = rtype, - distribution = "binary", - range = c(0, 1), - mean = NA_real_, - sd = NA_real_, - levels = NULL, - proportions = NULL, - formula = formula, - missing_codes = character(0), - missing_proportions = numeric(0), - garbage = list(), - source = "formula" - ), provenance = "formula", model_hint = "simstudy-or-native") -} - -mock_spec_correlated_continuous <- function(name, - mean, - sd, - correlation_group, - range = c(-Inf, Inf), - rtype = "double") { - mock_spec_continuous( - name = name, - range = range, - distribution = "correlated_normal", - mean = mean, - sd = sd, - rtype = rtype, - source = "correlation_spec", - provenance = "direct", - model_hint = "simstudy-advanced", - correlation_group = correlation_group - ) -} - -spec_table <- function(spec) { - data.frame( - name = vapply(spec, `[[`, character(1), "name"), - type = vapply(spec, `[[`, character(1), "type"), - rtype = vapply(spec, `[[`, character(1), "rtype"), - distribution = vapply(spec, `[[`, character(1), "distribution"), - source = vapply(spec, `[[`, character(1), "source"), - provenance = vapply(spec, function(x) format_provenance(x$provenance), character(1)), - model_hint = vapply(spec, `[[`, character(1), "model_hint"), - stringsAsFactors = FALSE - ) -} - -first_range <- function(details_subset) { - for (value in details_subset$recStart) { - parsed <- parse_range_notation(value) - if (!is.null(parsed) && parsed$type %in% c("integer", "continuous", "date")) { - return(c(parsed$min, parsed$max)) - } - } - - NULL -} - -extract_missing <- function(details_subset) { - missing_rows <- details_subset[ - grepl("^NA::", details_subset$recEnd %||% "", ignore.case = TRUE), - ] - - if (nrow(missing_rows) == 0) { - return(list(codes = character(0), proportions = numeric(0))) - } - - props <- missing_rows$proportion - props[is.na(props)] <- 0 - list( - codes = stats::setNames(missing_rows$recStart, missing_rows$recStart), - proportions = stats::setNames(props, missing_rows$recStart) - ) -} - -extract_garbage <- function(var_row) { - fields <- c( - "garbage_low_prop", "garbage_low_range", - "garbage_high_prop", "garbage_high_range" - ) - fields <- fields[fields %in% names(var_row)] - stats::setNames(as.list(var_row[1, fields, drop = TRUE]), fields) -} - -as_mock_spec_from_recodeflow <- function(variables, variable_details, databaseStart) { - out <- list() - - for (i in seq_len(nrow(variables))) { - var_row <- variables[i, ] - name <- var_row$variable - details_subset <- variable_details[ - variable_details$variable == name & - .database_start_matches(variable_details$databaseStart, databaseStart, allow_empty = TRUE), - ] - - type <- tolower(var_row$variableType) - rtype <- tolower(var_row$rType) - missing <- extract_missing(details_subset) - garbage <- extract_garbage(var_row) - - if (type %in% c("continuous", "integer", "numeric")) { - out[[name]] <- mock_spec_continuous( - name = name, - range = first_range(details_subset), - distribution = if ("distribution" %in% names(var_row)) var_row$distribution else "uniform", - mean = if ("mean" %in% names(var_row)) var_row$mean else NA_real_, - sd = if ("sd" %in% names(var_row)) var_row$sd else NA_real_, - rtype = rtype, - missing_codes = missing$codes, - missing_proportions = missing$proportions, - garbage = garbage, - source = "recodeflow" - ) - } else if (type %in% c("categorical", "factor")) { - props <- extract_proportions(details_subset, name) - missing_codes <- stats::setNames(names(props$missing), names(props$missing)) - out[[name]] <- mock_spec_categorical( - name = name, - levels = props$categories, - proportions = props$category_proportions, - rtype = rtype, - missing_codes = missing_codes, - missing_proportions = unlist(props$missing, use.names = TRUE), - garbage = garbage, - source = "recodeflow" - ) - } else if (type == "date") { - out[[name]] <- mock_spec_date( - name = name, - range = first_range(details_subset), - rtype = rtype, - source = "recodeflow" - ) - } else { - stop("Unsupported prototype variableType: ", var_row$variableType, call. = FALSE) - } - } - - new_mock_spec(out) -} - -simstudy_formula <- function(x) { - paste(x, collapse = ";") -} - -formula_dependencies <- function(var) { - if (is.null(var$formula) || is.na(var$formula)) { - return(character(0)) - } - - all.vars(str2lang(var$formula)) -} - -order_spec_by_dependencies <- function(spec) { - remaining <- names(spec) - ordered <- character(0) - - while (length(remaining) > 0) { - progressed <- FALSE - - for (name in remaining) { - deps <- intersect(formula_dependencies(spec[[name]]), names(spec)) - 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 - ) - } - } - - new_mock_spec( - spec[ordered], - spec_version = attr(spec, "spec_version"), - provenance = attr(spec, "provenance"), - model_hint = attr(spec, "model_hint"), - correlation_groups = attr(spec, "correlation_groups") %||% list() - ) -} - -validate_formula_referents <- function(spec) { - spec_names <- names(spec) - - for (var in spec) { - missing <- setdiff(formula_dependencies(var), spec_names) - if (length(missing) > 0) { - stop( - "Formula for variable '", var$name, "' references unknown variable(s): ", - paste(missing, collapse = ", "), - call. = FALSE - ) - } - } - - invisible(TRUE) -} - -correlation_defs_from_spec <- function(spec) { - groups <- attr(spec, "correlation_groups") %||% list() - group_names <- unique(na.omit(vapply( - spec, - function(var) var$correlation_group %||% NA_character_, - character(1) - ))) - - lapply(stats::setNames(group_names, group_names), function(group_name) { - vars <- spec[vapply( - spec, - function(var) identical(var$correlation_group %||% NA_character_, group_name), - logical(1) - )] - config <- groups[[group_name]] %||% list(rho = 0, corstr = "cs") - - list( - group = group_name, - names = vapply(vars, `[[`, character(1), "name"), - means = vapply(vars, `[[`, numeric(1), "mean"), - sds = vapply(vars, `[[`, numeric(1), "sd"), - rho = config$rho %||% 0, - corstr = config$corstr %||% "cs" - ) - }) -} - -as_simstudy_def <- function(spec) { - validate_formula_referents(spec) - spec <- order_spec_by_dependencies(spec) - def <- NULL - - for (var in spec) { - if (identical(var$distribution, "correlated_normal")) { - next - } - - if (!simstudy_available) { - stop("simstudy is not available; use backend = 'native' for this spike.", call. = FALSE) - } - - if (var$type == "continuous") { - range <- var$range - if (var$distribution == "normal") { - def <- simstudy::defData( - def, - varname = var$name, - formula = "mockdata_rtrunc_norm", - variance = paste0( - "min = ", range[[1]], - ", max = ", range[[2]], - ", mu = ", var$mean, - ", s = ", var$sd - ), - dist = "custom" - ) - } else if (var$rtype == "integer") { - def <- simstudy::defData( - def, - varname = var$name, - formula = simstudy_formula(range), - dist = "uniformInt" - ) - } else { - def <- simstudy::defData( - def, - varname = var$name, - formula = simstudy_formula(range), - dist = "uniform" - ) - } - } else if (var$type == "categorical") { - def <- simstudy::defData( - def, - varname = var$name, - formula = simstudy_formula(var$proportions), - variance = simstudy_formula(var$levels), - dist = "categorical" - ) - } else if (var$type == "date") { - days <- as.integer(var$range[[2]] - var$range[[1]]) - def <- simstudy::defData( - def, - varname = paste0(var$name, "__offset"), - formula = paste0("0;", days), - dist = "uniformInt" - ) - } else if (var$type == "binary_formula") { - def <- simstudy::defData( - def, - varname = var$name, - formula = var$formula, - dist = "binary", - link = "logit" - ) - } - } - - structure( - list(data_def = def, correlation_groups = correlation_defs_from_spec(spec)), - class = "mock_simstudy_def" - ) -} - -generate_mock_data_simstudy <- function(spec, n, seed = NULL) { - if (!simstudy_available) { - stop("simstudy is not available; use generate_mock_data_native().", call. = FALSE) - } - - if (!is.null(seed)) { - set.seed(seed) - } - - sim_def <- as_simstudy_def(spec) - simstudy::genData(n, sim_def$data_def) -} - -generate_mock_data_native <- function(spec, n, seed = NULL) { - validate_formula_referents(spec) - spec <- order_spec_by_dependencies(spec) - - if (!is.null(seed)) { - set.seed(seed) - } - - data <- data.frame(id = seq_len(n)) - - for (var in spec) { - if (var$type == "continuous") { - if (identical(var$distribution, "normal")) { - data[[var$name]] <- mockdata_rtrunc_norm( - n, - min = var$range[[1]], - max = var$range[[2]], - mu = var$mean, - s = var$sd - ) - } else if (var$rtype == "integer") { - data[[var$name]] <- sample(seq(var$range[[1]], var$range[[2]]), n, replace = TRUE) - } else { - data[[var$name]] <- stats::runif(n, var$range[[1]], var$range[[2]]) - } - } else if (var$type == "categorical") { - data[[var$name]] <- sample( - var$levels, - n, - replace = TRUE, - prob = var$proportions - ) - } else if (var$type == "date") { - days <- as.integer(var$range[[2]] - var$range[[1]]) - data[[paste0(var$name, "__offset")]] <- sample(0:days, n, replace = TRUE) - } else if (var$type == "binary_formula") { - linear_predictor <- eval(str2expression(var$formula), envir = data, enclos = parent.frame()) - data[[var$name]] <- stats::rbinom(n, size = 1, prob = stats::plogis(linear_predictor)) - } - } - - data -} - -generate_correlated_simstudy <- function(sim_def, n, seed = NULL) { - if (!simstudy_available) { - stop("simstudy is not available; use generate_correlated_native().", call. = FALSE) - } - - if (!is.null(seed)) { - set.seed(seed) - } - - group <- sim_def$correlation_groups[[1]] - as.data.frame(simstudy::genCorData( - n, - mu = group$means, - sigma = group$sds, - rho = group$rho, - corstr = group$corstr, - cnames = group$names - )) -} - -generate_correlated_native <- function(sim_def, n, seed = NULL) { - if (!is.null(seed)) { - set.seed(seed) - } - - group <- sim_def$correlation_groups[[1]] - n_vars <- length(group$names) - cor_matrix <- matrix(group$rho, nrow = n_vars, ncol = n_vars) - diag(cor_matrix) <- 1 - z <- matrix(stats::rnorm(n * n_vars), nrow = n) - values <- z %*% chol(cor_matrix) - values <- sweep(values, 2, group$sds, `*`) - values <- sweep(values, 2, group$means, `+`) - out <- as.data.frame(values) - names(out) <- group$names - out$id <- seq_len(n) - out[c("id", group$names)] -} - -inject_missing_codes <- function(values, - missing_codes, - missing_proportions, - seed = NULL, - return_assignment = FALSE) { - assignment <- rep("valid", length(values)) - - if (length(missing_codes) == 0 || sum(missing_proportions, na.rm = TRUE) <= 0) { - if (return_assignment) { - return(list(values = values, assignment = assignment)) - } - return(values) - } - - if (!is.null(seed)) { - set.seed(seed) - } - - missing_proportions[is.na(missing_proportions)] <- 0 - valid_prop <- max(0, 1 - sum(missing_proportions)) - assignment <- sample( - c("valid", names(missing_proportions)), - length(values), - replace = TRUE, - prob = c(valid_prop, missing_proportions) - ) - - values <- apply_missing_codes(values, assignment, as.list(missing_codes)) - - if (return_assignment) { - return(list(values = values, assignment = assignment)) - } - - values -} - -coerce_mock_rtype <- function(values, rtype) { - switch( - rtype, - integer = as.integer(round(as.numeric(values))), - numeric = as.numeric(values), - double = as.double(values), - factor = factor(values), - character = as.character(values), - date = as.Date(values), - values - ) -} - -postprocess_mock_data <- function(data, spec, seed = NULL) { - data <- as.data.frame(data) - diagnostics <- list(missing_assignments = list()) - - for (var in spec) { - if (var$type == "date") { - offset_name <- paste0(var$name, "__offset") - data[[var$name]] <- var$range[[1]] + data[[offset_name]] - data[[offset_name]] <- NULL - } - - if (!var$name %in% names(data)) { - next - } - - missing_result <- inject_missing_codes( - data[[var$name]], - missing_codes = var$missing_codes, - missing_proportions = var$missing_proportions, - seed = seed, - return_assignment = TRUE - ) - data[[var$name]] <- missing_result$values - diagnostics$missing_assignments[[var$name]] <- missing_result$assignment - - if (length(var$garbage) > 0) { - var_row <- as.data.frame(var$garbage, stringsAsFactors = FALSE) - data[[var$name]] <- apply_garbage( - data[[var$name]], - var_row = var_row, - variable_type = var$rtype, - missing_codes = unname(unlist(var$missing_codes, use.names = FALSE)), - seed = seed - ) - } - - data[[var$name]] <- coerce_mock_rtype(data[[var$name]], var$rtype) - } - - attr(data, "mockdata_diagnostics") <- diagnostics - data -} - -example_recodeflow_metadata <- function() { - variables <- data.frame( - variable = c("age", "smoking", "interview_date"), - variableType = c("continuous", "categorical", "date"), - rType = c("integer", "integer", "date"), - distribution = c("normal", NA, "uniform"), - mean = c(50, NA, NA), - sd = c(15, NA, NA), - garbage_low_prop = c(0.02, NA, NA), - garbage_low_range = c("[0,17]", NA, NA), - garbage_high_prop = c(0.02, NA, NA), - garbage_high_range = c("[101,115]", NA, NA), - stringsAsFactors = FALSE - ) - - variable_details <- data.frame( - variable = c( - "age", "age", "age", - "smoking", "smoking", "smoking", "smoking", - "interview_date" - ), - databaseStart = "minimal-example", - recStart = c( - "[18,100]", "997", "998", - "1", "2", "3", "7", - "[2001-01-01;2005-12-31]" - ), - recEnd = c( - "valid", "NA::b", "NA::b", - "valid", "valid", "valid", "NA::b", - "valid" - ), - proportion = c( - NA, 0.02, 0.01, - 0.50, 0.30, 0.17, 0.03, - NA - ), - catLabel = c( - NA, "don't know", "refused", - "never", "former", "current", "don't know", - NA - ), - stringsAsFactors = FALSE - ) - - list(variables = variables, variable_details = variable_details) -} - -run_correlated_height_weight <- function(n = 1000, - seed = 123, - backend = c("simstudy", "native")) { - backend <- match.arg(backend) - correlation_spec <- new_mock_spec(list( - height_cm = mock_spec_correlated_continuous( - "height_cm", - mean = 170, - sd = 10, - correlation_group = "body_size" - ), - weight_kg = mock_spec_correlated_continuous( - "weight_kg", - mean = 78, - sd = 16, - correlation_group = "body_size" - ) - ), - provenance = list(adapter = "direct", source = "correlation prototype"), - correlation_groups = list(body_size = list(rho = 0.65, corstr = "cs"))) - - sim_def <- as_simstudy_def(correlation_spec) - out <- if (backend == "simstudy") { - generate_correlated_simstudy(sim_def, n = n, seed = seed) - } else { - generate_correlated_native(sim_def, n = n, seed = seed) - } - - list(spec = correlation_spec, simstudy_def = sim_def, data = out) -} - -run_survival_anchor <- function(n = 1000, seed = 123) { - if (!simstudy_available) { - stop("simstudy is not available; survival generation is an advanced backend test.", call. = FALSE) - } - - set.seed(seed) - base_def <- simstudy::defData(varname = "exposed", formula = 0.40, dist = "binary") - surv_def <- simstudy::defSurv( - varname = "event_time", - formula = "0.3 * exposed", - scale = 600, - shape = 1 - ) - surv_def <- simstudy::defSurv(surv_def, varname = "censor_time", scale = 1500, shape = 1) - - data <- simstudy::genData(n, base_def) - data <- simstudy::genSurv( - data, - surv_def, - timeName = "followup_days", - censorName = "censor_time", - eventName = "event" - ) - - data <- as.data.frame(data) - entry_start <- as.Date("2001-01-01") - data$entry_date <- entry_start + sample(0:365, n, replace = TRUE) - data$event_date <- as.Date(NA) - data$censor_date <- as.Date(NA) - event_idx <- which(data$event == 1) - censor_idx <- which(data$event == 0) - data$event_date[event_idx] <- data$entry_date[event_idx] + round(data$followup_days[event_idx]) - data$censor_date[censor_idx] <- data$entry_date[censor_idx] + round(data$followup_days[censor_idx]) - data -} - -run_spike <- function(n = 1000, seed = 123, backend = c("simstudy", "native")) { - backend <- match.arg(backend) - - metadata <- example_recodeflow_metadata() - spec <- as_mock_spec_from_recodeflow( - metadata$variables, - metadata$variable_details, - databaseStart = "minimal-example" - ) - spec[["high_visits"]] <- mock_spec_binary_formula( - "high_visits", - "-4 + 0.04 * age + 0.8 * (smoking == 3)" - ) - - baseline <- if (backend == "simstudy") { - generate_mock_data_simstudy(spec, n = n, seed = seed) - } else { - generate_mock_data_native(spec, n = n, seed = seed) - } - final <- postprocess_mock_data(baseline, spec, seed = seed + 1) - correlated <- if (backend == "simstudy") run_correlated_height_weight(n, seed = seed) else NULL - survival <- if (backend == "simstudy") run_survival_anchor(n, seed = seed) else NULL - - list( - backend = backend, - spec = spec, - spec_table = spec_table(spec), - simstudy_def = if (backend == "simstudy") as_simstudy_def(spec) else NULL, - baseline = as.data.frame(baseline), - final = final, - correlated = correlated, - survival = survival - ) -} - -assert_spike <- function(result) { - spec <- result$spec - baseline <- result$baseline - final <- result$final - diagnostics <- attr(final, "mockdata_diagnostics") - - stopifnot(identical(attr(spec, "spec_version"), "0.4-spike-1")) - stopifnot(identical(attr(spec, "model_hint"), "hybrid")) - stopifnot(isTRUE(all.equal(as.numeric(spec$age$range), c(18, 100)))) - stopifnot(identical(spec$smoking$levels, c("1", "2", "3"))) - if (result$backend == "simstudy") { - stopifnot(inherits(result$simstudy_def, "mock_simstudy_def")) - stopifnot("data.table" %in% class(result$simstudy_def$data_def)) - } - stopifnot(all(c("age", "smoking", "interview_date", "high_visits") %in% names(final))) - stopifnot(all(baseline$age >= 18 & baseline$age <= 100)) - stopifnot(abs(mean(baseline$age) - 50) < 2) - stopifnot(abs(stats::sd(baseline$age) - 15) < 3) - stopifnot(is.integer(final$age)) - stopifnot(all(baseline$smoking %in% c(1, 2, 3))) - smoking_props <- prop.table(table(baseline$smoking)) - stopifnot(abs(unname(smoking_props["1"]) - 0.50) < 0.07) - stopifnot(abs(unname(smoking_props["2"]) - 0.30) < 0.07) - stopifnot(abs(unname(smoking_props["3"]) - 0.20) < 0.07) - stopifnot(all(final$high_visits %in% c(0, 1))) - stopifnot(any(final$high_visits == 1)) - stopifnot(inherits(final$interview_date, "Date")) - stopifnot(all(final$interview_date >= as.Date("2001-01-01"))) - stopifnot(all(final$interview_date <= as.Date("2005-12-31"))) - stopifnot(any(final$age %in% c(997L, 998L))) - stopifnot(any(final$age < 18L | final$age > 100L)) - age_valid_assignment <- diagnostics$missing_assignments$age == "valid" - age_garbage_rate <- mean( - (final$age < 18L | final$age > 100L) & age_valid_assignment, - na.rm = TRUE - ) - stopifnot(age_garbage_rate > 0.02) - stopifnot(age_garbage_rate < 0.06) - stopifnot(any(final$smoking == 7L)) - stopifnot(any(diagnostics$missing_assignments$smoking == "7")) - stopifnot(abs(mean(diagnostics$missing_assignments$smoking == "7") - 0.03) < 0.03) - - if (result$backend == "simstudy") { - correlated <- result$correlated$data - correlation_spec <- result$correlated$spec - survival <- result$survival - - stopifnot(all(vapply(correlation_spec, `[[`, character(1), "correlation_group") == "body_size")) - stopifnot(inherits(result$correlated$simstudy_def, "mock_simstudy_def")) - stopifnot(abs(stats::cor(correlated$height_cm, correlated$weight_kg) - 0.65) < 0.08) - stopifnot(abs(mean(correlated$height_cm) - 170) < 2) - stopifnot(abs(mean(correlated$weight_kg) - 78) < 3) - stopifnot(abs(stats::sd(correlated$height_cm) - 10) < 2) - stopifnot(abs(stats::sd(correlated$weight_kg) - 16) < 3) - stopifnot(all(survival$followup_days >= 0)) - stopifnot(any(survival$event == 1)) - stopifnot(all(is.na(survival$event_date) | survival$event_date >= survival$entry_date)) - stopifnot(all(is.na(survival$censor_date) | survival$censor_date >= survival$entry_date)) - stopifnot(!any(!is.na(survival$event_date) & !is.na(survival$censor_date))) - } - - invisible(TRUE) -} - -assert_native_fallback <- function(n = 1000, seed = 123) { - native_result <- run_spike(n = n, seed = seed, backend = "native") - final <- native_result$final - - stopifnot(is.null(native_result$simstudy_def)) - stopifnot(all(c("age", "smoking", "interview_date", "high_visits") %in% names(final))) - stopifnot(is.integer(final$age)) - stopifnot(inherits(final$interview_date, "Date")) - stopifnot(any(final$age %in% c(997L, 998L))) - stopifnot(any(final$smoking == 7L)) - - invisible(native_result) -} - -assert_missing_collision_case <- function(seed = 123) { - spec <- new_mock_spec(list( - collision_code = mock_spec_categorical( - name = "collision_code", - levels = c("1", "2", "97", "99"), - proportions = c(0.20, 0.20, 0.50, 0.10), - rtype = "integer", - missing_codes = c("97" = "97"), - missing_proportions = c("97" = 0.10), - provenance = "collision-test", - model_hint = "diagnostic-required" - ) - )) - - baseline <- generate_mock_data_native(spec, n = 1000, seed = seed) - final <- postprocess_mock_data(baseline, spec, seed = seed + 1) - assignment <- attr(final, "mockdata_diagnostics")$missing_assignments$collision_code - - stopifnot(any(baseline$collision_code == "97")) - stopifnot(any(final$collision_code == 97L & assignment == "valid")) - stopifnot(any(final$collision_code == 97L & assignment == "97")) - - invisible(final) -} - -assert_non_numeric_categorical_labels <- function(seed = 123) { - spec <- new_mock_spec(list( - smoking_label = mock_spec_categorical( - name = "smoking_label", - levels = c("never", "former", "current"), - proportions = c(0.50, 0.30, 0.20), - rtype = "character", - provenance = "label-test", - model_hint = "simstudy-or-native" - ) - )) - - native <- postprocess_mock_data( - generate_mock_data_native(spec, n = 1000, seed = seed), - spec, - seed = seed + 1 - ) - stopifnot(all(native$smoking_label %in% c("never", "former", "current"))) - - if (simstudy_available) { - sim <- postprocess_mock_data( - generate_mock_data_simstudy(spec, n = 1000, seed = seed), - spec, - seed = seed + 1 - ) - stopifnot(all(sim$smoking_label %in% c("never", "former", "current"))) - } - - invisible(TRUE) -} - -assert_formula_dependency_validation <- function() { - spec <- new_mock_spec(list( - outcome = mock_spec_binary_formula("outcome", "-1 + missing_predictor") - )) - - error <- tryCatch( - { - validate_formula_referents(spec) - NULL - }, - error = conditionMessage - ) - stopifnot(grepl("missing_predictor", error)) - - unordered <- new_mock_spec(list( - outcome = mock_spec_binary_formula("outcome", "-4 + 0.04 * age"), - age = mock_spec_continuous( - "age", - range = c(18, 100), - distribution = "normal", - mean = 50, - sd = 15, - rtype = "integer" - ) - )) - ordered <- order_spec_by_dependencies(unordered) - stopifnot(identical(names(ordered), c("age", "outcome"))) - - invisible(TRUE) -} - -assert_truncated_normal_boundaries <- function() { - error <- tryCatch( - { - mockdata_rtrunc_norm(10, min = 5, max = 5, mu = 5, s = 1) - NULL - }, - error = conditionMessage - ) - stopifnot(grepl("min < max", error)) - - invisible(TRUE) -} - -assert_seed_reproducibility <- function() { - first <- run_spike(seed = 123, backend = "native")$final - second <- run_spike(seed = 123, backend = "native")$final - stopifnot(identical(first, second)) - - if (simstudy_available) { - first_simstudy <- run_spike(seed = 123, backend = "simstudy")$final - second_simstudy <- run_spike(seed = 123, backend = "simstudy")$final - stopifnot(identical(first_simstudy, second_simstudy)) - } - - invisible(TRUE) -} - -from_linkml <- function(...) { - stop( - "from_linkml() is a forward-compatibility placeholder for a future ", - "third input adapter; it is not implemented in this spike.", - call. = FALSE - ) -} - -spike_result <- if (simstudy_available) { - run_spike(backend = "simstudy") -} else { - message("simstudy is not available; running native fallback assertions only.") - run_spike(backend = "native") -} -assert_spike(spike_result) -native_result <- assert_native_fallback() -collision_result <- assert_missing_collision_case() -assert_non_numeric_categorical_labels() -assert_formula_dependency_validation() -assert_truncated_normal_boundaries() -assert_seed_reproducibility() - -cat("MockData v0.4 simstudy spike passed.\n\n") -print(spike_result$spec_table) -cat("\nGenerated data preview:\n") -print(utils::head(spike_result$final)) -if (simstudy_available) { - cat("\nCorrelated height/weight correlation:\n") - print(stats::cor( - spike_result$correlated$data$height_cm, - spike_result$correlated$data$weight_kg - )) - cat("\nSurvival preview:\n") - print(utils::head(spike_result$survival[c("id", "exposed", "followup_days", "event", "entry_date", "event_date", "censor_date")])) -} -cat("\nNative fallback preview:\n") -print(utils::head(native_result$final)) -cat("\nMissing-code collision preview:\n") -print(utils::head(collision_result)) diff --git a/tests/testthat/test-mock-spec.R b/tests/testthat/test-mock-spec.R new file mode 100644 index 0000000..bc8de92 --- /dev/null +++ b/tests/testthat/test-mock-spec.R @@ -0,0 +1,143 @@ +test_that("mock_spec creates an empty specification", { + spec <- mock_spec() + + expect_s3_class(spec, "mock_spec") + expect_true(is_mock_spec(spec)) + expect_equal(spec$spec_version, "0.4.0") + expect_equal(length(spec$variables), 0) + expect_true(validate_mock_spec(spec)$valid) +}) + +test_that("mock_spec accepts NULL as an empty specification", { + spec <- mock_spec(NULL) + + expect_s3_class(spec, "mock_spec") + expect_equal(length(spec$variables), 0) + expect_true(validate_mock_spec(spec, n = 0)$valid) +}) + +test_that("mock_spec supports single continuous variable specs", { + age <- mock_spec_continuous( + name = "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "integer", + missing_codes = c(997, 998), + missing_proportions = c(0.02, 0.01) + ) + spec <- mock_spec(age) + + expect_s3_class(age, "mock_spec_variable") + expect_named(spec$variables, "age") + expect_equal(spec$variables$age$type, "continuous") + expect_equal(spec$variables$age$range, c(18, 85)) + expect_equal(spec$variables$age$provenance$adapter, "direct") + expect_true(validate_mock_spec(spec, n = 1)$valid) +}) + +test_that("mock_spec supports categorical variable specs", { + smoking <- mock_spec_categorical( + name = "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ) + spec <- mock_spec(list(smoking)) + + expect_named(spec$variables, "smoking") + expect_equal(spec$variables$smoking$type, "categorical") + expect_equal(spec$variables$smoking$levels, c("never", "former", "current")) + expect_equal(spec$variables$smoking$proportions, c(0.5, 0.3, 0.2)) + expect_true(validate_mock_spec(spec)$valid) +}) + +test_that("mock_spec supports date variable specs", { + interview_date <- mock_spec_date( + name = "interview_date", + range = as.Date(c("2001-01-01", "2005-12-31")), + source_format = "analysis" + ) + spec <- mock_spec(interview_date) + + expect_named(spec$variables, "interview_date") + expect_equal(spec$variables$interview_date$type, "date") + expect_s3_class(spec$variables$interview_date$range, "Date") + expect_equal(spec$variables$interview_date$model_hint, "native-postprocess") + expect_true(validate_mock_spec(spec, n = 0)$valid) +}) + +test_that("mock_spec validates n as a non-negative whole number", { + spec <- mock_spec() + + expect_true(validate_mock_spec(spec, n = 0)$valid) + expect_error(validate_mock_spec(spec, n = -1), "non-negative whole number") + expect_error(validate_mock_spec(spec, n = 1.5), "non-negative whole number") + expect_error(validate_mock_spec(spec, n = NA_real_), "non-negative whole number") +}) + +test_that("validate_mock_spec returns structured errors when strict is FALSE", { + spec <- mock_spec(mock_spec_categorical( + name = "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3) + )) + + result <- validate_mock_spec(spec, strict = FALSE) + + expect_false(result$valid) + expect_true(any(grepl("one proportion per level", result$errors))) +}) + +test_that("validate_mock_spec catches malformed continuous and date ranges", { + bad_continuous <- mock_spec(mock_spec_continuous( + name = "age", + range = c(85, 18) + )) + expect_error(validate_mock_spec(bad_continuous), "lower bound") + + bad_date <- mock_spec(mock_spec_date( + name = "interview_date", + range = c("2001-01-01", "2005-12-31") + )) + expect_error(validate_mock_spec(bad_date), "range must be Date") +}) + +test_that("validate_mock_spec catches normal distribution parameter errors", { + bad_normal <- mock_spec(mock_spec_continuous( + name = "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 0 + )) + + expect_error(validate_mock_spec(bad_normal), "sd > 0") +}) + +test_that("mock_spec rejects duplicate variable names", { + spec <- mock_spec( + mock_spec_continuous("age", range = c(18, 85)), + mock_spec_continuous("age", range = c(0, 100)) + ) + + result <- validate_mock_spec(spec, strict = FALSE) + + expect_false(result$valid) + expect_true(any(grepl("unique", result$errors))) +}) + +test_that("mock_spec validates model hints", { + expect_error( + mock_spec_continuous("age", range = c(18, 85), model_hint = "magic"), + "model_hint" + ) +}) + +test_that("validate_mock_spec rejects non-spec objects", { + result <- validate_mock_spec(list(), strict = FALSE) + + expect_false(result$valid) + expect_true(any(grepl("mock_spec", result$errors))) +}) From 75484a3f6a001b59559241d4adf9e8fd48f897cc Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 07:04:04 -0400 Subject: [PATCH 02/41] Address mock_spec milestone review --- NAMESPACE | 1 + NEWS.md | 12 ++++ R/mock_spec.R | 118 +++++++++++++++++++++++++++++++- tests/testthat/test-mock-spec.R | 66 ++++++++++++++++-- 4 files changed, 190 insertions(+), 7 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 33c9e9a..0c73a0a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +S3method(print,mock_spec_validation_result) S3method(print,mockdata_validation_result) export(add_garbage) export(apply_garbage) diff --git a/NEWS.md b/NEWS.md index 3c5a00a..b67598b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,15 @@ +# MockData 0.4.0 + +## Development + +- Started the v0.4 production refactor around a normalized `mock_spec` + architecture. +- Added `mock_spec()`, `mock_spec_continuous()`, `mock_spec_categorical()`, + `mock_spec_date()`, `is_mock_spec()`, and `validate_mock_spec()`. +- Added forward-compatible specification fields: `spec_version`, `provenance`, + and `model_hint`. +- Existing v0.3 generator APIs remain available while v0.4 internals are built. + # MockData 0.3.0 ## Breaking changes diff --git a/R/mock_spec.R b/R/mock_spec.R index 69b8a0a..96ed932 100644 --- a/R/mock_spec.R +++ b/R/mock_spec.R @@ -17,6 +17,29 @@ "diagnostic-required" ) +#' Model hints for MockData specifications +#' +#' Model hints are lightweight backend guidance carried by `mock_spec` objects +#' and variables. They are not generation commands; generation backends may use +#' them to choose a sensible default path. +#' +#' Supported values: +#' \describe{ +#' \item{`auto`}{Let MockData choose the backend.} +#' \item{`native`}{Prefer the native MockData backend.} +#' \item{`simstudy`}{Prefer the optional `simstudy` backend.} +#' \item{`native-postprocess`}{Generate baseline values natively, then rely +#' on MockData post-processing such as date/source-format conversion.} +#' \item{`simstudy-or-native`}{Either backend is expected to be suitable.} +#' \item{`simstudy-advanced`}{Feature is expected to need advanced `simstudy` +#' support, such as correlations or survival durations.} +#' \item{`diagnostic-required`}{Generation/post-processing must preserve +#' diagnostics needed to interpret the result.} +#' } +#' +#' @name mock_spec_model_hints +NULL + `%||%` <- function(x, y) { if (is.null(x)) y else x } @@ -135,16 +158,31 @@ #' @param provenance List or character describing where the spec came from. #' @param model_hint Character backend hint. One of the supported MockData model #' hints. +#' @param validate Logical. If `TRUE`, validate the constructed specification +#' before returning it. #' #' @return S3 object of class `mock_spec`. +#' +#' @examples +#' 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) +#' ) +#' ) +#' validate_mock_spec(spec) +#' #' @export mock_spec <- function(..., spec_version = .mock_spec_version, provenance = list(adapter = "direct", source = "direct"), - model_hint = "auto") { + model_hint = "auto", + validate = TRUE) { .validate_model_hint(model_hint) - structure( + spec <- structure( list( spec_version = spec_version, provenance = .normalize_provenance(provenance), @@ -153,6 +191,12 @@ mock_spec <- function(..., ), class = c("mock_spec", "list") ) + + if (isTRUE(validate)) { + validate_mock_spec(spec, strict = TRUE) + } + + spec } #' Create a continuous variable specification @@ -170,6 +214,17 @@ mock_spec <- function(..., #' @param model_hint Backend hint. #' #' @return A `mock_spec_variable` object. +#' +#' @examples +#' age <- mock_spec_continuous( +#' "age", +#' range = c(18, 85), +#' distribution = "normal", +#' mean = 50, +#' sd = 12, +#' rtype = "integer" +#' ) +#' #' @export mock_spec_continuous <- function(name, range, @@ -212,6 +267,15 @@ mock_spec_continuous <- function(name, #' @param model_hint Backend hint. #' #' @return A `mock_spec_variable` object. +#' +#' @examples +#' smoking <- mock_spec_categorical( +#' "smoking", +#' levels = c("never", "former", "current"), +#' proportions = c(0.5, 0.3, 0.2), +#' rtype = "character" +#' ) +#' #' @export mock_spec_categorical <- function(name, levels, @@ -251,6 +315,13 @@ mock_spec_categorical <- function(name, #' @param model_hint Backend hint. #' #' @return A `mock_spec_variable` object. +#' +#' @examples +#' interview_date <- mock_spec_date( +#' "interview_date", +#' range = as.Date(c("2001-01-01", "2005-12-31")) +#' ) +#' #' @export mock_spec_date <- function(name, range, @@ -281,6 +352,12 @@ mock_spec_date <- function(name, #' @param x Object to check. #' #' @return Logical scalar. +#' +#' @examples +#' spec <- mock_spec() +#' is_mock_spec(spec) +#' is_mock_spec(list()) +#' #' @export is_mock_spec <- function(x) { inherits(x, "mock_spec") @@ -301,6 +378,35 @@ is_mock_spec <- function(x) { ) } +#' @export +print.mock_spec_validation_result <- function(x, ...) { + status <- if (isTRUE(x$valid)) "valid" else "invalid" + cat("MockData mock_spec validation result: ", status, "\n", sep = "") + + if (length(x$errors) > 0) { + cat("\nErrors:\n") + for (i in seq_along(x$errors)) { + cat(i, ". ", x$errors[[i]], "\n", sep = "") + } + } + + if (length(x$warnings) > 0) { + cat("\nWarnings:\n") + for (i in seq_along(x$warnings)) { + cat(i, ". ", x$warnings[[i]], "\n", sep = "") + } + } + + if (length(x$info) > 0) { + cat("\nInfo:\n") + for (i in seq_along(x$info)) { + cat(i, ". ", x$info[[i]], "\n", sep = "") + } + } + + invisible(x) +} + .validate_probability_vector <- function(values, label, allow_null = FALSE) { errors <- character(0) @@ -435,6 +541,14 @@ is_mock_spec <- function(x) { #' a validation result object is returned. #' #' @return A `mock_spec_validation_result` object when valid or `strict = FALSE`. +#' +#' @examples +#' spec <- mock_spec(mock_spec_continuous("age", range = c(18, 85))) +#' validate_mock_spec(spec) +#' +#' result <- validate_mock_spec(list(), strict = FALSE) +#' result$valid +#' #' @export validate_mock_spec <- function(spec, n = NULL, strict = TRUE) { errors <- character(0) diff --git a/tests/testthat/test-mock-spec.R b/tests/testthat/test-mock-spec.R index bc8de92..edc3f41 100644 --- a/tests/testthat/test-mock-spec.R +++ b/tests/testthat/test-mock-spec.R @@ -82,7 +82,7 @@ test_that("validate_mock_spec returns structured errors when strict is FALSE", { name = "smoking", levels = c("never", "former", "current"), proportions = c(0.5, 0.3) - )) + ), validate = FALSE) result <- validate_mock_spec(spec, strict = FALSE) @@ -94,13 +94,13 @@ test_that("validate_mock_spec catches malformed continuous and date ranges", { bad_continuous <- mock_spec(mock_spec_continuous( name = "age", range = c(85, 18) - )) + ), validate = FALSE) expect_error(validate_mock_spec(bad_continuous), "lower bound") bad_date <- mock_spec(mock_spec_date( name = "interview_date", range = c("2001-01-01", "2005-12-31") - )) + ), validate = FALSE) expect_error(validate_mock_spec(bad_date), "range must be Date") }) @@ -111,7 +111,7 @@ test_that("validate_mock_spec catches normal distribution parameter errors", { distribution = "normal", mean = 50, sd = 0 - )) + ), validate = FALSE) expect_error(validate_mock_spec(bad_normal), "sd > 0") }) @@ -119,7 +119,8 @@ test_that("validate_mock_spec catches normal distribution parameter errors", { test_that("mock_spec rejects duplicate variable names", { spec <- mock_spec( mock_spec_continuous("age", range = c(18, 85)), - mock_spec_continuous("age", range = c(0, 100)) + mock_spec_continuous("age", range = c(0, 100)), + validate = FALSE ) result <- validate_mock_spec(spec, strict = FALSE) @@ -141,3 +142,58 @@ test_that("validate_mock_spec rejects non-spec objects", { expect_false(result$valid) expect_true(any(grepl("mock_spec", result$errors))) }) + +test_that("mock_spec validates on construction by default", { + expect_error( + mock_spec(mock_spec_categorical( + name = "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3) + )), + "one proportion per level" + ) +}) + +test_that("validate_mock_spec accumulates multiple errors", { + spec <- mock_spec( + mock_spec_categorical( + name = "smoking", + levels = character(0), + proportions = c(0.5, 0.5) + ), + validate = FALSE + ) + + result <- validate_mock_spec(spec, strict = FALSE) + + expect_false(result$valid) + expect_true(length(result$errors) >= 2) + expect_true(any(grepl("at least one level", result$errors))) + expect_true(any(grepl("one proportion per level", result$errors))) +}) + +test_that("is_mock_spec returns FALSE for non-spec objects", { + expect_false(is_mock_spec(list())) + expect_false(is_mock_spec(NULL)) + expect_false(is_mock_spec(data.frame())) +}) + +test_that("mock_spec preserves spec_version and rejects missing spec_version", { + spec <- mock_spec(spec_version = "0.4.0-test") + + expect_equal(spec$spec_version, "0.4.0-test") + + spec$spec_version <- NA_character_ + result <- validate_mock_spec(spec, strict = FALSE) + + expect_false(result$valid) + expect_true(any(grepl("spec_version", result$errors))) +}) + +test_that("print.mock_spec_validation_result summarizes errors", { + result <- validate_mock_spec(list(), strict = FALSE) + + expect_output(print(result), "invalid") + expect_output(print(result), "Errors") + expect_output(print(result), "spec must be a mock_spec object") +}) From 33d7aee57323aa4d416b3bff28c1de50fe3fa95e Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 07:11:08 -0400 Subject: [PATCH 03/41] Add direct mock specification helpers --- NAMESPACE | 3 + NEWS.md | 3 + R/mock_spec.R | 191 ++++++++++++++++++++++++++ tests/testthat/test-direct-mock-api.R | 81 +++++++++++ 4 files changed, 278 insertions(+) create mode 100644 tests/testthat/test-direct-mock-api.R diff --git a/NAMESPACE b/NAMESPACE index 0c73a0a..cb0f2e3 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -26,6 +26,9 @@ export(identify_derived_vars) export(import_from_recodeflow) export(is_mock_spec) export(make_garbage) +export(mock_categorical) +export(mock_continuous) +export(mock_date) export(mock_spec) export(mock_spec_categorical) export(mock_spec_continuous) diff --git a/NEWS.md b/NEWS.md index b67598b..80afcd5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,9 @@ architecture. - Added `mock_spec()`, `mock_spec_continuous()`, `mock_spec_categorical()`, `mock_spec_date()`, `is_mock_spec()`, and `validate_mock_spec()`. +- Added direct specification helpers `mock_continuous()`, + `mock_categorical()`, and `mock_date()` for simple use without + recodeflow-style metadata tables. - Added forward-compatible specification fields: `spec_version`, `provenance`, and `model_hint`. - Existing v0.3 generator APIs remain available while v0.4 internals are built. diff --git a/R/mock_spec.R b/R/mock_spec.R index 96ed932..928a5a2 100644 --- a/R/mock_spec.R +++ b/R/mock_spec.R @@ -146,6 +146,13 @@ NULL variables } +.direct_api_provenance <- function(source, provenance = NULL) { + .normalize_provenance( + provenance %||% list(adapter = "direct", source = source), + source = source + ) +} + #' Create a MockData specification #' #' `mock_spec()` creates the normalized v0.4 specification object used by the @@ -199,6 +206,190 @@ mock_spec <- function(..., spec } +#' Create a direct continuous mock-data specification +#' +#' `mock_continuous()` is the simple direct API for continuous variables. It +#' returns a validated `mock_spec`; it does not generate data. Generation +#' backends will consume this specification in a later v0.4 milestone. +#' +#' @param name Variable 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. Required when +#' `distribution = "normal"`. +#' @param rtype R output type. 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 continuous variable. +#' +#' @examples +#' age_spec <- mock_continuous( +#' "age", +#' range = c(18, 85), +#' distribution = "normal", +#' mean = 50, +#' sd = 12, +#' rtype = "integer" +#' ) +#' validate_mock_spec(age_spec) +#' +#' @export +mock_continuous <- function(name, + range, + distribution = "uniform", + mean = NA_real_, + sd = NA_real_, + 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_continuous", provenance) + + mock_spec( + mock_spec_continuous( + name = name, + range = range, + distribution = distribution, + mean = mean, + sd = sd, + 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 direct categorical mock-data specification +#' +#' `mock_categorical()` is the simple direct API for categorical variables. It +#' returns a validated `mock_spec`; it does not generate data. +#' +#' @param name Variable name. +#' @param levels Character vector of valid levels or codes. +#' @param proportions Optional probabilities aligned to `levels`. +#' @param rtype R output type. Defaults to `"factor"`. +#' @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 categorical variable. +#' +#' @examples +#' smoking_spec <- mock_categorical( +#' "smoking", +#' levels = c("never", "former", "current"), +#' proportions = c(0.5, 0.3, 0.2), +#' rtype = "character" +#' ) +#' validate_mock_spec(smoking_spec) +#' +#' @export +mock_categorical <- function(name, + levels, + proportions = NULL, + rtype = "factor", + missing_codes = character(0), + missing_proportions = numeric(0), + garbage_rules = list(), + provenance = NULL, + model_hint = "auto", + spec_version = .mock_spec_version) { + provenance <- .direct_api_provenance("mock_categorical", provenance) + + mock_spec( + mock_spec_categorical( + name = name, + levels = levels, + proportions = proportions, + 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 direct date mock-data specification +#' +#' `mock_date()` is the simple direct API for date variables. It returns a +#' validated `mock_spec`; it does not generate data. +#' +#' @param name Variable name. +#' @param range Date vector of length two giving the inclusive valid date range. +#' @param rtype R output type. Defaults to `"date"`. +#' @param source_format Source-format hint. Defaults to `"analysis"`. +#' @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 date variable. +#' +#' @examples +#' interview_date_spec <- mock_date( +#' "interview_date", +#' range = as.Date(c("2001-01-01", "2005-12-31")) +#' ) +#' validate_mock_spec(interview_date_spec) +#' +#' @export +mock_date <- function(name, + range, + rtype = "date", + source_format = "analysis", + missing_codes = character(0), + missing_proportions = numeric(0), + garbage_rules = list(), + provenance = NULL, + model_hint = "native-postprocess", + spec_version = .mock_spec_version) { + provenance <- .direct_api_provenance("mock_date", provenance) + + mock_spec( + mock_spec_date( + name = name, + range = range, + rtype = rtype, + source_format = source_format, + 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. diff --git a/tests/testthat/test-direct-mock-api.R b/tests/testthat/test-direct-mock-api.R new file mode 100644 index 0000000..2248282 --- /dev/null +++ b/tests/testthat/test-direct-mock-api.R @@ -0,0 +1,81 @@ +test_that("mock_continuous creates a validated one-variable spec", { + spec <- mock_continuous( + "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "integer", + missing_codes = c(997, 998), + missing_proportions = c(0.02, 0.01) + ) + + expect_s3_class(spec, "mock_spec") + expect_named(spec$variables, "age") + expect_equal(spec$variables$age$type, "continuous") + expect_equal(spec$variables$age$rtype, "integer") + expect_equal(spec$provenance$adapter, "direct") + expect_equal(spec$provenance$source, "mock_continuous") + expect_true(validate_mock_spec(spec)$valid) +}) + +test_that("mock_categorical creates a validated one-variable spec", { + spec <- mock_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ) + + expect_s3_class(spec, "mock_spec") + expect_named(spec$variables, "smoking") + expect_equal(spec$variables$smoking$type, "categorical") + expect_equal(spec$variables$smoking$levels, c("never", "former", "current")) + expect_equal(spec$variables$smoking$proportions, c(0.5, 0.3, 0.2)) + expect_equal(spec$provenance$source, "mock_categorical") + expect_true(validate_mock_spec(spec)$valid) +}) + +test_that("mock_date creates a validated one-variable spec", { + spec <- mock_date( + "interview_date", + range = as.Date(c("2001-01-01", "2005-12-31")) + ) + + expect_s3_class(spec, "mock_spec") + expect_named(spec$variables, "interview_date") + expect_equal(spec$variables$interview_date$type, "date") + expect_s3_class(spec$variables$interview_date$range, "Date") + expect_equal(spec$model_hint, "native-postprocess") + expect_equal(spec$provenance$source, "mock_date") + expect_true(validate_mock_spec(spec)$valid) +}) + +test_that("direct mock APIs validate immediately", { + expect_error( + mock_continuous( + "age", + range = c(18, 85), + distribution = "normal", + mean = 50 + ), + "sd > 0" + ) + + expect_error( + mock_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3) + ), + "one proportion per level" + ) + + expect_error( + mock_date( + "interview_date", + range = c("2001-01-01", "2005-12-31") + ), + "range must be Date" + ) +}) From f29414defb3fd815b47e92dd6ac7b40cfbe45648 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 09:24:35 -0400 Subject: [PATCH 04/41] Tighten mock_spec auditability contracts --- R/mock_spec.R | 99 +++++++++++++++++++++++---- development/adr/v04-hybrid-backend.md | 7 +- tests/testthat/test-direct-mock-api.R | 73 ++++++++++++++++++++ tests/testthat/test-mock-spec.R | 53 ++++++++++++++ 4 files changed, 219 insertions(+), 13 deletions(-) diff --git a/R/mock_spec.R b/R/mock_spec.R index 928a5a2..7fd3e51 100644 --- a/R/mock_spec.R +++ b/R/mock_spec.R @@ -7,6 +7,8 @@ .mock_spec_version <- "0.4.0" +.mock_spec_probability_tolerance <- 1e-8 + .mock_spec_model_hints <- c( "auto", "native", @@ -44,6 +46,10 @@ NULL if (is.null(x)) y else x } +.is_non_empty_string <- function(x) { + is.character(x) && length(x) == 1 && !is.na(x) && nzchar(trimws(x)) +} + .normalize_provenance <- function(provenance, source = NULL) { if (is.null(provenance)) { provenance <- list(adapter = "direct", source = source %||% "direct") @@ -51,14 +57,22 @@ NULL provenance <- list(adapter = as.character(provenance), source = source %||% as.character(provenance)) } - if (is.null(provenance$adapter) || is.na(provenance$adapter) || provenance$adapter == "") { - provenance$adapter <- "unknown" + if (!.is_non_empty_string(provenance$adapter)) { + stop("provenance$adapter must be a non-empty string.", call. = FALSE) } - if (is.null(provenance$source) || is.na(provenance$source) || provenance$source == "") { + if (is.null(provenance$source) && !is.null(source)) { + provenance$source <- source + } else if (is.null(provenance$source)) { provenance$source <- provenance$adapter } + if (!.is_non_empty_string(provenance$source)) { + stop("provenance$source must be a non-empty string.", call. = FALSE) + } - provenance + c( + list(adapter = provenance$adapter, source = provenance$source), + provenance[setdiff(names(provenance), c("adapter", "source"))] + ) } .validate_model_hint <- function(model_hint) { @@ -147,10 +161,15 @@ NULL } .direct_api_provenance <- function(source, provenance = NULL) { - .normalize_provenance( - provenance %||% list(adapter = "direct", source = source), - source = source - ) + provenance <- provenance %||% list(source = source) + + if (!is.list(provenance)) { + provenance <- list(source = as.character(provenance)) + } + provenance$adapter <- "direct" + provenance$source <- provenance$source %||% source + + .normalize_provenance(provenance, source = source) } #' Create a MockData specification @@ -169,6 +188,8 @@ NULL #' before returning it. #' #' @return S3 object of class `mock_spec`. +#' @family mock specification APIs +#' @seealso [mock_continuous()], [mock_categorical()], [mock_date()] #' #' @examples #' spec <- mock_spec( @@ -227,6 +248,8 @@ mock_spec <- function(..., #' @param spec_version Character version of the specification shape. #' #' @return A validated `mock_spec` object containing one continuous variable. +#' @family direct specification APIs +#' @seealso [mock_spec()], [mock_spec_continuous()] #' #' @examples #' age_spec <- mock_continuous( @@ -292,6 +315,8 @@ mock_continuous <- function(name, #' @param spec_version Character version of the specification shape. #' #' @return A validated `mock_spec` object containing one categorical variable. +#' @family direct specification APIs +#' @seealso [mock_spec()], [mock_spec_categorical()] #' #' @examples #' smoking_spec <- mock_categorical( @@ -351,6 +376,8 @@ mock_categorical <- function(name, #' @param spec_version Character version of the specification shape. #' #' @return A validated `mock_spec` object containing one date variable. +#' @family direct specification APIs +#' @seealso [mock_spec()], [mock_spec_date()] #' #' @examples #' interview_date_spec <- mock_date( @@ -405,6 +432,8 @@ mock_date <- function(name, #' @param model_hint Backend hint. #' #' @return A `mock_spec_variable` object. +#' @family mock specification APIs +#' @seealso [mock_spec()], [mock_continuous()] #' #' @examples #' age <- mock_spec_continuous( @@ -458,6 +487,8 @@ mock_spec_continuous <- function(name, #' @param model_hint Backend hint. #' #' @return A `mock_spec_variable` object. +#' @family mock specification APIs +#' @seealso [mock_spec()], [mock_categorical()] #' #' @examples #' smoking <- mock_spec_categorical( @@ -506,6 +537,8 @@ mock_spec_categorical <- function(name, #' @param model_hint Backend hint. #' #' @return A `mock_spec_variable` object. +#' @family mock specification APIs +#' @seealso [mock_spec()], [mock_date()] #' #' @examples #' interview_date <- mock_spec_date( @@ -622,6 +655,22 @@ print.mock_spec_validation_result <- function(x, ...) { errors } +.validate_provenance <- function(provenance, label) { + errors <- character(0) + + if (!is.list(provenance)) { + return(paste0(label, " provenance must be a list.")) + } + if (!.is_non_empty_string(provenance$adapter)) { + errors <- c(errors, paste0(label, " provenance$adapter must be a non-empty string.")) + } + if (!.is_non_empty_string(provenance$source)) { + errors <- c(errors, paste0(label, " provenance$source must be a non-empty string.")) + } + + errors +} + .validate_missing_spec <- function(variable) { errors <- character(0) @@ -643,7 +692,7 @@ print.mock_spec_validation_result <- function(x, ...) { )) missing_sum <- sum(variable$missing_proportions, na.rm = TRUE) - if (missing_sum > 1) { + if (missing_sum > 1 + .mock_spec_probability_tolerance) { errors <- c(errors, paste0( "Variable '", variable$name, "' missing proportions must sum to <= 1." @@ -685,6 +734,21 @@ print.mock_spec_validation_result <- function(x, ...) { } errors <- c(errors, .validate_missing_spec(variable)) + errors <- c(errors, .validate_provenance( + variable$provenance, + paste0("Variable '", variable$name, "'") + )) + if (is.null(variable$model_hint) || + length(variable$model_hint) != 1 || + is.na(variable$model_hint) || + !variable$model_hint %in% .mock_spec_model_hints) { + errors <- c(errors, paste0( + "Variable '", variable$name, + "' model_hint must be one of: ", + paste(.mock_spec_model_hints, collapse = ", "), + "." + )) + } if (variable$type == "continuous") { errors <- c(errors, .validate_range(variable$range, variable$name, "numeric")) @@ -710,7 +774,7 @@ print.mock_spec_validation_result <- function(x, ...) { allow_null = FALSE )) prop_sum <- sum(variable$proportions, na.rm = TRUE) - if (abs(prop_sum - 1) > 0.001) { + if (abs(prop_sum - 1) > .mock_spec_probability_tolerance) { errors <- c(errors, paste0("Variable '", variable$name, "' proportions must sum to 1.")) } } @@ -749,8 +813,19 @@ validate_mock_spec <- function(spec, n = NULL, strict = TRUE) { if (!is_mock_spec(spec)) { errors <- c(errors, "spec must be a mock_spec object.") } else { - if (is.null(spec$spec_version) || length(spec$spec_version) != 1 || is.na(spec$spec_version)) { - errors <- c(errors, "mock_spec must have a scalar spec_version.") + if (!.is_non_empty_string(spec$spec_version)) { + errors <- c(errors, "mock_spec must have a non-empty scalar spec_version.") + } + errors <- c(errors, .validate_provenance(spec$provenance, "mock_spec")) + if (is.null(spec$model_hint) || + length(spec$model_hint) != 1 || + is.na(spec$model_hint) || + !spec$model_hint %in% .mock_spec_model_hints) { + errors <- c(errors, paste0( + "mock_spec model_hint must be one of: ", + paste(.mock_spec_model_hints, collapse = ", "), + "." + )) } if (is.null(spec$variables) || !is.list(spec$variables)) { errors <- c(errors, "mock_spec variables must be a list.") diff --git a/development/adr/v04-hybrid-backend.md b/development/adr/v04-hybrid-backend.md index eca4420..63fc032 100644 --- a/development/adr/v04-hybrid-backend.md +++ b/development/adr/v04-hybrid-backend.md @@ -112,4 +112,9 @@ Production refactor should proceed in layers: - Whether `mock_spec` is internal-only or partially user-facing in v0.4.0. - How formula/dependency syntax enters from recodeflow or direct APIs. - How Table 1 / summary specifications become a future adapter. - +- How the legacy `var_row` shim used by v0.3 garbage helpers is replaced with + typed v0.4 post-processing specs. +- Empty, `NULL`, `n = 0`, and single-row input behavior across adapters and + backends. +- Seed discipline across native generation, post-processing, and the optional + `simstudy` backend. diff --git a/tests/testthat/test-direct-mock-api.R b/tests/testthat/test-direct-mock-api.R index 2248282..d66c087 100644 --- a/tests/testthat/test-direct-mock-api.R +++ b/tests/testthat/test-direct-mock-api.R @@ -16,6 +16,8 @@ test_that("mock_continuous creates a validated one-variable spec", { expect_equal(spec$variables$age$rtype, "integer") expect_equal(spec$provenance$adapter, "direct") expect_equal(spec$provenance$source, "mock_continuous") + expect_equal(spec$variables$age$provenance$adapter, "direct") + expect_equal(spec$variables$age$provenance$source, "mock_continuous") expect_true(validate_mock_spec(spec)$valid) }) @@ -32,7 +34,10 @@ test_that("mock_categorical creates a validated one-variable spec", { expect_equal(spec$variables$smoking$type, "categorical") expect_equal(spec$variables$smoking$levels, c("never", "former", "current")) expect_equal(spec$variables$smoking$proportions, c(0.5, 0.3, 0.2)) + expect_equal(spec$provenance$adapter, "direct") expect_equal(spec$provenance$source, "mock_categorical") + expect_equal(spec$variables$smoking$provenance$adapter, "direct") + expect_equal(spec$variables$smoking$provenance$source, "mock_categorical") expect_true(validate_mock_spec(spec)$valid) }) @@ -47,10 +52,78 @@ test_that("mock_date creates a validated one-variable spec", { expect_equal(spec$variables$interview_date$type, "date") expect_s3_class(spec$variables$interview_date$range, "Date") expect_equal(spec$model_hint, "native-postprocess") + expect_equal(spec$variables$interview_date$model_hint, "native-postprocess") + expect_equal(spec$provenance$adapter, "direct") expect_equal(spec$provenance$source, "mock_date") + expect_equal(spec$variables$interview_date$provenance$adapter, "direct") + expect_equal(spec$variables$interview_date$provenance$source, "mock_date") expect_true(validate_mock_spec(spec)$valid) }) +test_that("direct mock APIs are equivalent to explicit mock_spec wrappers", { + continuous_provenance <- list(adapter = "direct", source = "mock_continuous") + expect_equal( + mock_continuous("age", range = c(18, 85), rtype = "integer"), + mock_spec( + mock_spec_continuous( + "age", + range = c(18, 85), + rtype = "integer", + provenance = continuous_provenance + ), + provenance = continuous_provenance + ) + ) + + categorical_provenance <- list(adapter = "direct", source = "mock_categorical") + expect_equal( + mock_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2) + ), + mock_spec( + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + provenance = categorical_provenance + ), + provenance = categorical_provenance + ) + ) + + date_provenance <- list(adapter = "direct", source = "mock_date") + expect_equal( + mock_date( + "interview_date", + range = as.Date(c("2001-01-01", "2005-12-31")) + ), + mock_spec( + mock_spec_date( + "interview_date", + range = as.Date(c("2001-01-01", "2005-12-31")), + provenance = date_provenance + ), + provenance = date_provenance, + model_hint = "native-postprocess" + ) + ) +}) + +test_that("direct mock APIs keep adapter provenance fixed as direct", { + spec <- mock_continuous( + "age", + range = c(18, 85), + provenance = list(adapter = "not-direct", source = "custom-note") + ) + + expect_equal(spec$provenance$adapter, "direct") + expect_equal(spec$provenance$source, "custom-note") + expect_equal(spec$variables$age$provenance$adapter, "direct") + expect_equal(spec$variables$age$provenance$source, "custom-note") +}) + test_that("direct mock APIs validate immediately", { expect_error( mock_continuous( diff --git a/tests/testthat/test-mock-spec.R b/tests/testthat/test-mock-spec.R index edc3f41..7d748ef 100644 --- a/tests/testthat/test-mock-spec.R +++ b/tests/testthat/test-mock-spec.R @@ -188,6 +188,59 @@ test_that("mock_spec preserves spec_version and rejects missing spec_version", { expect_false(result$valid) expect_true(any(grepl("spec_version", result$errors))) + + spec$spec_version <- "" + result <- validate_mock_spec(spec, strict = FALSE) + + expect_false(result$valid) + expect_true(any(grepl("spec_version", result$errors))) +}) + +test_that("validate_mock_spec checks provenance and model_hint after mutation", { + spec <- mock_spec(mock_spec_continuous("age", range = c(18, 85))) + + spec$provenance <- "not-a-list" + result <- validate_mock_spec(spec, strict = FALSE) + expect_false(result$valid) + expect_true(any(grepl("provenance must be a list", result$errors))) + + spec <- mock_spec(mock_spec_continuous("age", range = c(18, 85))) + spec$provenance$adapter <- "" + result <- validate_mock_spec(spec, strict = FALSE) + expect_false(result$valid) + expect_true(any(grepl("provenance\\$adapter", result$errors))) + + spec <- mock_spec(mock_spec_continuous("age", range = c(18, 85))) + spec$provenance$source <- NA_character_ + result <- validate_mock_spec(spec, strict = FALSE) + expect_false(result$valid) + expect_true(any(grepl("provenance\\$source", result$errors))) + + spec <- mock_spec(mock_spec_continuous("age", range = c(18, 85))) + spec$model_hint <- "magic" + result <- validate_mock_spec(spec, strict = FALSE) + expect_false(result$valid) + expect_true(any(grepl("mock_spec model_hint", result$errors))) + + spec <- mock_spec(mock_spec_continuous("age", range = c(18, 85))) + spec$variables$age$provenance$adapter <- "" + spec$variables$age$model_hint <- "magic" + result <- validate_mock_spec(spec, strict = FALSE) + expect_false(result$valid) + expect_true(any(grepl("Variable 'age' provenance\\$adapter", result$errors))) + expect_true(any(grepl("Variable 'age' model_hint", result$errors))) +}) + +test_that("proportion sums use floating-point tolerance consistently", { + spec <- mock_spec(mock_spec_categorical( + "smoking", + levels = c("never", "former"), + proportions = c(0.5, 0.5 + 1e-15), + missing_codes = c("7", "9"), + missing_proportions = c(0.5, 0.5 + 1e-15) + )) + + expect_true(validate_mock_spec(spec)$valid) }) test_that("print.mock_spec_validation_result summarizes errors", { From bd7b389272b40fe5f6890ef85d13f833a2ec5684 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 09:32:13 -0400 Subject: [PATCH 05/41] Add recodeflow mock_spec adapter --- NAMESPACE | 1 + NEWS.md | 2 + R/mock_spec_recodeflow.R | 416 +++++++++++++++++++++ tests/testthat/test-recodeflow-mock-spec.R | 112 ++++++ 4 files changed, 531 insertions(+) create mode 100644 R/mock_spec_recodeflow.R create mode 100644 tests/testthat/test-recodeflow-mock-spec.R diff --git a/NAMESPACE b/NAMESPACE index cb0f2e3..b1b6f71 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -33,6 +33,7 @@ export(mock_spec) export(mock_spec_categorical) export(mock_spec_continuous) export(mock_spec_date) +export(mock_spec_from_recodeflow) export(parse_range_notation) export(parse_variable_start) export(read_mock_data_config) diff --git a/NEWS.md b/NEWS.md index 80afcd5..9b9d9c5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -9,6 +9,8 @@ - Added direct specification helpers `mock_continuous()`, `mock_categorical()`, and `mock_date()` for simple use without recodeflow-style metadata tables. +- Added `mock_spec_from_recodeflow()` to adapt recodeflow-style `variables` + and `variable_details` metadata into validated `mock_spec` objects. - Added forward-compatible specification fields: `spec_version`, `provenance`, and `model_hint`. - Existing v0.3 generator APIs remain available while v0.4 internals are built. diff --git a/R/mock_spec_recodeflow.R b/R/mock_spec_recodeflow.R new file mode 100644 index 0000000..0274ce5 --- /dev/null +++ b/R/mock_spec_recodeflow.R @@ -0,0 +1,416 @@ +# ============================================================================== +# MockData v0.4 Recodeflow Adapter +# ============================================================================== +# Converts recodeflow-style variables and variable_details metadata into the +# normalized mock_spec representation. +# ============================================================================== + +.read_recodeflow_table <- function(x, label) { + if (is.data.frame(x)) { + return(x) + } + + if (is.character(x) && length(x) == 1) { + if (!file.exists(x)) { + stop(label, " file does not exist: ", x, call. = FALSE) + } + return(read.csv(x, stringsAsFactors = FALSE, check.names = FALSE)) + } + + stop(label, " must be a data frame or a single CSV path.", call. = FALSE) +} + +.is_blank <- function(x) { + is.null(x) || length(x) == 0 || is.na(x[1]) || trimws(as.character(x[1])) == "" +} + +.row_value <- function(row, name, default = NA) { + if (!name %in% names(row)) { + return(default) + } + + value <- row[[name]][1] + if (length(value) == 0) { + return(default) + } + + value +} + +.row_character <- function(row, name, default = NA_character_) { + value <- .row_value(row, name, default) + if (.is_blank(value)) { + return(default) + } + as.character(value) +} + +.row_numeric <- function(row, name, default = NA_real_) { + value <- .row_value(row, name, default) + if (.is_blank(value)) { + return(default) + } + suppressWarnings(as.numeric(value)) +} + +.recodeflow_required_columns <- function(data, required, label) { + missing <- setdiff(required, names(data)) + if (length(missing) > 0) { + stop(label, " is missing required column(s): ", paste(missing, collapse = ", "), call. = FALSE) + } +} + +.filter_recodeflow_by_database <- function(data, databaseStart, allow_empty = TRUE) { + if (is.null(databaseStart) || !"databaseStart" %in% names(data)) { + return(data) + } + + data[.database_start_matches(data$databaseStart, databaseStart, allow_empty = allow_empty), , drop = FALSE] +} + +.filter_recodeflow_details <- function(variable_details, variable, databaseStart) { + if (is.null(variable_details)) { + return(NULL) + } + + details <- variable_details[variable_details$variable == variable, , drop = FALSE] + .filter_recodeflow_by_database(details, databaseStart, allow_empty = TRUE) +} + +.recodeflow_variable_kind <- function(var_row) { + rtype <- tolower(.row_character(var_row, "rType", "")) + variable_type <- tolower(.row_character(var_row, "variableType", "")) + + if (rtype == "date" || variable_type == "date") { + return("date") + } + if (variable_type == "categorical" || rtype %in% c("factor", "character", "logical")) { + return("categorical") + } + if (variable_type == "continuous" || rtype %in% c("integer", "double", "numeric")) { + return("continuous") + } + + stop( + "Variable '", .row_character(var_row, "variable", ""), + "' has unsupported variableType/rType combination: variableType = '", + variable_type, "', rType = '", rtype, "'.", + call. = FALSE + ) +} + +.recodeflow_rtype <- function(var_row, kind) { + rtype <- tolower(.row_character(var_row, "rType", "")) + if (rtype != "") { + if (rtype == "numeric") { + return("double") + } + return(rtype) + } + + switch( + kind, + continuous = "double", + categorical = "factor", + date = "date" + ) +} + +.parse_single_date <- function(value) { + if (.is_blank(value)) { + return(NULL) + } + + parsed <- parse_range_notation(paste0("[", value, ",", value, "]")) + if (!is.null(parsed) && identical(parsed$type, "date")) { + return(c(parsed$min, parsed$max)) + } + + NULL +} + +.recodeflow_valid_rows <- function(details) { + if (is.null(details) || nrow(details) == 0) { + return(details) + } + + rec_start <- as.character(details$recStart) + rec_end <- if ("recEnd" %in% names(details)) as.character(details$recEnd) else rep("", nrow(details)) + + keep <- !is.na(rec_start) & + rec_start != "" & + rec_start != "else" & + !grepl("^garbage_", rec_start, ignore.case = TRUE) & + !grepl("^NA::", rec_end) & + !grepl("^DerivedVar::", rec_start) & + !grepl("^Func::", rec_end) + + details[keep, , drop = FALSE] +} + +.recodeflow_range <- function(details, variable, kind) { + valid_rows <- .recodeflow_valid_rows(details) + if (is.null(valid_rows) || nrow(valid_rows) == 0) { + stop("Variable '", variable, "' has no valid recodeflow detail rows for range extraction.", call. = FALSE) + } + + for (i in seq_len(nrow(valid_rows))) { + rec_start <- valid_rows$recStart[i] + parsed <- parse_range_notation(rec_start) + + if (kind == "date") { + if (!is.null(parsed) && identical(parsed$type, "date")) { + return(c(parsed$min, parsed$max)) + } + + single_date <- .parse_single_date(rec_start) + if (!is.null(single_date)) { + return(single_date) + } + } else { + if (!is.null(parsed) && parsed$type %in% c("integer", "continuous", "single_value")) { + return(c(parsed$min, parsed$max)) + } + } + } + + stop("Variable '", variable, "' has no parseable ", kind, " range in recStart.", call. = FALSE) +} + +.recodeflow_missing <- function(details) { + if (is.null(details) || nrow(details) == 0 || !"recEnd" %in% names(details)) { + return(list(codes = character(0), proportions = numeric(0))) + } + + is_missing <- grepl("^NA::", details$recEnd) & + !is.na(details$recStart) & + details$recStart != "" & + details$recStart != "else" + + missing_rows <- details[is_missing, , drop = FALSE] + if (nrow(missing_rows) == 0) { + return(list(codes = character(0), proportions = numeric(0))) + } + + 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) + ) +} + +.recodeflow_distribution <- function(var_row, details) { + distribution <- tolower(.row_character(var_row, "distribution", "")) + if (distribution != "") { + return(distribution) + } + + params <- tryCatch( + extract_distribution_params(details), + error = function(e) list(distribution = "uniform") + ) + params$distribution %||% "uniform" +} + +.recodeflow_garbage_rules <- function(var_row) { + rules <- list() + + low_prop <- .row_numeric(var_row, "garbage_low_prop") + low_range <- .row_character(var_row, "garbage_low_range", "") + if (low_range == "[;]") { + low_range <- "" + } + if ((!is.na(low_prop) && low_prop > 0) || low_range != "") { + rules$low <- list(proportion = low_prop, range = low_range) + } + + high_prop <- .row_numeric(var_row, "garbage_high_prop") + high_range <- .row_character(var_row, "garbage_high_range", "") + if (high_range == "[;]") { + high_range <- "" + } + if ((!is.na(high_prop) && high_prop > 0) || high_range != "") { + rules$high <- list(proportion = high_prop, range = high_range) + } + + rules +} + +.recodeflow_provenance <- function(variable, databaseStart = NULL) { + provenance <- list(adapter = "recodeflow", source = variable) + if (!is.null(databaseStart)) { + provenance$databaseStart <- paste(databaseStart, collapse = ",") + } + provenance +} + +.recodeflow_to_spec_variable <- function(var_row, details, databaseStart) { + variable <- .row_character(var_row, "variable") + kind <- .recodeflow_variable_kind(var_row) + rtype <- .recodeflow_rtype(var_row, kind) + provenance <- .recodeflow_provenance(variable, databaseStart) + missing <- .recodeflow_missing(details) + garbage_rules <- .recodeflow_garbage_rules(var_row) + + if (kind == "categorical") { + proportions <- extract_proportions(details, variable_name = variable) + if (length(proportions$categories) == 0) { + stop("Variable '", variable, "' has no valid categorical levels.", call. = FALSE) + } + + 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)), + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = "native" + )) + } + + if (kind == "continuous") { + distribution <- .recodeflow_distribution(var_row, details) + + return(mock_spec_continuous( + name = variable, + range = .recodeflow_range(details, variable, "continuous"), + distribution = distribution, + mean = .row_numeric(var_row, "mean"), + sd = .row_numeric(var_row, "sd"), + rtype = rtype, + missing_codes = missing$codes, + missing_proportions = missing$proportions, + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = "native" + )) + } + + source_format <- .row_character(var_row, "sourceFormat", "analysis") + + .new_mock_spec_variable( + name = variable, + type = "date", + rtype = rtype, + distribution = .recodeflow_distribution(var_row, details), + range = .recodeflow_range(details, variable, "date"), + source_format = source_format, + missing_codes = missing$codes, + missing_proportions = missing$proportions, + garbage_rules = garbage_rules, + provenance = provenance, + model_hint = "native-postprocess", + rate = .row_numeric(var_row, "rate"), + shape = .row_numeric(var_row, "shape"), + followup_min = .row_numeric(var_row, "followup_min"), + followup_max = .row_numeric(var_row, "followup_max"), + event_prop = .row_numeric(var_row, "event_prop") + ) +} + +#' Convert recodeflow metadata to a MockData specification +#' +#' `mock_spec_from_recodeflow()` adapts recodeflow-style `variables` and +#' `variable_details` metadata into the normalized v0.4 `mock_spec` shape. It +#' returns a validated specification; it does not generate data. +#' +#' @param variables Data frame or CSV path for recodeflow-style `variables` +#' metadata. +#' @param variable_details Data frame, CSV path, or `NULL` for recodeflow-style +#' `variable_details` metadata. +#' @param databaseStart Optional database/cycle token used to filter metadata by +#' exact comma-separated `databaseStart` values. +#' @param role Character vector of role tokens to include. Defaults to +#' `"enabled"`. Use `NULL` to skip role filtering. +#' @param exclude_derived Logical. If `TRUE`, exclude variables identified by +#' `DerivedVar::` or `Func::` rows in `variable_details`. +#' @param spec_version Character version of the specification shape. +#' @param model_hint Backend hint for the returned specification. +#' +#' @return A validated `mock_spec` object. +#' @family mock specification APIs +#' @seealso [mock_spec()], [mock_continuous()], [mock_categorical()], +#' [mock_date()] +#' +#' @examples +#' variables <- data.frame( +#' variable = "age", +#' variableType = "Continuous", +#' rType = "integer", +#' role = "enabled", +#' distribution = "uniform" +#' ) +#' details <- data.frame( +#' variable = "age", +#' recStart = "[18, 85]", +#' recEnd = "copy", +#' proportion = 1 +#' ) +#' spec <- mock_spec_from_recodeflow(variables, details) +#' validate_mock_spec(spec) +#' +#' @export +mock_spec_from_recodeflow <- function(variables, + variable_details = NULL, + databaseStart = NULL, + role = "enabled", + exclude_derived = TRUE, + spec_version = .mock_spec_version, + model_hint = "auto") { + variables <- .read_recodeflow_table(variables, "variables") + variables <- .migrate_garbage_aliases(variables) + .recodeflow_required_columns(variables, "variable", "variables") + + if (!is.null(variable_details)) { + variable_details <- .read_recodeflow_table(variable_details, "variable_details") + .recodeflow_required_columns(variable_details, c("variable", "recStart"), "variable_details") + } + + if (!is.null(role)) { + if (!"role" %in% names(variables)) { + stop("variables must have a 'role' column when role filtering is requested.", call. = FALSE) + } + variables <- variables[.role_matches(variables$role, role, ignore.case = TRUE), , drop = FALSE] + } + + variables <- .filter_recodeflow_by_database(variables, databaseStart, allow_empty = TRUE) + + if (nrow(variables) == 0) { + stop("No variables matched the requested role/database filters.", call. = FALSE) + } + + if (isTRUE(exclude_derived) && !is.null(variable_details)) { + derived <- identify_derived_vars(variables, variable_details) + variables <- variables[!variables$variable %in% derived, , drop = FALSE] + } + + if (nrow(variables) == 0) { + stop("No non-derived variables remain after filtering.", call. = FALSE) + } + + spec_variables <- lapply(seq_len(nrow(variables)), function(i) { + var_row <- variables[i, , drop = FALSE] + details <- .filter_recodeflow_details(variable_details, var_row$variable[1], databaseStart) + .recodeflow_to_spec_variable(var_row, details, databaseStart) + }) + + provenance <- list(adapter = "recodeflow", source = "variables+variable_details") + if (!is.null(databaseStart)) { + provenance$databaseStart <- paste(databaseStart, collapse = ",") + } + if (!is.null(role)) { + provenance$role <- paste(role, collapse = ",") + } + + mock_spec( + spec_variables, + spec_version = spec_version, + provenance = provenance, + model_hint = model_hint + ) +} diff --git a/tests/testthat/test-recodeflow-mock-spec.R b/tests/testthat/test-recodeflow-mock-spec.R new file mode 100644 index 0000000..97db9e7 --- /dev/null +++ b/tests/testthat/test-recodeflow-mock-spec.R @@ -0,0 +1,112 @@ +minimal_example_path <- function(...) { + file.path("..", "..", "inst", "extdata", "minimal-example", ...) +} + +test_that("mock_spec_from_recodeflow converts minimal metadata", { + variables <- read.csv( + minimal_example_path("variables.csv"), + stringsAsFactors = FALSE, + check.names = FALSE + ) + variable_details <- read.csv( + minimal_example_path("variable_details.csv"), + stringsAsFactors = FALSE, + check.names = FALSE + ) + + spec <- mock_spec_from_recodeflow(variables, variable_details) + + expect_s3_class(spec, "mock_spec") + expect_equal(spec$provenance$adapter, "recodeflow") + expect_true(validate_mock_spec(spec)$valid) + expect_false("BMI_derived" %in% names(spec$variables)) + expect_true(all(c("age", "smoking", "interview_date") %in% names(spec$variables))) + + expect_equal(spec$variables$age$type, "continuous") + expect_equal(spec$variables$age$rtype, "integer") + expect_equal(spec$variables$age$distribution, "normal") + expect_equal(spec$variables$age$range, c(18, 100)) + expect_equal(spec$variables$age$missing_codes, c("997", "998", "999")) + expect_length(spec$variables$age$garbage_rules, 0) + + expect_equal(spec$variables$smoking$type, "categorical") + expect_equal(spec$variables$smoking$levels, c("1", "2", "3")) + expect_equal(sum(spec$variables$smoking$proportions), 1) + + expect_equal(spec$variables$interview_date$type, "date") + expect_s3_class(spec$variables$interview_date$range, "Date") + expect_equal(spec$variables$interview_date$source_format, "analysis") +}) + +test_that("mock_spec_from_recodeflow filters exact role and databaseStart tokens", { + variables <- data.frame( + variable = c("age", "disabled_age", "cycle10_age"), + variableType = "Continuous", + rType = "integer", + role = c("enabled", "disabled", "enabled"), + databaseStart = c("cycle1, cycle2", "cycle1", "cycle10"), + distribution = "uniform", + stringsAsFactors = FALSE + ) + details <- data.frame( + variable = c("age", "disabled_age", "cycle10_age"), + recStart = c("[18, 85]", "[18, 85]", "[18, 85]"), + recEnd = "copy", + databaseStart = c("cycle1", "cycle1", "cycle10"), + proportion = 1, + stringsAsFactors = FALSE + ) + + spec <- mock_spec_from_recodeflow( + variables, + details, + databaseStart = "cycle1", + role = "enabled" + ) + + expect_named(spec$variables, "age") +}) + +test_that("mock_spec_from_recodeflow preserves garbage and survival fields", { + variables <- read.csv( + minimal_example_path("variables.csv"), + stringsAsFactors = FALSE, + check.names = FALSE + ) + variable_details <- read.csv( + minimal_example_path("variable_details.csv"), + stringsAsFactors = FALSE, + check.names = FALSE + ) + + 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$high$proportion, 0.01) + + expect_equal(spec$variables$primary_event_date$distribution, "gompertz") + expect_equal(spec$variables$primary_event_date$event_prop, 0.3) + expect_equal(spec$variables$primary_event_date$followup_max, 5475) +}) + +test_that("mock_spec_from_recodeflow validates adapter inputs", { + variables <- data.frame( + variable = "age", + variableType = "Continuous", + rType = "integer", + role = "enabled", + stringsAsFactors = FALSE + ) + + expect_error( + mock_spec_from_recodeflow(variables, variable_details = NULL), + "no valid recodeflow detail rows" + ) + + variables$role <- "disabled" + expect_error( + mock_spec_from_recodeflow(variables, data.frame(variable = "age", recStart = "[18, 85]")), + "No variables matched" + ) +}) From b1895f5e0a9a8d107ffdf192cc38c098087933db Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 11:10:54 -0400 Subject: [PATCH 06/41] Harden recodeflow mock_spec adapter --- NEWS.md | 5 +- R/mock_spec_recodeflow.R | 62 +++++++++++- tests/testthat/test-recodeflow-mock-spec.R | 104 ++++++++++++++++++++- 3 files changed, 164 insertions(+), 7 deletions(-) diff --git a/NEWS.md b/NEWS.md index 9b9d9c5..b6558a3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,7 +10,10 @@ `mock_categorical()`, and `mock_date()` for simple use without recodeflow-style metadata tables. - Added `mock_spec_from_recodeflow()` to adapt recodeflow-style `variables` - and `variable_details` metadata into validated `mock_spec` objects. + and `variable_details` metadata into validated `mock_spec` objects while + preserving role/database filtering, categorical proportions, `recEnd` + missing-code semantics, valid ranges, garbage rules, date ranges, and + survival/date fields. - Added forward-compatible specification fields: `spec_version`, `provenance`, and `model_hint`. - Existing v0.3 generator APIs remain available while v0.4 internals are built. diff --git a/R/mock_spec_recodeflow.R b/R/mock_spec_recodeflow.R index 0274ce5..f713a15 100644 --- a/R/mock_spec_recodeflow.R +++ b/R/mock_spec_recodeflow.R @@ -14,7 +14,12 @@ if (!file.exists(x)) { stop(label, " file does not exist: ", x, call. = FALSE) } - return(read.csv(x, stringsAsFactors = FALSE, check.names = FALSE)) + return(read.csv( + x, + stringsAsFactors = FALSE, + check.names = FALSE, + na.strings = c("", "NA") + )) } stop(label, " must be a data frame or a single CSV path.", call. = FALSE) @@ -50,7 +55,18 @@ if (.is_blank(value)) { return(default) } - suppressWarnings(as.numeric(value)) + + numeric_value <- suppressWarnings(as.numeric(value)) + if (is.na(numeric_value)) { + stop( + "Column '", name, "' for variable '", + .row_character(row, "variable", ""), + "' must be numeric; got '", as.character(value), "'.", + call. = FALSE + ) + } + + numeric_value } .recodeflow_required_columns <- function(data, required, label) { @@ -61,9 +77,15 @@ } .filter_recodeflow_by_database <- function(data, databaseStart, allow_empty = TRUE) { - if (is.null(databaseStart) || !"databaseStart" %in% names(data)) { + if (is.null(databaseStart)) { return(data) } + if (!"databaseStart" %in% names(data)) { + stop( + "databaseStart filtering was requested, but metadata has no 'databaseStart' column.", + call. = FALSE + ) + } data[.database_start_matches(data$databaseStart, databaseStart, allow_empty = allow_empty), , drop = FALSE] } @@ -143,7 +165,7 @@ !grepl("^garbage_", rec_start, ignore.case = TRUE) & !grepl("^NA::", rec_end) & !grepl("^DerivedVar::", rec_start) & - !grepl("^Func::", rec_end) + !grepl("^Func::", rec_start) details[keep, , drop = FALSE] } @@ -209,7 +231,16 @@ params <- tryCatch( extract_distribution_params(details), - error = function(e) list(distribution = "uniform") + error = function(e) { + warning( + "Could not infer distribution for variable '", + .row_character(var_row, "variable", ""), + "' from details; using uniform. Reason: ", + conditionMessage(e), + call. = FALSE + ) + list(distribution = "uniform") + } ) params$distribution %||% "uniform" } @@ -319,6 +350,20 @@ #' `variable_details` metadata into the normalized v0.4 `mock_spec` shape. It #' returns a validated specification; it does not generate data. #' +#' @details +#' This adapter preserves recodeflow semantics instead of treating metadata as a +#' generic table. It uses exact role and `databaseStart` token matching, parses +#' valid ranges from `recStart`, classifies missing codes from `recEnd` values +#' that begin with `NA::`, preserves categorical levels and proportions, carries +#' `garbage_*` settings into `garbage_rules`, and stores survival/date fields +#' such as `rate`, `shape`, `followup_min`, `followup_max`, and `event_prop` on +#' date variables for later backend milestones. +#' +#' By default, variables identified by `DerivedVar::` or `Func::` rows are +#' excluded because they should be evaluated after raw mock variables are +#' generated. Set `exclude_derived = FALSE` only when you want those rows to +#' appear in the adapter input and fail or be handled by later formula support. +#' #' @param variables Data frame or CSV path for recodeflow-style `variables` #' metadata. #' @param variable_details Data frame, CSV path, or `NULL` for recodeflow-style @@ -386,6 +431,13 @@ mock_spec_from_recodeflow <- function(variables, if (isTRUE(exclude_derived) && !is.null(variable_details)) { derived <- identify_derived_vars(variables, variable_details) + removed <- intersect(variables$variable, derived) + if (length(removed) > 0) { + message( + "Excluding derived recodeflow variable(s): ", + paste(removed, collapse = ", ") + ) + } variables <- variables[!variables$variable %in% derived, , drop = FALSE] } diff --git a/tests/testthat/test-recodeflow-mock-spec.R b/tests/testthat/test-recodeflow-mock-spec.R index 97db9e7..51fdcc4 100644 --- a/tests/testthat/test-recodeflow-mock-spec.R +++ b/tests/testthat/test-recodeflow-mock-spec.R @@ -31,7 +31,7 @@ test_that("mock_spec_from_recodeflow converts minimal metadata", { expect_equal(spec$variables$smoking$type, "categorical") expect_equal(spec$variables$smoking$levels, c("1", "2", "3")) - expect_equal(sum(spec$variables$smoking$proportions), 1) + expect_equal(spec$variables$smoking$proportions, c(0.5, 0.3, 0.17) / 0.97) expect_equal(spec$variables$interview_date$type, "date") expect_s3_class(spec$variables$interview_date$range, "Date") @@ -84,6 +84,7 @@ test_that("mock_spec_from_recodeflow preserves garbage and survival fields", { 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$high$proportion, 0.01) + expect_equal(spec$variables$BMI$garbage_rules$high$range, "[60;150]") expect_equal(spec$variables$primary_event_date$distribution, "gompertz") expect_equal(spec$variables$primary_event_date$event_prop, 0.3) @@ -110,3 +111,104 @@ test_that("mock_spec_from_recodeflow validates adapter inputs", { "No variables matched" ) }) + +test_that("mock_spec_from_recodeflow fails loudly on missing databaseStart column", { + variables <- data.frame( + variable = "age", + variableType = "Continuous", + rType = "integer", + role = "enabled", + stringsAsFactors = FALSE + ) + details <- data.frame( + variable = "age", + recStart = "[18, 85]", + recEnd = "copy", + proportion = 1, + stringsAsFactors = FALSE + ) + + expect_error( + mock_spec_from_recodeflow(variables, details, databaseStart = "cycle1"), + "no 'databaseStart' column" + ) +}) + +test_that("mock_spec_from_recodeflow rejects non-numeric scalar fields", { + variables <- data.frame( + variable = "age", + variableType = "Continuous", + rType = "integer", + role = "enabled", + distribution = "normal", + mean = "middle", + sd = 10, + stringsAsFactors = FALSE + ) + details <- data.frame( + variable = "age", + recStart = "[18, 85]", + recEnd = "copy", + proportion = 1, + stringsAsFactors = FALSE + ) + + expect_error( + mock_spec_from_recodeflow(variables, details), + "must be numeric" + ) +}) + +test_that("mock_spec_from_recodeflow excludes Func rows from valid ranges", { + variables <- data.frame( + variable = "age", + variableType = "Continuous", + rType = "integer", + role = "enabled", + distribution = "uniform", + stringsAsFactors = FALSE + ) + details <- data.frame( + variable = c("age", "age"), + recStart = c("Func::age_cleanup", "[18, 85]"), + recEnd = c("copy", "copy"), + proportion = c(NA, 1), + stringsAsFactors = FALSE + ) + + spec <- mock_spec_from_recodeflow(variables, details) + + expect_equal(spec$variables$age$range, c(18, 85)) +}) + +test_that("mock_spec_from_recodeflow matches direct adapter specs modulo provenance", { + variables <- data.frame( + variable = "age", + variableType = "Continuous", + rType = "integer", + role = "enabled", + distribution = "uniform", + stringsAsFactors = FALSE + ) + details <- data.frame( + variable = "age", + recStart = "[18, 85]", + recEnd = "copy", + proportion = 1, + stringsAsFactors = FALSE + ) + + recodeflow_spec <- mock_spec_from_recodeflow(variables, details) + direct_spec <- mock_continuous( + "age", + range = c(18, 85), + rtype = "integer", + missing_codes = character(0) + ) + + recodeflow_spec$provenance <- direct_spec$provenance + recodeflow_spec$variables$age$provenance <- direct_spec$variables$age$provenance + recodeflow_spec$variables$age$model_hint <- direct_spec$variables$age$model_hint + + expect_equal(recodeflow_spec, direct_spec) +}) From a6aa4cf4ab3f00aca6a3a6ef4857b1512c8bee00 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 11:24:19 -0400 Subject: [PATCH 07/41] Add native mock_spec backend --- NAMESPACE | 1 + NEWS.md | 2 + R/mock_spec_native.R | 264 +++++++++++++++++++++++++++ tests/testthat/test-native-backend.R | 123 +++++++++++++ 4 files changed, 390 insertions(+) create mode 100644 R/mock_spec_native.R create mode 100644 tests/testthat/test-native-backend.R diff --git a/NAMESPACE b/NAMESPACE index b1b6f71..76b5988 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -15,6 +15,7 @@ export(create_wide_survival_data) export(extract_distribution_params) export(extract_proportions) export(generate_garbage_values) +export(generate_mock_data_native) export(get_cycle_variables) export(get_enabled_variables) export(get_raw_var_dependencies) diff --git a/NEWS.md b/NEWS.md index b6558a3..5e14789 100644 --- a/NEWS.md +++ b/NEWS.md @@ -14,6 +14,8 @@ preserving role/database filtering, categorical proportions, `recEnd` missing-code semantics, valid ranges, garbage rules, date ranges, and survival/date fields. +- Added `generate_mock_data_native()` to generate baseline valid mock data from + `mock_spec` objects with the native R backend. - Added forward-compatible specification fields: `spec_version`, `provenance`, and `model_hint`. - Existing v0.3 generator APIs remain available while v0.4 internals are built. diff --git a/R/mock_spec_native.R b/R/mock_spec_native.R new file mode 100644 index 0000000..1e0d812 --- /dev/null +++ b/R/mock_spec_native.R @@ -0,0 +1,264 @@ +# ============================================================================== +# MockData v0.4 Native Backend +# ============================================================================== +# Baseline native generation from mock_spec. Post-processing for missing codes, +# garbage, diagnostics, and richer rType handling lands in later milestones. +# ============================================================================== + +.with_mock_seed <- function(seed, expr) { + 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) + } + + had_seed <- exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE) + if (had_seed) { + old_seed <- get(".Random.seed", envir = .GlobalEnv, inherits = FALSE) + } + + on.exit({ + 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) + + set.seed(seed) + force(expr) +} + +.empty_native_data <- function(n) { + data.frame(row.names = seq_len(n)) +} + +.sample_indices <- function(n_levels, n, prob = NULL) { + if (n == 0) { + return(integer(0)) + } + sample.int(n_levels, size = n, replace = TRUE, prob = prob) +} + +.native_truncated_normal <- function(n, mean, sd, range) { + if (n == 0) { + return(numeric(0)) + } + + values <- rep(NA_real_, n) + remaining <- seq_len(n) + attempts <- 0 + max_attempts <- 100 + + while (length(remaining) > 0 && attempts < max_attempts) { + draws <- stats::rnorm(length(remaining), mean = mean, sd = sd) + valid <- draws >= range[[1]] & draws <= range[[2]] + values[remaining[valid]] <- draws[valid] + remaining <- remaining[!valid] + attempts <- attempts + 1 + } + + if (length(remaining) > 0) { + warning( + "Could not fill all truncated-normal values by rejection sampling; ", + "using uniform draws for the remaining values.", + call. = FALSE + ) + values[remaining] <- stats::runif(length(remaining), range[[1]], range[[2]]) + } + + values +} + +.coerce_native_continuous <- function(values, rtype, variable_name) { + if (rtype == "integer") { + return(as.integer(round(values))) + } + if (rtype %in% c("double", "numeric")) { + return(as.numeric(values)) + } + + stop( + "Variable '", variable_name, "' has unsupported native continuous rType '", + rtype, "'.", + call. = FALSE + ) +} + +.coerce_native_categorical <- function(values, levels, rtype, variable_name) { + if (rtype == "factor") { + return(factor(values, levels = levels)) + } + if (rtype == "character") { + return(as.character(values)) + } + if (rtype == "integer") { + converted <- suppressWarnings(as.integer(values)) + if (any(is.na(converted) & !is.na(values))) { + stop( + "Variable '", variable_name, + "' integer categorical generation requires integer-like levels.", + call. = FALSE + ) + } + return(converted) + } + if (rtype %in% c("double", "numeric")) { + converted <- suppressWarnings(as.numeric(values)) + if (any(is.na(converted) & !is.na(values))) { + stop( + "Variable '", variable_name, + "' numeric categorical generation requires numeric-like levels.", + call. = FALSE + ) + } + return(converted) + } + if (rtype == "logical") { + if (!all(values %in% c("TRUE", "FALSE", "true", "false", "1", "0", TRUE, FALSE))) { + stop( + "Variable '", variable_name, + "' logical categorical generation requires TRUE/FALSE or 1/0 levels.", + call. = FALSE + ) + } + return(values %in% c("TRUE", "true", "1", TRUE)) + } + + stop( + "Variable '", variable_name, "' has unsupported native categorical rType '", + rtype, "'.", + call. = FALSE + ) +} + +.coerce_native_date <- function(values, rtype, variable_name) { + if (rtype == "date") { + return(values) + } + if (rtype == "character") { + return(as.character(values)) + } + + stop( + "Variable '", variable_name, "' has unsupported native date rType '", + rtype, "'.", + call. = FALSE + ) +} + +.generate_native_continuous <- function(variable, n) { + distribution <- tolower(variable$distribution %||% "uniform") + + if (distribution == "uniform") { + values <- stats::runif(n, variable$range[[1]], variable$range[[2]]) + } else if (distribution == "normal") { + values <- .native_truncated_normal(n, variable$mean, variable$sd, variable$range) + } else { + stop( + "Native backend does not yet support continuous distribution '", + distribution, "' for variable '", variable$name, "'.", + call. = FALSE + ) + } + + .coerce_native_continuous(values, variable$rtype, variable$name) +} + +.generate_native_categorical <- function(variable, n) { + levels <- as.character(variable$levels) + prob <- variable$proportions + if (is.null(prob)) { + prob <- rep(1 / length(levels), length(levels)) + } + + values <- levels[.sample_indices(length(levels), n, prob)] + .coerce_native_categorical(values, levels, variable$rtype, variable$name) +} + +.generate_native_date <- function(variable, n) { + distribution <- tolower(variable$distribution %||% "uniform") + if (distribution != "uniform") { + stop( + "Native backend does not yet support date distribution '", + distribution, "' for variable '", variable$name, "'.", + call. = FALSE + ) + } + + if (n == 0) { + values <- as.Date(character(0)) + } else { + range_numeric <- as.integer(variable$range) + offsets <- .sample_indices( + range_numeric[[2]] - range_numeric[[1]] + 1, + n + ) - 1 + values <- as.Date(range_numeric[[1]] + offsets, origin = "1970-01-01") + } + + .coerce_native_date(values, variable$rtype, variable$name) +} + +.generate_native_variable <- function(variable, n) { + if (variable$type == "continuous") { + return(.generate_native_continuous(variable, n)) + } + if (variable$type == "categorical") { + return(.generate_native_categorical(variable, n)) + } + if (variable$type == "date") { + return(.generate_native_date(variable, n)) + } + + stop( + "Native backend does not support variable type '", variable$type, + "' for variable '", variable$name, "'.", + call. = FALSE + ) +} + +#' Generate mock data with the native backend +#' +#' `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. +#' +#' @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. +#' +#' @return A data frame with one column per `mock_spec` variable and `n` rows. +#' @family mock generation APIs +#' @seealso [mock_spec()], [mock_continuous()], [mock_spec_from_recodeflow()] +#' +#' @examples +#' 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) +#' ) +#' ) +#' data <- generate_mock_data_native(spec, n = 10, seed = 1) +#' head(data) +#' +#' @export +generate_mock_data_native <- function(spec, n, seed = NULL) { + validate_mock_spec(spec, n = n, strict = TRUE) + + .with_mock_seed(seed, { + if (length(spec$variables) == 0) { + .empty_native_data(n) + } else { + columns <- lapply(spec$variables, .generate_native_variable, n = n) + names(columns) <- names(spec$variables) + as.data.frame(columns, stringsAsFactors = FALSE, check.names = FALSE) + } + }) +} diff --git a/tests/testthat/test-native-backend.R b/tests/testthat/test-native-backend.R new file mode 100644 index 0000000..7b3c56d --- /dev/null +++ b/tests/testthat/test-native-backend.R @@ -0,0 +1,123 @@ +test_that("generate_mock_data_native generates baseline direct specs", { + spec <- mock_spec( + mock_spec_continuous( + "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "integer" + ), + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ), + mock_spec_date( + "interview_date", + range = as.Date(c("2001-01-01", "2005-12-31")) + ) + ) + + result <- generate_mock_data_native(spec, n = 500, seed = 101) + + expect_s3_class(result, "data.frame") + expect_equal(nrow(result), 500) + expect_named(result, c("age", "smoking", "interview_date")) + expect_true(all(result$age >= 18 & result$age <= 85)) + expect_type(result$age, "integer") + expect_true(all(result$smoking %in% c("never", "former", "current"))) + expect_s3_class(result$interview_date, "Date") + expect_true(all(result$interview_date >= as.Date("2001-01-01"))) + expect_true(all(result$interview_date <= as.Date("2005-12-31"))) +}) + +test_that("generate_mock_data_native is reproducible without leaking RNG state", { + spec <- mock_continuous("age", range = c(18, 85), rtype = "integer") + + set.seed(999) + before <- runif(1) + result_1 <- generate_mock_data_native(spec, n = 10, seed = 42) + after_1 <- runif(1) + + set.seed(999) + expect_equal(runif(1), before) + result_2 <- generate_mock_data_native(spec, n = 10, seed = 42) + after_2 <- runif(1) + + expect_equal(result_1, result_2) + expect_equal(after_1, after_2) +}) + +test_that("generate_mock_data_native handles empty specs and n = 0", { + empty <- generate_mock_data_native(mock_spec(), n = 5, seed = 1) + expect_s3_class(empty, "data.frame") + expect_equal(nrow(empty), 5) + expect_equal(ncol(empty), 0) + + spec <- mock_categorical("smoking", levels = c("never", "former", "current")) + zero <- generate_mock_data_native(spec, n = 0, seed = 1) + expect_equal(nrow(zero), 0) + expect_named(zero, "smoking") +}) + +test_that("generate_mock_data_native consumes simple recodeflow specs", { + variables <- data.frame( + variable = c("age", "smoking", "interview_date"), + variableType = c("Continuous", "Categorical", "Continuous"), + rType = c("integer", "character", "date"), + role = "enabled", + distribution = c("uniform", "", "uniform"), + stringsAsFactors = FALSE + ) + details <- data.frame( + variable = c("age", "smoking", "smoking", "interview_date"), + recStart = c("[18, 85]", "never", "current", "[2001-01-01,2001-01-31]"), + recEnd = c("copy", "never", "current", "copy"), + proportion = c(1, 0.75, 0.25, 1), + stringsAsFactors = FALSE + ) + + spec <- mock_spec_from_recodeflow(variables, details) + result <- generate_mock_data_native(spec, n = 100, seed = 55) + + expect_named(result, c("age", "smoking", "interview_date")) + expect_true(all(result$age >= 18 & result$age <= 85)) + expect_true(all(result$smoking %in% c("never", "current"))) + expect_s3_class(result$interview_date, "Date") +}) + +test_that("generate_mock_data_native fails loudly for unsupported native features", { + survival_like <- mock_spec( + mock_spec_date( + "event_date", + range = as.Date(c("2001-01-01", "2005-12-31")) + ), + validate = FALSE + ) + survival_like$variables$event_date$distribution <- "gompertz" + + expect_error( + generate_mock_data_native(survival_like, n = 10), + "does not yet support date distribution" + ) + + expect_error( + generate_mock_data_native(list(), n = 10), + "mock_spec" + ) +}) + +test_that("generate_mock_data_native rejects lossy categorical coercion", { + spec <- mock_categorical( + "smoking", + levels = c("never", "former", "current"), + rtype = "integer" + ) + + expect_error( + generate_mock_data_native(spec, n = 10, seed = 1), + "integer-like levels" + ) +}) From 501698b6ae70a865af84b29afe8b1206d28dd819 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 11:33:49 -0400 Subject: [PATCH 08/41] Harden native mock_spec backend --- R/mock_spec_native.R | 37 ++++++++++- tests/testthat/test-native-backend.R | 93 ++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/R/mock_spec_native.R b/R/mock_spec_native.R index 1e0d812..990cbf2 100644 --- a/R/mock_spec_native.R +++ b/R/mock_spec_native.R @@ -42,7 +42,30 @@ sample.int(n_levels, size = n, replace = TRUE, prob = prob) } -.native_truncated_normal <- function(n, mean, sd, range) { +.native_formula_variables <- function(spec) { + names(Filter(function(variable) { + formula <- variable$formula + !is.null(formula) && + !(is.character(formula) && length(formula) == 1 && (is.na(formula) || trimws(formula) == "")) + }, spec$variables)) +} + +.check_native_backend_scope <- function(spec) { + formula_variables <- .native_formula_variables(spec) + if (length(formula_variables) > 0) { + stop( + "Formula evaluation is not yet implemented in the M4 native backend. ", + "Formula variable(s): ", + paste(formula_variables, collapse = ", "), + ". Expected in a later formula/dependency milestone.", + call. = FALSE + ) + } + + invisible(TRUE) +} + +.native_truncated_normal <- function(n, mean, sd, range, variable_name) { if (n == 0) { return(numeric(0)) } @@ -62,7 +85,8 @@ if (length(remaining) > 0) { warning( - "Could not fill all truncated-normal values by rejection sampling; ", + "Variable '", variable_name, + "': could not fill all truncated-normal values by rejection sampling; ", "using uniform draws for the remaining values.", call. = FALSE ) @@ -155,7 +179,13 @@ if (distribution == "uniform") { values <- stats::runif(n, variable$range[[1]], variable$range[[2]]) } else if (distribution == "normal") { - values <- .native_truncated_normal(n, variable$mean, variable$sd, variable$range) + values <- .native_truncated_normal( + n, + variable$mean, + variable$sd, + variable$range, + variable$name + ) } else { stop( "Native backend does not yet support continuous distribution '", @@ -251,6 +281,7 @@ #' @export generate_mock_data_native <- function(spec, n, seed = NULL) { validate_mock_spec(spec, n = n, strict = TRUE) + .check_native_backend_scope(spec) .with_mock_seed(seed, { if (length(spec$variables) == 0) { diff --git a/tests/testthat/test-native-backend.R b/tests/testthat/test-native-backend.R index 7b3c56d..9904878 100644 --- a/tests/testthat/test-native-backend.R +++ b/tests/testthat/test-native-backend.R @@ -33,6 +33,39 @@ test_that("generate_mock_data_native generates baseline direct specs", { expect_true(all(result$interview_date <= as.Date("2005-12-31"))) }) +test_that("generate_mock_data_native preserves statistical contracts", { + spec <- mock_spec( + mock_spec_continuous("uniform_age", range = c(20, 80), rtype = "double"), + mock_spec_continuous( + "normal_age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "double" + ), + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ) + ) + + result <- generate_mock_data_native(spec, n = 5000, seed = 202) + + expect_equal(mean(result$uniform_age), 50, tolerance = 1) + expect_equal(stats::sd(result$uniform_age), 60 / sqrt(12), tolerance = 1) + expect_equal(mean(result$normal_age), 50, tolerance = 1) + expect_equal(stats::sd(result$normal_age), 12, tolerance = 1) + + observed <- prop.table(table(factor( + result$smoking, + levels = c("never", "former", "current") + ))) + expect_equal(as.numeric(observed), c(0.5, 0.3, 0.2), tolerance = 0.03) +}) + test_that("generate_mock_data_native is reproducible without leaking RNG state", { spec <- mock_continuous("age", range = c(18, 85), rtype = "integer") @@ -60,6 +93,22 @@ test_that("generate_mock_data_native handles empty specs and n = 0", { zero <- generate_mock_data_native(spec, n = 0, seed = 1) expect_equal(nrow(zero), 0) expect_named(zero, "smoking") + + continuous_zero <- generate_mock_data_native( + mock_continuous("age", range = c(18, 85)), + n = 0, + seed = 1 + ) + expect_equal(nrow(continuous_zero), 0) + expect_named(continuous_zero, "age") + + one <- generate_mock_data_native( + mock_continuous("age", range = c(18, 85), rtype = "integer"), + n = 1, + seed = 1 + ) + expect_equal(nrow(one), 1) + expect_true(one$age >= 18 && one$age <= 85) }) test_that("generate_mock_data_native consumes simple recodeflow specs", { @@ -86,6 +135,23 @@ test_that("generate_mock_data_native consumes simple recodeflow specs", { expect_true(all(result$age >= 18 & result$age <= 85)) expect_true(all(result$smoking %in% c("never", "current"))) expect_s3_class(result$interview_date, "Date") + + direct_spec <- mock_spec( + mock_spec_continuous("age", range = c(18, 85), rtype = "integer"), + mock_spec_categorical( + "smoking", + levels = c("never", "current"), + proportions = c(0.75, 0.25), + rtype = "character" + ), + mock_spec_date( + "interview_date", + range = as.Date(c("2001-01-01", "2001-01-31")) + ) + ) + + direct_result <- generate_mock_data_native(direct_spec, n = 100, seed = 55) + expect_equal(result, direct_result) }) test_that("generate_mock_data_native fails loudly for unsupported native features", { @@ -109,6 +175,33 @@ test_that("generate_mock_data_native fails loudly for unsupported native feature ) }) +test_that("generate_mock_data_native rejects formula specs until evaluator milestone", { + variable <- mock_spec_continuous("bmi", range = c(15, 50)) + variable$formula <- "weight / height^2" + spec <- mock_spec(variable) + + expect_error( + generate_mock_data_native(spec, n = 10, seed = 1), + "Formula evaluation is not yet implemented" + ) +}) + +test_that("generate_mock_data_native warns on truncated normal fallback", { + spec <- mock_spec(mock_spec_continuous( + "age", + range = c(0, 1), + distribution = "normal", + mean = 1000, + sd = 1 + )) + + expect_warning( + result <- generate_mock_data_native(spec, n = 5, seed = 1), + "Variable 'age'" + ) + expect_true(all(result$age >= 0 & result$age <= 1)) +}) + test_that("generate_mock_data_native rejects lossy categorical coercion", { spec <- mock_categorical( "smoking", From fb6ae3f1aa865650623f55945c35ab2b95b96a91 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 19:26:23 -0400 Subject: [PATCH 09/41] Add mock_spec post-processing layer --- NAMESPACE | 1 + NEWS.md | 3 + R/mock_spec_postprocess.R | 312 ++++++++++++++++++++ tests/testthat/test-mock-spec-postprocess.R | 162 ++++++++++ 4 files changed, 478 insertions(+) create mode 100644 R/mock_spec_postprocess.R create mode 100644 tests/testthat/test-mock-spec-postprocess.R diff --git a/NAMESPACE b/NAMESPACE index 76b5988..52cae5a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -37,6 +37,7 @@ export(mock_spec_date) export(mock_spec_from_recodeflow) export(parse_range_notation) export(parse_variable_start) +export(postprocess_mock_data) export(read_mock_data_config) export(read_mock_data_config_details) export(sample_with_proportions) diff --git a/NEWS.md b/NEWS.md index 5e14789..fe6fda1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -16,6 +16,9 @@ survival/date fields. - Added `generate_mock_data_native()` to generate baseline valid mock data from `mock_spec` objects with the native R backend. +- Added `postprocess_mock_data()` to apply `mock_spec` missing-code and + garbage-value rules after baseline generation, with diagnostics that + distinguish assigned missing/garbage rows from naturally drawn values. - Added forward-compatible specification fields: `spec_version`, `provenance`, and `model_hint`. - Existing v0.3 generator APIs remain available while v0.4 internals are built. diff --git a/R/mock_spec_postprocess.R b/R/mock_spec_postprocess.R new file mode 100644 index 0000000..f71a6d5 --- /dev/null +++ b/R/mock_spec_postprocess.R @@ -0,0 +1,312 @@ +# ============================================================================== +# MockData v0.4 Post-processing Layer +# ============================================================================== +# Applies missing-code and garbage-value rules after baseline generation while +# preserving diagnostics that distinguish assigned states from coincidental +# value collisions. +# ============================================================================== + +.postprocess_empty_diagnostics <- function(spec, n) { + variables <- lapply(spec$variables, function(variable) { + list( + n = n, + preexisting_missing_code_indices = integer(0), + assigned_missing_indices = integer(0), + assigned_missing_codes = character(0), + assigned_garbage_indices = list(low = integer(0), high = integer(0)), + assigned_garbage_values = list(low = character(0), high = character(0)) + ) + }) + + list( + spec_version = spec$spec_version, + variables = variables + ) +} + +.values_match_codes <- function(values, codes) { + if (length(codes) == 0) { + return(rep(FALSE, length(values))) + } + + as.character(values) %in% as.character(codes) +} + +.sample_postprocess_indices <- function(candidates, n, avoid = integer(0)) { + if (n == 0) { + return(integer(0)) + } + if (length(candidates) < n) { + stop("Not enough candidate rows are available for post-processing.", call. = FALSE) + } + + preferred <- setdiff(candidates, avoid) + if (length(preferred) >= n) { + return(.sample_values(preferred, n)) + } + + c( + preferred, + .sample_values(setdiff(candidates, preferred), n - length(preferred)) + ) +} + +.coerce_postprocess_values <- function(values, variable, target) { + if (inherits(target, "factor")) { + return(as.character(values)) + } + + if (inherits(target, "Date") || variable$rtype == "date") { + converted <- as.Date(values) + if (any(is.na(converted) & !is.na(values))) { + stop("Variable '", variable$name, "' has date post-processing values that cannot be parsed.", call. = FALSE) + } + return(converted) + } + + if (is.integer(target) || variable$rtype == "integer") { + converted <- suppressWarnings(as.integer(round(as.numeric(values)))) + if (any(is.na(converted) & !is.na(values))) { + stop("Variable '", variable$name, "' has integer post-processing values that cannot be parsed.", call. = FALSE) + } + return(converted) + } + + if (is.numeric(target) || variable$rtype %in% c("double", "numeric")) { + converted <- suppressWarnings(as.numeric(values)) + if (any(is.na(converted) & !is.na(values))) { + stop("Variable '", variable$name, "' has numeric post-processing values that cannot be parsed.", call. = FALSE) + } + return(converted) + } + + if (is.logical(target) || variable$rtype == "logical") { + value_chr <- as.character(values) + if (!all(value_chr %in% c("TRUE", "FALSE", "true", "false", "1", "0"))) { + stop("Variable '", variable$name, "' has logical post-processing values that cannot be parsed.", call. = FALSE) + } + return(value_chr %in% c("TRUE", "true", "1")) + } + + as.character(values) +} + +.assign_postprocess_values <- function(target, indices, values) { + if (length(indices) == 0) { + return(target) + } + + if (inherits(target, "factor")) { + missing_levels <- setdiff(as.character(values), levels(target)) + if (length(missing_levels) > 0) { + levels(target) <- c(levels(target), missing_levels) + } + } + + target[indices] <- values + target +} + +.generate_garbage_for_rule <- function(rule, variable, n) { + if (n == 0) { + return(vector(mode = "character", length = 0)) + } + + parsed <- parse_range_notation(rule$range) + if (is.null(parsed)) { + stop( + "Variable '", variable$name, "' has an invalid garbage range: ", + rule$range, + call. = FALSE + ) + } + + if (identical(parsed$type, "date")) { + date_values <- seq(parsed$min, parsed$max, by = "day") + return(.sample_values(date_values, n, replace = TRUE)) + } + + if (identical(parsed$type, "integer") && !is.null(parsed$values)) { + return(.sample_values(parsed$values, n, replace = TRUE)) + } + + values <- stats::runif(n, parsed$min, parsed$max) + if (variable$type == "categorical" || variable$rtype == "integer") { + values <- round(values) + } + + values +} + +.postprocess_missing <- function(values, variable, diagnostics) { + if (length(variable$missing_codes) == 0) { + return(list(values = values, diagnostics = diagnostics)) + } + + available <- seq_along(values) + preexisting <- which(.values_match_codes(values, variable$missing_codes)) + assigned <- integer(0) + assigned_codes <- character(0) + + for (i in seq_along(variable$missing_codes)) { + proportion <- variable$missing_proportions[[i]] + n_assign <- round(length(values) * proportion) + if (n_assign == 0) { + next + } + + code <- variable$missing_codes[[i]] + code_values <- rep(code, n_assign) + assign_idx <- .sample_postprocess_indices( + available, + n_assign, + avoid = union(preexisting, assigned) + ) + coerced <- .coerce_postprocess_values(code_values, variable, values) + values <- .assign_postprocess_values(values, assign_idx, coerced) + + available <- setdiff(available, assign_idx) + assigned <- c(assigned, assign_idx) + assigned_codes <- c(assigned_codes, as.character(code_values)) + } + + diagnostics$preexisting_missing_code_indices <- preexisting + diagnostics$assigned_missing_indices <- assigned + diagnostics$assigned_missing_codes <- assigned_codes + + list(values = values, diagnostics = diagnostics) +} + +.postprocess_garbage <- function(values, variable, diagnostics) { + if (length(variable$garbage_rules) == 0) { + return(list(values = values, diagnostics = diagnostics)) + } + if (is.null(names(variable$garbage_rules)) || any(names(variable$garbage_rules) == "")) { + stop("Variable '", variable$name, "' garbage_rules must be a named list.", call. = FALSE) + } + + assigned_missing <- diagnostics$assigned_missing_indices + valid_idx <- setdiff(which(!is.na(values)), assigned_missing) + remaining_idx <- valid_idx + + requested <- vapply(variable$garbage_rules, function(rule) { + proportion <- rule$proportion %||% 0 + if (is.na(proportion)) { + proportion <- 0 + } + as.integer(round(length(valid_idx) * proportion)) + }, integer(1)) + + if (sum(requested) > length(valid_idx)) { + stop( + "Variable '", variable$name, + "' garbage rules request more rows than are available after missing-code assignment.", + call. = FALSE + ) + } + + for (rule_name in names(variable$garbage_rules)) { + rule <- variable$garbage_rules[[rule_name]] + n_assign <- requested[[rule_name]] + if (n_assign == 0) { + next + } + + if (is.null(rule$range) || is.na(rule$range) || trimws(rule$range) == "") { + stop("Variable '", variable$name, "' garbage rule '", rule_name, "' is missing a range.", call. = FALSE) + } + + assign_idx <- .sample_postprocess_indices(remaining_idx, n_assign) + raw_values <- .generate_garbage_for_rule(rule, variable, n_assign) + coerced <- .coerce_postprocess_values(raw_values, variable, values) + values <- .assign_postprocess_values(values, assign_idx, coerced) + + diagnostics$assigned_garbage_indices[[rule_name]] <- assign_idx + diagnostics$assigned_garbage_values[[rule_name]] <- coerced + remaining_idx <- setdiff(remaining_idx, assign_idx) + } + + list(values = values, diagnostics = diagnostics) +} + +.postprocess_variable <- function(values, variable, diagnostics) { + missing_result <- .postprocess_missing(values, variable, diagnostics) + garbage_result <- .postprocess_garbage( + missing_result$values, + variable, + missing_result$diagnostics + ) + + garbage_result +} + +#' Apply mock_spec post-processing rules +#' +#' `postprocess_mock_data()` applies v0.4 `mock_spec` missing-code and +#' garbage-value rules to an already generated baseline data frame. It records a +#' `mockdata_diagnostics` attribute so downstream checks can distinguish values +#' assigned by post-processing from values that were drawn naturally by the +#' baseline generator. +#' +#' @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 diagnostics Logical. If `TRUE`, attach a `mockdata_diagnostics` +#' attribute to the returned data frame. +#' +#' @return A data frame with post-processing applied. +#' @family mock generation APIs +#' @seealso [generate_mock_data_native()], [mock_spec()] +#' +#' @examples +#' spec <- mock_categorical( +#' "smoking", +#' levels = c("never", "former", "current"), +#' proportions = c(0.5, 0.3, 0.2), +#' rtype = "character", +#' missing_codes = "9", +#' missing_proportions = 0.05 +#' ) +#' baseline <- generate_mock_data_native(spec, n = 20, seed = 1) +#' result <- postprocess_mock_data(baseline, spec, seed = 2) +#' attr(result, "mockdata_diagnostics")$variables$smoking +#' +#' @export +postprocess_mock_data <- function(data, spec, seed = NULL, diagnostics = TRUE) { + if (!is.data.frame(data)) { + stop("data must be a data frame.", call. = FALSE) + } + validate_mock_spec(spec, n = nrow(data), strict = TRUE) + + missing_columns <- setdiff(names(spec$variables), names(data)) + if (length(missing_columns) > 0) { + stop( + "data is missing column(s) required by spec: ", + paste(missing_columns, collapse = ", "), + call. = FALSE + ) + } + + .with_mock_seed(seed, { + output <- data + diag <- .postprocess_empty_diagnostics(spec, nrow(data)) + + for (variable_name in names(spec$variables)) { + variable <- spec$variables[[variable_name]] + result <- .postprocess_variable( + output[[variable_name]], + variable, + diag$variables[[variable_name]] + ) + output[[variable_name]] <- result$values + diag$variables[[variable_name]] <- result$diagnostics + } + + if (isTRUE(diagnostics)) { + attr(output, "mockdata_diagnostics") <- diag + } + + output + }) +} diff --git a/tests/testthat/test-mock-spec-postprocess.R b/tests/testthat/test-mock-spec-postprocess.R new file mode 100644 index 0000000..b4dc87f --- /dev/null +++ b/tests/testthat/test-mock-spec-postprocess.R @@ -0,0 +1,162 @@ +test_that("postprocess_mock_data distinguishes missing-code collisions", { + spec <- mock_categorical( + "response", + levels = c("1", "97"), + proportions = c(0.7, 0.3), + rtype = "character", + missing_codes = "97", + missing_proportions = 0.2 + ) + baseline <- generate_mock_data_native(spec, n = 200, seed = 11) + + result <- postprocess_mock_data(baseline, spec, seed = 12) + diagnostics <- attr(result, "mockdata_diagnostics")$variables$response + + expect_true(length(diagnostics$preexisting_missing_code_indices) > 0) + expect_equal(length(diagnostics$assigned_missing_indices), 40) + expect_length(intersect( + diagnostics$preexisting_missing_code_indices, + diagnostics$assigned_missing_indices + ), 0) + expect_true(all(result$response[diagnostics$assigned_missing_indices] == "97")) + expect_true(any(baseline$response[diagnostics$preexisting_missing_code_indices] == "97")) +}) + +test_that("postprocess_mock_data applies integer missing and garbage rules", { + spec <- mock_continuous( + "age", + range = c(18, 85), + rtype = "integer", + missing_codes = 997, + missing_proportions = 0.1, + garbage_rules = list(high = list(proportion = 0.05, range = "[150, 200]")) + ) + baseline <- generate_mock_data_native(spec, n = 100, seed = 21) + + result <- postprocess_mock_data(baseline, spec, seed = 22) + diagnostics <- attr(result, "mockdata_diagnostics")$variables$age + high_idx <- diagnostics$assigned_garbage_indices$high + + expect_type(result$age, "integer") + expect_equal(length(diagnostics$assigned_missing_indices), 10) + expect_equal(result$age[diagnostics$assigned_missing_indices], rep(997L, 10)) + expect_equal(length(high_idx), round(90 * 0.05)) + expect_true(all(result$age[high_idx] >= 150L & result$age[high_idx] <= 200L)) + expect_length(intersect(high_idx, diagnostics$assigned_missing_indices), 0) +}) + +test_that("postprocess_mock_data preserves Date values", { + spec <- mock_date( + "interview_date", + range = as.Date(c("2001-01-01", "2001-01-31")), + missing_codes = "2099-01-01", + missing_proportions = 0.1, + garbage_rules = list(high = list( + proportion = 0.1, + range = "[2025-01-01, 2025-01-31]" + )) + ) + baseline <- generate_mock_data_native(spec, n = 50, seed = 31) + + result <- postprocess_mock_data(baseline, spec, seed = 32) + diagnostics <- attr(result, "mockdata_diagnostics")$variables$interview_date + high_idx <- diagnostics$assigned_garbage_indices$high + + expect_s3_class(result$interview_date, "Date") + expect_equal( + result$interview_date[diagnostics$assigned_missing_indices], + rep(as.Date("2099-01-01"), 5) + ) + expect_true(all(result$interview_date[high_idx] >= as.Date("2025-01-01"))) + expect_true(all(result$interview_date[high_idx] <= as.Date("2025-01-31"))) +}) + +test_that("postprocess_mock_data is reproducible without leaking RNG state", { + spec <- mock_continuous( + "age", + range = c(18, 85), + rtype = "integer", + missing_codes = 997, + missing_proportions = 0.1, + garbage_rules = list(high = list(proportion = 0.1, range = "[150, 200]")) + ) + baseline <- generate_mock_data_native(spec, n = 100, seed = 71) + + set.seed(999) + before <- runif(1) + result_1 <- postprocess_mock_data(baseline, spec, seed = 72) + after_1 <- runif(1) + + set.seed(999) + expect_equal(runif(1), before) + result_2 <- postprocess_mock_data(baseline, spec, seed = 72) + after_2 <- runif(1) + + expect_equal(result_1, result_2) + expect_equal(after_1, after_2) +}) + +test_that("postprocess_mock_data preserves and extends factor levels", { + spec <- mock_categorical( + "smoking", + levels = c("1", "2", "3"), + proportions = c(0.5, 0.3, 0.2), + missing_codes = "9", + missing_proportions = 0.1, + garbage_rules = list(low = list(proportion = 0.1, range = "[-2, 0]")) + ) + baseline <- generate_mock_data_native(spec, n = 60, seed = 41) + + result <- postprocess_mock_data(baseline, spec, seed = 42) + diagnostics <- attr(result, "mockdata_diagnostics")$variables$smoking + low_idx <- diagnostics$assigned_garbage_indices$low + + expect_s3_class(result$smoking, "factor") + expect_true("9" %in% levels(result$smoking)) + expect_true(all(as.character(result$smoking[diagnostics$assigned_missing_indices]) == "9")) + expect_true(all(as.character(result$smoking[low_idx]) %in% c("-2", "-1", "0"))) +}) + +test_that("postprocess_mock_data rejects unnamed garbage rules", { + spec <- mock_continuous( + "age", + range = c(18, 85), + garbage_rules = list(list(proportion = 0.1, range = "[150, 200]")) + ) + baseline <- generate_mock_data_native(spec, n = 10, seed = 81) + + expect_error( + postprocess_mock_data(baseline, spec, seed = 82), + "named list" + ) +}) + +test_that("postprocess_mock_data rejects impossible garbage requests", { + spec <- mock_continuous( + "age", + range = c(18, 85), + garbage_rules = list( + low = list(proportion = 0.8, range = "[-10, 0]"), + high = list(proportion = 0.8, range = "[150, 200]") + ) + ) + baseline <- generate_mock_data_native(spec, n = 10, seed = 51) + + expect_error( + postprocess_mock_data(baseline, spec, seed = 52), + "request more rows" + ) +}) + +test_that("postprocess_mock_data validates input shape and diagnostics opt-out", { + spec <- mock_continuous("age", range = c(18, 85)) + baseline <- generate_mock_data_native(spec, n = 10, seed = 61) + + expect_error( + postprocess_mock_data(data.frame(other = 1:10), spec), + "missing column" + ) + + result <- postprocess_mock_data(baseline, spec, diagnostics = FALSE) + expect_null(attr(result, "mockdata_diagnostics")) +}) From 6daef47cc411115d2b2d898591a48a2e9dde2cd7 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 19:50:53 -0400 Subject: [PATCH 10/41] Harden mock_spec post-processing diagnostics --- R/mock_spec_postprocess.R | 64 +++++++++++++++-- tests/testthat/test-mock-spec-postprocess.R | 76 ++++++++++++++++++++- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/R/mock_spec_postprocess.R b/R/mock_spec_postprocess.R index f71a6d5..fc1b671 100644 --- a/R/mock_spec_postprocess.R +++ b/R/mock_spec_postprocess.R @@ -8,13 +8,27 @@ .postprocess_empty_diagnostics <- function(spec, n) { variables <- lapply(spec$variables, function(variable) { + garbage_rule_names <- names(variable$garbage_rules) + if (is.null(garbage_rule_names)) { + garbage_rule_names <- character(0) + } + garbage_rule_names <- .ordered_garbage_rule_names(garbage_rule_names) + garbage_indices <- stats::setNames( + rep(list(integer(0)), length(garbage_rule_names)), + garbage_rule_names + ) + garbage_values <- stats::setNames( + rep(list(character(0)), length(garbage_rule_names)), + garbage_rule_names + ) + list( n = n, preexisting_missing_code_indices = integer(0), assigned_missing_indices = integer(0), assigned_missing_codes = character(0), - assigned_garbage_indices = list(low = integer(0), high = integer(0)), - assigned_garbage_values = list(low = character(0), high = character(0)) + assigned_garbage_indices = garbage_indices, + assigned_garbage_values = garbage_values ) }) @@ -138,10 +152,21 @@ values } +.ordered_garbage_rule_names <- function(rule_names) { + c(intersect(c("low", "high"), rule_names), setdiff(rule_names, c("low", "high"))) +} + .postprocess_missing <- function(values, variable, diagnostics) { if (length(variable$missing_codes) == 0) { return(list(values = values, diagnostics = diagnostics)) } + if (sum(variable$missing_proportions) > 1 + .mock_spec_probability_tolerance) { + stop( + "Variable '", variable$name, + "' missing proportions request more rows than are available.", + call. = FALSE + ) + } available <- seq_along(values) preexisting <- which(.values_match_codes(values, variable$missing_codes)) @@ -182,11 +207,25 @@ return(list(values = values, diagnostics = diagnostics)) } if (is.null(names(variable$garbage_rules)) || any(names(variable$garbage_rules) == "")) { - stop("Variable '", variable$name, "' garbage_rules must be a named list.", call. = FALSE) + rule_names <- names(variable$garbage_rules) + unnamed <- if (is.null(rule_names)) { + seq_along(variable$garbage_rules) + } else { + which(rule_names == "") + } + stop( + "Variable '", variable$name, "' garbage_rules must be a named list; ", + "unnamed rule index: ", paste(unnamed, collapse = ", "), + ".", + call. = FALSE + ) } - assigned_missing <- diagnostics$assigned_missing_indices - valid_idx <- setdiff(which(!is.na(values)), assigned_missing) + protected_idx <- union( + diagnostics$assigned_missing_indices, + diagnostics$preexisting_missing_code_indices + ) + valid_idx <- setdiff(which(!is.na(values)), protected_idx) remaining_idx <- valid_idx requested <- vapply(variable$garbage_rules, function(rule) { @@ -205,7 +244,7 @@ ) } - for (rule_name in names(variable$garbage_rules)) { + for (rule_name in .ordered_garbage_rule_names(names(variable$garbage_rules))) { rule <- variable$garbage_rules[[rule_name]] n_assign <- requested[[rule_name]] if (n_assign == 0) { @@ -256,6 +295,12 @@ #' attribute to the returned data frame. #' #' @return A data frame with post-processing applied. +#' +#' @details +#' Diagnostics are stored as a data-frame attribute. Base R subsetting and some +#' downstream tools may drop attributes, so preserve the original post-processed +#' object when diagnostics are part of the audit trail. +#' #' @family mock generation APIs #' @seealso [generate_mock_data_native()], [mock_spec()] #' @@ -277,6 +322,13 @@ postprocess_mock_data <- function(data, spec, seed = NULL, diagnostics = TRUE) { if (!is.data.frame(data)) { stop("data must be a data frame.", call. = FALSE) } + if (!is.null(attr(data, "mockdata_diagnostics"))) { + stop( + "postprocess_mock_data() appears to have already run on this data. ", + "Start from baseline generated data to avoid double post-processing.", + call. = FALSE + ) + } validate_mock_spec(spec, n = nrow(data), strict = TRUE) missing_columns <- setdiff(names(spec$variables), names(data)) diff --git a/tests/testthat/test-mock-spec-postprocess.R b/tests/testthat/test-mock-spec-postprocess.R index b4dc87f..cd942b4 100644 --- a/tests/testthat/test-mock-spec-postprocess.R +++ b/tests/testthat/test-mock-spec-postprocess.R @@ -22,6 +22,47 @@ test_that("postprocess_mock_data distinguishes missing-code collisions", { expect_true(any(baseline$response[diagnostics$preexisting_missing_code_indices] == "97")) }) +test_that("postprocess_mock_data does not overwrite preexisting missing-code collisions with garbage", { + spec <- mock_categorical( + "response", + levels = c("1", "97"), + proportions = c(0.5, 0.5), + rtype = "character", + missing_codes = "97", + missing_proportions = 0, + garbage_rules = list(low = list(proportion = 1, range = "[-2, 0]")) + ) + baseline <- generate_mock_data_native(spec, n = 100, seed = 13) + expect_true(any(baseline$response == "97")) + + result <- postprocess_mock_data(baseline, spec, seed = 14) + diagnostics <- attr(result, "mockdata_diagnostics")$variables$response + + preexisting <- diagnostics$preexisting_missing_code_indices + garbage <- diagnostics$assigned_garbage_indices$low + + expect_length(intersect(preexisting, garbage), 0) + expect_true(all(result$response[preexisting] == "97")) +}) + +test_that("postprocess_mock_data rejects missing-proportion overflow", { + spec <- mock_categorical( + "response", + levels = c("1", "2"), + proportions = c(0.5, 0.5), + rtype = "character", + missing_codes = c("97", "98"), + missing_proportions = c(0.1, 0.1) + ) + spec$variables$response$missing_proportions <- c(0.6, 0.6) + baseline <- data.frame(response = rep(c("1", "2"), each = 10)) + + expect_error( + postprocess_mock_data(baseline, spec, seed = 16), + "missing proportions must sum" + ) +}) + test_that("postprocess_mock_data applies integer missing and garbage rules", { spec <- mock_continuous( "age", @@ -117,6 +158,23 @@ test_that("postprocess_mock_data preserves and extends factor levels", { expect_true(all(as.character(result$smoking[low_idx]) %in% c("-2", "-1", "0"))) }) +test_that("postprocess_mock_data applies garbage rules in canonical order", { + spec <- mock_continuous( + "age", + range = c(18, 85), + garbage_rules = list( + high = list(proportion = 0.1, range = "[150, 200]"), + low = list(proportion = 0.1, range = "[-10, 0]") + ) + ) + baseline <- generate_mock_data_native(spec, n = 20, seed = 81) + + result <- postprocess_mock_data(baseline, spec, seed = 82) + diagnostics <- attr(result, "mockdata_diagnostics")$variables$age + + expect_equal(names(diagnostics$assigned_garbage_indices), c("low", "high")) +}) + test_that("postprocess_mock_data rejects unnamed garbage rules", { spec <- mock_continuous( "age", @@ -127,7 +185,7 @@ test_that("postprocess_mock_data rejects unnamed garbage rules", { expect_error( postprocess_mock_data(baseline, spec, seed = 82), - "named list" + "unnamed rule index: 1" ) }) @@ -160,3 +218,19 @@ test_that("postprocess_mock_data validates input shape and diagnostics opt-out", result <- postprocess_mock_data(baseline, spec, diagnostics = FALSE) expect_null(attr(result, "mockdata_diagnostics")) }) + +test_that("postprocess_mock_data rejects idempotent re-call", { + spec <- mock_continuous( + "age", + range = c(18, 85), + missing_codes = 997, + missing_proportions = 0.1 + ) + baseline <- generate_mock_data_native(spec, n = 20, seed = 91) + result <- postprocess_mock_data(baseline, spec, seed = 92) + + expect_error( + postprocess_mock_data(result, spec, seed = 93), + "already run" + ) +}) From 2b6aa1e2aedf7e41d883c10a54b990532863d47e Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Mon, 18 May 2026 19:55:11 -0400 Subject: [PATCH 11/41] Promote mock_spec pipeline assertions --- tests/testthat/test-mock-spec-pipeline.R | 145 +++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/testthat/test-mock-spec-pipeline.R diff --git a/tests/testthat/test-mock-spec-pipeline.R b/tests/testthat/test-mock-spec-pipeline.R new file mode 100644 index 0000000..a53cf26 --- /dev/null +++ b/tests/testthat/test-mock-spec-pipeline.R @@ -0,0 +1,145 @@ +run_native_pipeline <- function(spec, n, seed) { + baseline <- generate_mock_data_native(spec, n = n, seed = seed) + postprocess_mock_data(baseline, spec, seed = seed + 1) +} + +test_that("native mock_spec pipeline preserves categorical codes and diagnostics", { + spec <- mock_categorical( + "response", + levels = c("1", "97"), + proportions = c(0.55, 0.45), + rtype = "character", + missing_codes = "97", + missing_proportions = 0.2, + garbage_rules = list(low = list(proportion = 0.4, range = "[-2, 0]")) + ) + + result <- run_native_pipeline(spec, n = 200, seed = 101) + diagnostics <- attr(result, "mockdata_diagnostics")$variables$response + + expect_true(all(result$response %in% c("1", "97", "-2", "-1", "0"))) + expect_equal(length(diagnostics$assigned_missing_indices), 40) + expect_true(length(diagnostics$preexisting_missing_code_indices) > 0) + expect_length(intersect( + diagnostics$preexisting_missing_code_indices, + diagnostics$assigned_missing_indices + ), 0) + expect_length(intersect( + diagnostics$preexisting_missing_code_indices, + diagnostics$assigned_garbage_indices$low + ), 0) + expect_true(all(result$response[diagnostics$preexisting_missing_code_indices] == "97")) + expect_true(all(result$response[diagnostics$assigned_missing_indices] == "97")) +}) + +test_that("native mock_spec pipeline is reproducible as a composed workflow", { + spec <- mock_spec( + mock_spec_continuous( + "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "integer", + missing_codes = 997, + missing_proportions = 0.05, + garbage_rules = list(high = list(proportion = 0.05, range = "[150, 200]")) + ), + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ) + ) + + first <- run_native_pipeline(spec, n = 100, seed = 202) + second <- run_native_pipeline(spec, n = 100, seed = 202) + + expect_equal(first, second) + expect_equal( + attr(first, "mockdata_diagnostics"), + attr(second, "mockdata_diagnostics") + ) +}) + +test_that("recodeflow pipeline preserves recEnd-driven missingness and garbage", { + variables <- data.frame( + variable = "smoking", + variableType = "Categorical", + rType = "character", + role = "enabled", + garbage_low_prop = 0.1, + garbage_low_range = "[-2, 0]", + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = "smoking", + recStart = c("1", "2", "97", "99"), + recEnd = c("copy", "copy", "NA::b", "NA::b"), + proportion = c(0.5, 0.3, 0.1, 0.1), + stringsAsFactors = FALSE + ) + spec <- mock_spec_from_recodeflow(variables, variable_details) + + result <- run_native_pipeline(spec, n = 100, seed = 303) + diagnostics <- attr(result, "mockdata_diagnostics")$variables$smoking + + expect_equal(spec$variables$smoking$levels, c("1", "2")) + expect_equal(spec$variables$smoking$missing_codes, c("97", "99")) + expect_equal(length(diagnostics$assigned_missing_indices), 20) + expect_equal(length(diagnostics$assigned_garbage_indices$low), 8) + expect_true(all(result$smoking[diagnostics$assigned_missing_indices] %in% c("97", "99"))) + expect_true(all(result$smoking[diagnostics$assigned_garbage_indices$low] %in% c("-2", "-1", "0"))) + expect_length(intersect( + diagnostics$assigned_missing_indices, + diagnostics$assigned_garbage_indices$low + ), 0) +}) + +test_that("direct and recodeflow pipelines agree for equivalent specs", { + direct_spec <- mock_categorical( + "smoking", + levels = c("1", "2"), + proportions = c(0.625, 0.375), + rtype = "character", + missing_codes = c("97", "99"), + missing_proportions = c(0.1, 0.1), + garbage_rules = list(low = list(proportion = 0.1, range = "[-2, 0]")) + ) + variables <- data.frame( + variable = "smoking", + variableType = "Categorical", + rType = "character", + role = "enabled", + garbage_low_prop = 0.1, + garbage_low_range = "[-2, 0]", + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = "smoking", + recStart = c("1", "2", "97", "99"), + recEnd = c("copy", "copy", "NA::b", "NA::b"), + proportion = c(0.5, 0.3, 0.1, 0.1), + stringsAsFactors = FALSE + ) + recodeflow_spec <- mock_spec_from_recodeflow(variables, variable_details) + + direct_result <- run_native_pipeline(direct_spec, n = 100, seed = 404) + recodeflow_result <- run_native_pipeline(recodeflow_spec, n = 100, seed = 404) + + attr(direct_result, "mockdata_diagnostics") <- NULL + attr(recodeflow_result, "mockdata_diagnostics") <- NULL + expect_equal(direct_result, recodeflow_result) +}) + +test_that("pipeline keeps deferred formula variables loud", { + variable <- mock_spec_continuous("bmi", range = c(15, 50)) + variable$formula <- "weight / height^2" + spec <- mock_spec(variable) + + expect_error( + run_native_pipeline(spec, n = 10, seed = 505), + "Formula evaluation is not yet implemented" + ) +}) From c28354a2ef1bfa273d8488cb0c60e0ea3e1ec81e Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Tue, 19 May 2026 19:37:32 -0400 Subject: [PATCH 12/41] Add optional simstudy mock_spec backend --- DESCRIPTION | 1 + NAMESPACE | 1 + NEWS.md | 3 + R/mock_spec_simstudy.R | 169 +++++++++++++++++++++++++ tests/testthat/test-simstudy-backend.R | 108 ++++++++++++++++ 5 files changed, 282 insertions(+) create mode 100644 R/mock_spec_simstudy.R create mode 100644 tests/testthat/test-simstudy-backend.R diff --git a/DESCRIPTION b/DESCRIPTION index 6a79c59..3f5a388 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -32,6 +32,7 @@ Suggests: readr, stringr, lubridate, + simstudy, knitr, quarto, devtools, diff --git a/NAMESPACE b/NAMESPACE index 52cae5a..987afd8 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -16,6 +16,7 @@ export(extract_distribution_params) export(extract_proportions) export(generate_garbage_values) export(generate_mock_data_native) +export(generate_mock_data_simstudy) export(get_cycle_variables) export(get_enabled_variables) export(get_raw_var_dependencies) diff --git a/NEWS.md b/NEWS.md index fe6fda1..f5aa558 100644 --- a/NEWS.md +++ b/NEWS.md @@ -19,6 +19,9 @@ - Added `postprocess_mock_data()` to apply `mock_spec` missing-code and garbage-value rules after baseline generation, with diagnostics that distinguish assigned missing/garbage rows from naturally drawn values. +- Added `generate_mock_data_simstudy()` as a soft-gated optional backend for + baseline categorical and uniform continuous generation when `simstudy` is + installed, with native generation retained for MockData-specific semantics. - Added forward-compatible specification fields: `spec_version`, `provenance`, and `model_hint`. - Existing v0.3 generator APIs remain available while v0.4 internals are built. diff --git a/R/mock_spec_simstudy.R b/R/mock_spec_simstudy.R new file mode 100644 index 0000000..776dce4 --- /dev/null +++ b/R/mock_spec_simstudy.R @@ -0,0 +1,169 @@ +# ============================================================================== +# MockData v0.4 Optional simstudy Backend +# ============================================================================== +# Baseline generation from mock_spec using simstudy when the optional package is +# installed. MockData still owns post-processing and diagnostics. +# ============================================================================== + +.require_simstudy <- function() { + if (!requireNamespace("simstudy", quietly = TRUE)) { + stop( + "The optional simstudy backend requires the 'simstudy' package. ", + "Install simstudy or use generate_mock_data_native().", + call. = FALSE + ) + } + + invisible(TRUE) +} + +.simstudy_definition <- function(def, variable) { + if (variable$type == "continuous") { + distribution <- tolower(variable$distribution %||% "uniform") + if (distribution == "uniform") { + return(simstudy::defData( + dtDefs = def, + varname = variable$name, + formula = paste(variable$range, collapse = ";"), + dist = "uniform" + )) + } + } + + if (variable$type == "categorical") { + probabilities <- variable$proportions + if (is.null(probabilities)) { + probabilities <- rep(1 / length(variable$levels), length(variable$levels)) + } + + return(simstudy::defData( + dtDefs = def, + varname = variable$name, + formula = paste(probabilities, collapse = ";"), + variance = paste(variable$levels, collapse = ";"), + dist = "categorical" + )) + } + + stop( + "simstudy backend does not yet support variable '", variable$name, + "' of type '", variable$type, "'.", + call. = FALSE + ) +} + +.simstudy_can_generate <- function(variable) { + if (variable$type == "categorical") { + return(TRUE) + } + + variable$type == "continuous" && + identical(tolower(variable$distribution %||% "uniform"), "uniform") +} + +.simstudy_variables <- function(spec) { + Filter(.simstudy_can_generate, spec$variables) +} + +.native_only_variables <- function(spec) { + Filter(function(variable) !.simstudy_can_generate(variable), spec$variables) +} + +.generate_simstudy_baseline <- function(variables, n) { + if (length(variables) == 0) { + return(.empty_native_data(n)) + } + + def <- NULL + for (variable in variables) { + def <- .simstudy_definition(def, variable) + } + + generated <- as.data.frame(simstudy::genData(n, def), stringsAsFactors = FALSE) + generated <- generated[, names(variables), drop = FALSE] + + for (variable_name in names(variables)) { + variable <- variables[[variable_name]] + if (variable$type == "continuous") { + generated[[variable_name]] <- .coerce_native_continuous( + generated[[variable_name]], + variable$rtype, + variable$name + ) + } else if (variable$type == "categorical") { + generated[[variable_name]] <- .coerce_native_categorical( + as.character(generated[[variable_name]]), + as.character(variable$levels), + variable$rtype, + variable$name + ) + } + } + + generated +} + +.generate_native_only_baseline <- function(variables, n) { + if (length(variables) == 0) { + return(.empty_native_data(n)) + } + + columns <- lapply(variables, .generate_native_variable, n = n) + names(columns) <- names(variables) + as.data.frame(columns, stringsAsFactors = FALSE, check.names = FALSE) +} + +#' Generate mock data with the optional simstudy backend +#' +#' `generate_mock_data_simstudy()` consumes a validated `mock_spec` and +#' generates baseline valid values through the optional `simstudy` package for +#' supported uniform continuous and categorical variables. MockData remains +#' responsible for missing-code injection, garbage values, and diagnostics +#' through [postprocess_mock_data()]. +#' +#' Variables that need MockData semantics not covered by this milestone, such as +#' truncated normal ranges and calendar dates, are generated by MockData's native +#' path inside the same seeded call. +#' +#' @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. +#' +#' @return A data frame with one column per `mock_spec` variable and `n` rows. +#' @family mock generation APIs +#' @seealso [generate_mock_data_native()], [postprocess_mock_data()], +#' [mock_spec()] +#' +#' @examples +#' spec <- mock_continuous("age", range = c(18, 85), rtype = "integer") +#' if (requireNamespace("simstudy", quietly = TRUE)) { +#' data <- generate_mock_data_simstudy(spec, n = 10, seed = 1) +#' head(data) +#' } +#' +#' @export +generate_mock_data_simstudy <- function(spec, n, seed = NULL) { + .require_simstudy() + validate_mock_spec(spec, n = n, strict = TRUE) + .check_native_backend_scope(spec) + + simstudy_variables <- .simstudy_variables(spec) + native_only_variables <- .native_only_variables(spec) + + .with_mock_seed(seed, { + simstudy_data <- .generate_simstudy_baseline(simstudy_variables, n) + native_data <- .generate_native_only_baseline(native_only_variables, n) + + columns <- c(simstudy_data, native_data) + if (length(spec$variables) == 0) { + return(.empty_native_data(n)) + } + + as.data.frame( + columns[names(spec$variables)], + stringsAsFactors = FALSE, + check.names = FALSE + ) + }) +} diff --git a/tests/testthat/test-simstudy-backend.R b/tests/testthat/test-simstudy-backend.R new file mode 100644 index 0000000..e479d44 --- /dev/null +++ b/tests/testthat/test-simstudy-backend.R @@ -0,0 +1,108 @@ +test_that("generate_mock_data_simstudy fails clearly when simstudy is unavailable", { + if (requireNamespace("simstudy", quietly = TRUE)) { + skip("simstudy is installed; unavailable-path test is not applicable") + } + + spec <- mock_continuous("age", range = c(18, 85)) + + expect_error( + generate_mock_data_simstudy(spec, n = 10, seed = 1), + "requires the 'simstudy' package" + ) +}) + +test_that("generate_mock_data_simstudy generates supported baseline specs", { + skip_if_not_installed("simstudy") + + spec <- mock_spec( + mock_spec_continuous("age", range = c(18, 85), rtype = "integer"), + mock_spec_continuous( + "bmi", + range = c(15, 50), + distribution = "normal", + mean = 27, + sd = 5 + ), + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ), + mock_spec_date("interview_date", range = as.Date(c("2001-01-01", "2001-01-31"))) + ) + + result <- generate_mock_data_simstudy(spec, n = 1000, seed = 707) + + expect_named(result, c("age", "bmi", "smoking", "interview_date")) + expect_type(result$age, "integer") + expect_type(result$bmi, "double") + expect_type(result$smoking, "character") + expect_s3_class(result$interview_date, "Date") + expect_true(all(result$age >= 18 & result$age <= 85)) + expect_true(all(result$bmi >= 15 & result$bmi <= 50)) + expect_equal(mean(result$bmi), 27, tolerance = 1) + + observed <- prop.table(table(factor( + result$smoking, + levels = c("never", "former", "current") + ))) + expect_equal(as.numeric(observed), c(0.5, 0.3, 0.2), tolerance = 0.05) +}) + +test_that("generate_mock_data_simstudy composes with MockData post-processing", { + skip_if_not_installed("simstudy") + + spec <- mock_categorical( + "response", + levels = c("1", "97"), + proportions = c(0.6, 0.4), + rtype = "character", + missing_codes = "97", + missing_proportions = 0.2, + garbage_rules = list(low = list(proportion = 0.2, range = "[-2, 0]")) + ) + + baseline <- generate_mock_data_simstudy(spec, n = 100, seed = 808) + result <- postprocess_mock_data(baseline, spec, seed = 809) + diagnostics <- attr(result, "mockdata_diagnostics")$variables$response + + expect_equal(length(diagnostics$assigned_missing_indices), 20) + expect_true(length(diagnostics$preexisting_missing_code_indices) > 0) + expect_length(intersect( + diagnostics$preexisting_missing_code_indices, + diagnostics$assigned_garbage_indices$low + ), 0) +}) + +test_that("generate_mock_data_simstudy is reproducible", { + skip_if_not_installed("simstudy") + + 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" + ) + ) + + first <- generate_mock_data_simstudy(spec, n = 100, seed = 909) + second <- generate_mock_data_simstudy(spec, n = 100, seed = 909) + + expect_equal(first, second) +}) + +test_that("generate_mock_data_simstudy keeps deferred formula variables loud", { + skip_if_not_installed("simstudy") + + variable <- mock_spec_continuous("bmi", range = c(15, 50)) + variable$formula <- "weight / height^2" + spec <- mock_spec(variable) + + expect_error( + generate_mock_data_simstudy(spec, n = 10, seed = 1), + "Formula evaluation is not yet implemented" + ) +}) From 70da7b85815dc165b575856c073eb16d4f103997 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Tue, 19 May 2026 19:57:20 -0400 Subject: [PATCH 13/41] Harden optional simstudy backend --- DESCRIPTION | 2 +- R/mock_spec_simstudy.R | 51 +++++++++- tests/testthat/test-simstudy-backend.R | 127 ++++++++++++++++++++++++- 3 files changed, 177 insertions(+), 3 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 3f5a388..dbb0f20 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -32,7 +32,7 @@ Suggests: readr, stringr, lubridate, - simstudy, + simstudy (>= 0.8.1), knitr, quarto, devtools, diff --git a/R/mock_spec_simstudy.R b/R/mock_spec_simstudy.R index 776dce4..2535623 100644 --- a/R/mock_spec_simstudy.R +++ b/R/mock_spec_simstudy.R @@ -13,11 +13,40 @@ call. = FALSE ) } + if (utils::packageVersion("simstudy") < "0.8.1") { + stop( + "The optional simstudy backend requires simstudy >= 0.8.1. ", + "Install a newer simstudy version or use generate_mock_data_native().", + call. = FALSE + ) + } + + invisible(TRUE) +} + +.check_simstudy_variable <- function(variable) { + if (identical(variable$name, "id")) { + stop( + "Variable name 'id' conflicts with simstudy's generated row identifier. ", + "Rename the variable or use generate_mock_data_native().", + call. = FALSE + ) + } + + if (variable$type == "categorical" && any(grepl(";", as.character(variable$levels), fixed = TRUE))) { + stop( + "Variable '", variable$name, + "' has categorical level(s) containing ';', which simstudy uses as a delimiter.", + call. = FALSE + ) + } invisible(TRUE) } .simstudy_definition <- function(def, variable) { + .check_simstudy_variable(variable) + if (variable$type == "continuous") { distribution <- tolower(variable$distribution %||% "uniform") if (distribution == "uniform") { @@ -52,6 +81,26 @@ ) } +.normalize_simstudy_categorical <- function(values, variable) { + value_chr <- as.character(values) + levels <- as.character(variable$levels) + if (all(value_chr %in% levels)) { + return(value_chr) + } + + index <- suppressWarnings(as.integer(value_chr)) + if (!any(is.na(index)) && all(index >= 1 & index <= length(levels))) { + return(levels[index]) + } + + stop( + "simstudy returned categorical values for variable '", variable$name, + "' that do not match the mock_spec levels. ", + "This may indicate a simstudy version or delimiter mismatch.", + call. = FALSE + ) +} + .simstudy_can_generate <- function(variable) { if (variable$type == "categorical") { return(TRUE) @@ -92,7 +141,7 @@ ) } else if (variable$type == "categorical") { generated[[variable_name]] <- .coerce_native_categorical( - as.character(generated[[variable_name]]), + .normalize_simstudy_categorical(generated[[variable_name]], variable), as.character(variable$levels), variable$rtype, variable$name diff --git a/tests/testthat/test-simstudy-backend.R b/tests/testthat/test-simstudy-backend.R index e479d44..93924b5 100644 --- a/tests/testthat/test-simstudy-backend.R +++ b/tests/testthat/test-simstudy-backend.R @@ -50,6 +50,114 @@ test_that("generate_mock_data_simstudy generates supported baseline specs", { expect_equal(as.numeric(observed), c(0.5, 0.3, 0.2), tolerance = 0.05) }) +test_that("generate_mock_data_simstudy protects simstudy-specific categorical contracts", { + skip_if_not_installed("simstudy") + + semicolon <- mock_categorical( + "group", + levels = c("never;former", "current"), + proportions = c(0.5, 0.5), + rtype = "character" + ) + expect_error( + generate_mock_data_simstudy(semicolon, n = 10, seed = 1), + "containing ';'" + ) + + reserved <- mock_categorical( + "id", + levels = c("a", "b"), + proportions = c(0.5, 0.5), + rtype = "character" + ) + expect_error( + generate_mock_data_simstudy(reserved, n = 10, seed = 1), + "conflicts with simstudy" + ) +}) + +test_that("simstudy categorical normalization handles labels and old integer indices", { + variable <- mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ) + + expect_equal( + MockData:::.normalize_simstudy_categorical(c("never", "current"), variable), + c("never", "current") + ) + expect_equal( + MockData:::.normalize_simstudy_categorical(c(1L, 3L), variable), + c("never", "current") + ) + expect_error( + MockData:::.normalize_simstudy_categorical(c("mystery"), variable), + "do not match" + ) +}) + +test_that("generate_mock_data_simstudy roughly matches native contracts when installed", { + skip_if_not_installed("simstudy") + + 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" + ) + ) + + native <- generate_mock_data_native(spec, n = 5000, seed = 1001) + simstudy <- generate_mock_data_simstudy(spec, n = 5000, seed = 1001) + + expect_named(simstudy, names(native)) + expect_type(simstudy$age, "integer") + expect_type(simstudy$smoking, "character") + expect_equal(mean(simstudy$age), mean(native$age), tolerance = 2) + expect_equal(stats::sd(simstudy$age), stats::sd(native$age), tolerance = 2) + + observed <- prop.table(table(factor( + simstudy$smoking, + levels = c("never", "former", "current") + ))) + expect_equal(as.numeric(observed), c(0.5, 0.3, 0.2), tolerance = 0.05) +}) + +test_that("generate_mock_data_simstudy routes unsupported pieces through native backend", { + skip_if_not_installed("simstudy") + + spec <- mock_spec( + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ), + mock_spec_continuous( + "bmi", + range = c(15, 50), + distribution = "normal", + mean = 27, + sd = 5 + ), + mock_spec_date("interview_date", range = as.Date(c("2001-01-01", "2001-01-31"))) + ) + + expect_false(MockData:::.simstudy_can_generate(spec$variables$bmi)) + expect_false(MockData:::.simstudy_can_generate(spec$variables$interview_date)) + + result <- generate_mock_data_simstudy(spec, n = 1000, seed = 1002) + expect_true(all(result$bmi >= 15 & result$bmi <= 50)) + expect_equal(mean(result$bmi), 27, tolerance = 1) + expect_s3_class(result$interview_date, "Date") + expect_true(all(result$interview_date >= as.Date("2001-01-01"))) + expect_true(all(result$interview_date <= as.Date("2001-01-31"))) +}) + test_that("generate_mock_data_simstudy composes with MockData post-processing", { skip_if_not_installed("simstudy") @@ -91,7 +199,24 @@ test_that("generate_mock_data_simstudy is reproducible", { first <- generate_mock_data_simstudy(spec, n = 100, seed = 909) second <- generate_mock_data_simstudy(spec, n = 100, seed = 909) - expect_equal(first, second) + expect_identical(first, second) +}) + +test_that("generate_mock_data_simstudy handles empty specs and n = 0", { + skip_if_not_installed("simstudy") + + empty <- generate_mock_data_simstudy(mock_spec(), n = 5, seed = 1) + expect_s3_class(empty, "data.frame") + expect_equal(nrow(empty), 5) + expect_equal(ncol(empty), 0) + + zero <- generate_mock_data_simstudy( + mock_categorical("smoking", levels = c("never", "former", "current")), + n = 0, + seed = 1 + ) + expect_equal(nrow(zero), 0) + expect_named(zero, "smoking") }) test_that("generate_mock_data_simstudy keeps deferred formula variables loud", { From e109d546865fa925da454ebb2fac24b529b5b405 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Tue, 19 May 2026 20:06:09 -0400 Subject: [PATCH 14/41] Polish v0.4 reference documentation --- NEWS.md | 6 ++++++ R/mock_spec.R | 39 ++++++++++++++++++++++++++++++++++++++- R/mock_spec_native.R | 33 ++++++++++++++++++++++++++++++++- R/mock_spec_postprocess.R | 35 +++++++++++++++++++++++++++++++---- R/mock_spec_recodeflow.R | 38 +++++++++++++++++++++++++++++++++++++- R/mock_spec_simstudy.R | 30 ++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 7 deletions(-) diff --git a/NEWS.md b/NEWS.md index f5aa558..9bdd9b5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -19,9 +19,15 @@ - Added `postprocess_mock_data()` to apply `mock_spec` missing-code and garbage-value rules after baseline generation, with diagnostics that distinguish assigned missing/garbage rows from naturally drawn values. +- Post-processing diagnostics now protect naturally drawn missing-code + collisions from later garbage assignment, apply garbage rules in canonical + `low` -> `high` -> other order, and stop on repeated post-processing. - Added `generate_mock_data_simstudy()` as a soft-gated optional backend for baseline categorical and uniform continuous generation when `simstudy` is installed, with native generation retained for MockData-specific semantics. +- The optional `simstudy` backend is kept in `Suggests`, requires + `simstudy >= 0.8.1`, and validates categorical labels before converting + generated values back into MockData's `mock_spec` levels. - Added forward-compatible specification fields: `spec_version`, `provenance`, and `model_hint`. - Existing v0.3 generator APIs remain available while v0.4 internals are built. diff --git a/R/mock_spec.R b/R/mock_spec.R index 7fd3e51..5efd59e 100644 --- a/R/mock_spec.R +++ b/R/mock_spec.R @@ -46,10 +46,12 @@ NULL if (is.null(x)) y else x } +#' @noRd .is_non_empty_string <- function(x) { is.character(x) && length(x) == 1 && !is.na(x) && nzchar(trimws(x)) } +#' @noRd .normalize_provenance <- function(provenance, source = NULL) { if (is.null(provenance)) { provenance <- list(adapter = "direct", source = source %||% "direct") @@ -75,6 +77,7 @@ NULL ) } +#' @noRd .validate_model_hint <- function(model_hint) { if (length(model_hint) != 1 || is.na(model_hint) || !model_hint %in% .mock_spec_model_hints) { stop( @@ -87,6 +90,7 @@ NULL invisible(TRUE) } +#' @noRd .new_mock_spec_variable <- function(name, type, rtype, @@ -137,6 +141,7 @@ NULL ) } +#' @noRd .as_mock_spec_variable_list <- function(...) { variables <- list(...) @@ -160,6 +165,7 @@ NULL variables } +#' @noRd .direct_api_provenance <- function(source, provenance = NULL) { provenance <- provenance %||% list(source = source) @@ -178,6 +184,14 @@ NULL #' new architecture. Direct APIs and recodeflow adapters should both normalize #' into this shape before validation and generation. #' +#' @details +#' The v0.4 API is layered. The `mock_*()` helpers are the simple direct API for +#' one-variable specifications. The `mock_spec_*()` constructors create variable +#' specifications that can be composed with `mock_spec()`. Metadata adapters, +#' such as [mock_spec_from_recodeflow()], translate external metadata into the +#' same internal shape. Generation backends consume `mock_spec` objects rather +#' than re-reading user-facing metadata. +#' #' @param ... `mock_spec_variable` objects, or a single list of them. `NULL` #' creates an empty specification. #' @param spec_version Character version of the specification shape. @@ -189,7 +203,9 @@ NULL #' #' @return S3 object of class `mock_spec`. #' @family mock specification APIs -#' @seealso [mock_continuous()], [mock_categorical()], [mock_date()] +#' @seealso [mock_continuous()], [mock_categorical()], [mock_date()], +#' [mock_spec_from_recodeflow()], [generate_mock_data_native()], +#' [postprocess_mock_data()] #' #' @examples #' spec <- mock_spec( @@ -233,6 +249,11 @@ mock_spec <- function(..., #' returns a validated `mock_spec`; it does not generate data. Generation #' backends will consume this specification in a later v0.4 milestone. #' +#' @details +#' Use `mock_continuous()` when specifying one variable directly in R code. Use +#' [mock_spec_continuous()] with [mock_spec()] when composing several variables +#' or when writing an adapter from another metadata source. +#' #' @param name Variable name. #' @param range Numeric vector of length two giving the inclusive valid range. #' @param distribution Distribution name. Defaults to `"uniform"`. @@ -302,6 +323,11 @@ mock_continuous <- function(name, #' `mock_categorical()` is the simple direct API for categorical variables. It #' returns a validated `mock_spec`; it does not generate data. #' +#' @details +#' Use `mock_categorical()` when specifying one variable directly in R code. Use +#' [mock_spec_categorical()] with [mock_spec()] when composing several variables +#' or when writing an adapter from another metadata source. +#' #' @param name Variable name. #' @param levels Character vector of valid levels or codes. #' @param proportions Optional probabilities aligned to `levels`. @@ -363,6 +389,11 @@ mock_categorical <- function(name, #' `mock_date()` is the simple direct API for date variables. It returns a #' validated `mock_spec`; it does not generate data. #' +#' @details +#' Date variables default to `model_hint = "native-postprocess"` because MockData +#' owns calendar-date generation and source-format conversion. Optional +#' backends may still generate other variables in the same specification. +#' #' @param name Variable name. #' @param range Date vector of length two giving the inclusive valid date range. #' @param rtype R output type. Defaults to `"date"`. @@ -587,6 +618,7 @@ is_mock_spec <- function(x) { inherits(x, "mock_spec") } +#' @noRd .new_mock_spec_validation_result <- function(valid = TRUE, errors = character(0), warnings = character(0), @@ -631,6 +663,7 @@ print.mock_spec_validation_result <- function(x, ...) { invisible(x) } +#' @noRd .validate_probability_vector <- function(values, label, allow_null = FALSE) { errors <- character(0) @@ -655,6 +688,7 @@ print.mock_spec_validation_result <- function(x, ...) { errors } +#' @noRd .validate_provenance <- function(provenance, label) { errors <- character(0) @@ -671,6 +705,7 @@ print.mock_spec_validation_result <- function(x, ...) { errors } +#' @noRd .validate_missing_spec <- function(variable) { errors <- character(0) @@ -702,6 +737,7 @@ print.mock_spec_validation_result <- function(x, ...) { errors } +#' @noRd .validate_range <- function(range, variable_name, expected_class = "numeric") { errors <- character(0) @@ -726,6 +762,7 @@ print.mock_spec_validation_result <- function(x, ...) { errors } +#' @noRd .validate_mock_spec_variable <- function(variable) { errors <- character(0) diff --git a/R/mock_spec_native.R b/R/mock_spec_native.R index 990cbf2..53c8286 100644 --- a/R/mock_spec_native.R +++ b/R/mock_spec_native.R @@ -5,6 +5,7 @@ # garbage, diagnostics, and richer rType handling lands in later milestones. # ============================================================================== +#' @noRd .with_mock_seed <- function(seed, expr) { if (is.null(seed)) { return(force(expr)) @@ -19,6 +20,7 @@ old_seed <- get(".Random.seed", envir = .GlobalEnv, inherits = FALSE) } + # Generation should be reproducible without changing the caller's RNG stream. on.exit({ if (had_seed) { assign(".Random.seed", old_seed, envir = .GlobalEnv) @@ -31,10 +33,12 @@ force(expr) } +#' @noRd .empty_native_data <- function(n) { data.frame(row.names = seq_len(n)) } +#' @noRd .sample_indices <- function(n_levels, n, prob = NULL) { if (n == 0) { return(integer(0)) @@ -42,6 +46,7 @@ sample.int(n_levels, size = n, replace = TRUE, prob = prob) } +#' @noRd .native_formula_variables <- function(spec) { names(Filter(function(variable) { formula <- variable$formula @@ -50,6 +55,7 @@ }, spec$variables)) } +#' @noRd .check_native_backend_scope <- function(spec) { formula_variables <- .native_formula_variables(spec) if (length(formula_variables) > 0) { @@ -65,6 +71,7 @@ invisible(TRUE) } +#' @noRd .native_truncated_normal <- function(n, mean, sd, range, variable_name) { if (n == 0) { return(numeric(0)) @@ -84,6 +91,8 @@ } if (length(remaining) > 0) { + # Pathological truncation windows can make rejection sampling impractical; + # keep generation bounded and tell the caller the tail came from uniform. warning( "Variable '", variable_name, "': could not fill all truncated-normal values by rejection sampling; ", @@ -96,6 +105,7 @@ values } +#' @noRd .coerce_native_continuous <- function(values, rtype, variable_name) { if (rtype == "integer") { return(as.integer(round(values))) @@ -111,6 +121,7 @@ ) } +#' @noRd .coerce_native_categorical <- function(values, levels, rtype, variable_name) { if (rtype == "factor") { return(factor(values, levels = levels)) @@ -158,6 +169,7 @@ ) } +#' @noRd .coerce_native_date <- function(values, rtype, variable_name) { if (rtype == "date") { return(values) @@ -173,6 +185,7 @@ ) } +#' @noRd .generate_native_continuous <- function(variable, n) { distribution <- tolower(variable$distribution %||% "uniform") @@ -197,6 +210,7 @@ .coerce_native_continuous(values, variable$rtype, variable$name) } +#' @noRd .generate_native_categorical <- function(variable, n) { levels <- as.character(variable$levels) prob <- variable$proportions @@ -208,6 +222,7 @@ .coerce_native_categorical(values, levels, variable$rtype, variable$name) } +#' @noRd .generate_native_date <- function(variable, n) { distribution <- tolower(variable$distribution %||% "uniform") if (distribution != "uniform") { @@ -222,6 +237,8 @@ values <- as.Date(character(0)) } else { range_numeric <- as.integer(variable$range) + # Sample day offsets numerically, then restore Date class; this avoids + # locale-dependent date parsing during generation. offsets <- .sample_indices( range_numeric[[2]] - range_numeric[[1]] + 1, n @@ -232,6 +249,7 @@ .coerce_native_date(values, variable$rtype, variable$name) } +#' @noRd .generate_native_variable <- function(variable, n) { if (variable$type == "continuous") { return(.generate_native_continuous(variable, n)) @@ -257,6 +275,18 @@ #' not yet apply missing-code injection, garbage values, diagnostics, formula #' evaluation, or optional `simstudy` features. #' +#' @details +#' The native backend is the default MIT-licensed baseline engine. It currently +#' supports uniform continuous variables, truncated-normal continuous variables, +#' categorical variables, and uniform calendar dates. Missing codes, garbage +#' values, and diagnostics are intentionally handled by [postprocess_mock_data()] +#' so that all backends share the same audit trail. +#' +#' If `seed` is supplied, the previous R random state is restored after +#' generation. This gives reproducible output without advancing the caller's RNG +#' stream. Formula variables are rejected loudly until the formula/dependency +#' milestone promotes the spike evaluator into production. +#' #' @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 @@ -264,7 +294,8 @@ #' #' @return A data frame with one column per `mock_spec` variable and `n` rows. #' @family mock generation APIs -#' @seealso [mock_spec()], [mock_continuous()], [mock_spec_from_recodeflow()] +#' @seealso [mock_spec()], [mock_continuous()], [mock_spec_from_recodeflow()], +#' [postprocess_mock_data()], [generate_mock_data_simstudy()] #' #' @examples #' spec <- mock_spec( diff --git a/R/mock_spec_postprocess.R b/R/mock_spec_postprocess.R index fc1b671..d2751d4 100644 --- a/R/mock_spec_postprocess.R +++ b/R/mock_spec_postprocess.R @@ -6,6 +6,7 @@ # value collisions. # ============================================================================== +#' @noRd .postprocess_empty_diagnostics <- function(spec, n) { variables <- lapply(spec$variables, function(variable) { garbage_rule_names <- names(variable$garbage_rules) @@ -38,6 +39,7 @@ ) } +#' @noRd .values_match_codes <- function(values, codes) { if (length(codes) == 0) { return(rep(FALSE, length(values))) @@ -46,6 +48,7 @@ as.character(values) %in% as.character(codes) } +#' @noRd .sample_postprocess_indices <- function(candidates, n, avoid = integer(0)) { if (n == 0) { return(integer(0)) @@ -65,6 +68,7 @@ ) } +#' @noRd .coerce_postprocess_values <- function(values, variable, target) { if (inherits(target, "factor")) { return(as.character(values)) @@ -105,6 +109,7 @@ as.character(values) } +#' @noRd .assign_postprocess_values <- function(target, indices, values) { if (length(indices) == 0) { return(target) @@ -121,6 +126,7 @@ target } +#' @noRd .generate_garbage_for_rule <- function(rule, variable, n) { if (n == 0) { return(vector(mode = "character", length = 0)) @@ -152,10 +158,14 @@ values } +#' @noRd .ordered_garbage_rule_names <- function(rule_names) { + # Keep the long-standing garbage convention deterministic: low rules run + # before high rules; any future rule names follow in caller order. c(intersect(c("low", "high"), rule_names), setdiff(rule_names, c("low", "high"))) } +#' @noRd .postprocess_missing <- function(values, variable, diagnostics) { if (length(variable$missing_codes) == 0) { return(list(values = values, diagnostics = diagnostics)) @@ -169,6 +179,8 @@ } available <- seq_along(values) + # Record values that naturally collide with declared missing codes before + # assigning any new missing codes; this is the auditability contract. preexisting <- which(.values_match_codes(values, variable$missing_codes)) assigned <- integer(0) assigned_codes <- character(0) @@ -202,6 +214,7 @@ list(values = values, diagnostics = diagnostics) } +#' @noRd .postprocess_garbage <- function(values, variable, diagnostics) { if (length(variable$garbage_rules) == 0) { return(list(values = values, diagnostics = diagnostics)) @@ -225,6 +238,9 @@ diagnostics$assigned_missing_indices, diagnostics$preexisting_missing_code_indices ) + # Garbage must not overwrite either assigned missing rows or naturally drawn + # missing-code collisions, otherwise diagnostics would no longer describe the + # returned data. valid_idx <- setdiff(which(!is.na(values)), protected_idx) remaining_idx <- valid_idx @@ -268,6 +284,7 @@ list(values = values, diagnostics = diagnostics) } +#' @noRd .postprocess_variable <- function(values, variable, diagnostics) { missing_result <- .postprocess_missing(values, variable, diagnostics) garbage_result <- .postprocess_garbage( @@ -297,12 +314,22 @@ #' @return A data frame with post-processing applied. #' #' @details -#' Diagnostics are stored as a data-frame attribute. Base R subsetting and some -#' downstream tools may drop attributes, so preserve the original post-processed -#' object when diagnostics are part of the audit trail. +#' Missing-code diagnostics separate values that were naturally drawn as a +#' declared missing code (`preexisting_missing_code_indices`) from values that +#' were assigned by post-processing (`assigned_missing_indices`). Garbage rules +#' are applied only to rows that are not missing-code diagnostics, preserving the +#' audit trail for collision cases such as a valid category code that is also a +#' declared missing code. +#' +#' Garbage rules are applied in canonical order: `low`, then `high`, then any +#' other named rules in caller order. Diagnostics are stored as a data-frame +#' attribute. Base R subsetting and some downstream tools may drop attributes, +#' so preserve the original post-processed object when diagnostics are part of +#' the audit trail. #' #' @family mock generation APIs -#' @seealso [generate_mock_data_native()], [mock_spec()] +#' @seealso [generate_mock_data_native()], [generate_mock_data_simstudy()], +#' [mock_spec()] #' #' @examples #' spec <- mock_categorical( diff --git a/R/mock_spec_recodeflow.R b/R/mock_spec_recodeflow.R index f713a15..bcf0f52 100644 --- a/R/mock_spec_recodeflow.R +++ b/R/mock_spec_recodeflow.R @@ -5,6 +5,7 @@ # normalized mock_spec representation. # ============================================================================== +#' @noRd .read_recodeflow_table <- function(x, label) { if (is.data.frame(x)) { return(x) @@ -14,6 +15,8 @@ if (!file.exists(x)) { stop(label, " file does not exist: ", x, call. = FALSE) } + # Pin CSV parsing so path inputs behave like data-frame inputs for the + # recodeflow conventions MockData understands. return(read.csv( x, stringsAsFactors = FALSE, @@ -25,10 +28,12 @@ stop(label, " must be a data frame or a single CSV path.", call. = FALSE) } +#' @noRd .is_blank <- function(x) { is.null(x) || length(x) == 0 || is.na(x[1]) || trimws(as.character(x[1])) == "" } +#' @noRd .row_value <- function(row, name, default = NA) { if (!name %in% names(row)) { return(default) @@ -42,6 +47,7 @@ value } +#' @noRd .row_character <- function(row, name, default = NA_character_) { value <- .row_value(row, name, default) if (.is_blank(value)) { @@ -50,6 +56,7 @@ as.character(value) } +#' @noRd .row_numeric <- function(row, name, default = NA_real_) { value <- .row_value(row, name, default) if (.is_blank(value)) { @@ -69,6 +76,7 @@ numeric_value } +#' @noRd .recodeflow_required_columns <- function(data, required, label) { missing <- setdiff(required, names(data)) if (length(missing) > 0) { @@ -76,6 +84,7 @@ } } +#' @noRd .filter_recodeflow_by_database <- function(data, databaseStart, allow_empty = TRUE) { if (is.null(databaseStart)) { return(data) @@ -87,9 +96,12 @@ ) } + # databaseStart fields are comma-separated tokens, not substrings; this keeps + # cycles such as cchs2017 and cchs2017_2018_p distinct. data[.database_start_matches(data$databaseStart, databaseStart, allow_empty = allow_empty), , drop = FALSE] } +#' @noRd .filter_recodeflow_details <- function(variable_details, variable, databaseStart) { if (is.null(variable_details)) { return(NULL) @@ -99,6 +111,7 @@ .filter_recodeflow_by_database(details, databaseStart, allow_empty = TRUE) } +#' @noRd .recodeflow_variable_kind <- function(var_row) { rtype <- tolower(.row_character(var_row, "rType", "")) variable_type <- tolower(.row_character(var_row, "variableType", "")) @@ -121,6 +134,7 @@ ) } +#' @noRd .recodeflow_rtype <- function(var_row, kind) { rtype <- tolower(.row_character(var_row, "rType", "")) if (rtype != "") { @@ -138,6 +152,7 @@ ) } +#' @noRd .parse_single_date <- function(value) { if (.is_blank(value)) { return(NULL) @@ -151,6 +166,7 @@ NULL } +#' @noRd .recodeflow_valid_rows <- function(details) { if (is.null(details) || nrow(details) == 0) { return(details) @@ -159,6 +175,8 @@ rec_start <- as.character(details$recStart) rec_end <- if ("recEnd" %in% names(details)) as.character(details$recEnd) else rep("", nrow(details)) + # Functional and derived rows live in recStart; recEnd carries missing-code + # semantics such as NA::a / NA::b. keep <- !is.na(rec_start) & rec_start != "" & rec_start != "else" & @@ -170,6 +188,7 @@ details[keep, , drop = FALSE] } +#' @noRd .recodeflow_range <- function(details, variable, kind) { valid_rows <- .recodeflow_valid_rows(details) if (is.null(valid_rows) || nrow(valid_rows) == 0) { @@ -199,6 +218,7 @@ stop("Variable '", variable, "' has no parseable ", kind, " range in recStart.", call. = FALSE) } +#' @noRd .recodeflow_missing <- function(details) { if (is.null(details) || nrow(details) == 0 || !"recEnd" %in% names(details)) { return(list(codes = character(0), proportions = numeric(0))) @@ -223,6 +243,7 @@ ) } +#' @noRd .recodeflow_distribution <- function(var_row, details) { distribution <- tolower(.row_character(var_row, "distribution", "")) if (distribution != "") { @@ -245,6 +266,7 @@ params$distribution %||% "uniform" } +#' @noRd .recodeflow_garbage_rules <- function(var_row) { rules <- list() @@ -269,6 +291,7 @@ rules } +#' @noRd .recodeflow_provenance <- function(variable, databaseStart = NULL) { provenance <- list(adapter = "recodeflow", source = variable) if (!is.null(databaseStart)) { @@ -277,6 +300,7 @@ provenance } +#' @noRd .recodeflow_to_spec_variable <- function(var_row, details, databaseStart) { variable <- .row_character(var_row, "variable") kind <- .recodeflow_variable_kind(var_row) @@ -364,6 +388,12 @@ #' generated. Set `exclude_derived = FALSE` only when you want those rows to #' appear in the adapter input and fail or be handled by later formula support. #' +#' CSV path inputs are read with `stringsAsFactors = FALSE`, +#' `check.names = FALSE`, and `na.strings = c("", "NA")` so path-based inputs +#' preserve recodeflow column names and treat blank metadata cells like missing +#' values. The adapter normalizes `rType = "numeric"` to `"double"` to match +#' the v0.4 `mock_spec` type vocabulary. +#' #' @param variables Data frame or CSV path for recodeflow-style `variables` #' metadata. #' @param variable_details Data frame, CSV path, or `NULL` for recodeflow-style @@ -380,7 +410,7 @@ #' @return A validated `mock_spec` object. #' @family mock specification APIs #' @seealso [mock_spec()], [mock_continuous()], [mock_categorical()], -#' [mock_date()] +#' [mock_date()], [generate_mock_data_native()], [postprocess_mock_data()] #' #' @examples #' variables <- data.frame( @@ -399,6 +429,12 @@ #' spec <- mock_spec_from_recodeflow(variables, details) #' validate_mock_spec(spec) #' +#' variables_file <- tempfile(fileext = ".csv") +#' details_file <- tempfile(fileext = ".csv") +#' write.csv(variables, variables_file, row.names = FALSE) +#' write.csv(details, details_file, row.names = FALSE) +#' spec_from_files <- mock_spec_from_recodeflow(variables_file, details_file) +#' #' @export mock_spec_from_recodeflow <- function(variables, variable_details = NULL, diff --git a/R/mock_spec_simstudy.R b/R/mock_spec_simstudy.R index 2535623..a6b08fc 100644 --- a/R/mock_spec_simstudy.R +++ b/R/mock_spec_simstudy.R @@ -5,6 +5,7 @@ # installed. MockData still owns post-processing and diagnostics. # ============================================================================== +#' @noRd .require_simstudy <- function() { if (!requireNamespace("simstudy", quietly = TRUE)) { stop( @@ -24,6 +25,7 @@ invisible(TRUE) } +#' @noRd .check_simstudy_variable <- function(variable) { if (identical(variable$name, "id")) { stop( @@ -34,6 +36,8 @@ } if (variable$type == "categorical" && any(grepl(";", as.character(variable$levels), fixed = TRUE))) { + # simstudy encodes categorical probabilities and labels as semicolon- + # delimited strings; labels containing ';' cannot round-trip safely. stop( "Variable '", variable$name, "' has categorical level(s) containing ';', which simstudy uses as a delimiter.", @@ -44,6 +48,7 @@ invisible(TRUE) } +#' @noRd .simstudy_definition <- function(def, variable) { .check_simstudy_variable(variable) @@ -65,6 +70,8 @@ probabilities <- rep(1 / length(variable$levels), length(variable$levels)) } + # defData() expects categorical probabilities in formula and labels in + # variance, both as semicolon-delimited vectors. return(simstudy::defData( dtDefs = def, varname = variable$name, @@ -81,6 +88,7 @@ ) } +#' @noRd .normalize_simstudy_categorical <- function(values, variable) { value_chr <- as.character(values) levels <- as.character(variable$levels) @@ -88,6 +96,8 @@ return(value_chr) } + # Older simstudy versions returned 1-based category indices in some paths; + # keep the adapter explicit so cross-version drift cannot masquerade as data. index <- suppressWarnings(as.integer(value_chr)) if (!any(is.na(index)) && all(index >= 1 & index <= length(levels))) { return(levels[index]) @@ -101,6 +111,7 @@ ) } +#' @noRd .simstudy_can_generate <- function(variable) { if (variable$type == "categorical") { return(TRUE) @@ -110,14 +121,17 @@ identical(tolower(variable$distribution %||% "uniform"), "uniform") } +#' @noRd .simstudy_variables <- function(spec) { Filter(.simstudy_can_generate, spec$variables) } +#' @noRd .native_only_variables <- function(spec) { Filter(function(variable) !.simstudy_can_generate(variable), spec$variables) } +#' @noRd .generate_simstudy_baseline <- function(variables, n) { if (length(variables) == 0) { return(.empty_native_data(n)) @@ -152,6 +166,7 @@ generated } +#' @noRd .generate_native_only_baseline <- function(variables, n) { if (length(variables) == 0) { return(.empty_native_data(n)) @@ -174,6 +189,19 @@ #' truncated normal ranges and calendar dates, are generated by MockData's native #' path inside the same seeded call. #' +#' @details +#' `simstudy` is an optional `Suggests` dependency and is GPL-3 licensed; +#' MockData remains MIT licensed by keeping this backend soft-gated and by +#' retaining [generate_mock_data_native()] as the default engine. Use this +#' backend when `simstudy` is installed and you want to exercise the optional +#' engine path. It currently delegates only categorical and uniform continuous +#' baseline generation to `simstudy`; unsupported variables are routed through +#' MockData's native backend so that a single specification can mix capabilities. +#' +#' Missing-code assignment, garbage values, and diagnostics are not delegated to +#' `simstudy`. They remain MockData-owned post-processing so both backends share +#' the same auditability contract. +#' #' @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 @@ -202,6 +230,8 @@ generate_mock_data_simstudy <- function(spec, n, seed = NULL) { .with_mock_seed(seed, { simstudy_data <- .generate_simstudy_baseline(simstudy_variables, n) + # Normal/date variables stay native until MockData has an explicit contract + # for mapping their range and calendar semantics into simstudy definitions. native_data <- .generate_native_only_baseline(native_only_variables, n) columns <- c(simstudy_data, native_data) From 3d448df827331d9e201860e0ee6cf54665b08b43 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 06:26:10 -0400 Subject: [PATCH 15/41] Clarify v0.4 postprocess and simstudy notes --- NEWS.md | 7 ++++++- R/mock_spec_postprocess.R | 11 +++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index 9bdd9b5..3fcdd78 100644 --- a/NEWS.md +++ b/NEWS.md @@ -21,13 +21,18 @@ distinguish assigned missing/garbage rows from naturally drawn values. - Post-processing diagnostics now protect naturally drawn missing-code collisions from later garbage assignment, apply garbage rules in canonical - `low` -> `high` -> other order, and stop on repeated post-processing. + `low` -> `high` -> other order, and stop on repeated post-processing. This + prevents silent diagnostic drift when a naturally drawn missing-code value + would otherwise be overwritten by garbage assignment. - Added `generate_mock_data_simstudy()` as a soft-gated optional backend for baseline categorical and uniform continuous generation when `simstudy` is installed, with native generation retained for MockData-specific semantics. - The optional `simstudy` backend is kept in `Suggests`, requires `simstudy >= 0.8.1`, and validates categorical labels before converting generated values back into MockData's `mock_spec` levels. +- The optional `simstudy` backend now rejects variables named `id`, which + conflicts with `simstudy`'s generated row identifier, and normalizes + categorical output through an explicit label-or-index validation path. - Added forward-compatible specification fields: `spec_version`, `provenance`, and `model_hint`. - Existing v0.3 generator APIs remain available while v0.4 internals are built. diff --git a/R/mock_spec_postprocess.R b/R/mock_spec_postprocess.R index d2751d4..8b394f2 100644 --- a/R/mock_spec_postprocess.R +++ b/R/mock_spec_postprocess.R @@ -322,10 +322,13 @@ #' declared missing code. #' #' Garbage rules are applied in canonical order: `low`, then `high`, then any -#' other named rules in caller order. Diagnostics are stored as a data-frame -#' attribute. Base R subsetting and some downstream tools may drop attributes, -#' so preserve the original post-processed object when diagnostics are part of -#' the audit trail. +#' other named rules in caller order. Each garbage rule is a named list with a +#' `proportion` field and a `range` field using MockData range notation, for +#' example `list(high = list(proportion = 0.05, range = "[150, 200]"))`. +#' +#' Diagnostics are stored as a data-frame attribute. Base R subsetting and some +#' downstream tools may drop attributes, so preserve the original post-processed +#' object when diagnostics are part of the audit trail. #' #' @family mock generation APIs #' @seealso [generate_mock_data_native()], [generate_mock_data_simstudy()], From 9fe335e7cd3e9fd7ebe4732a984ef47619f324b7 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 06:33:18 -0400 Subject: [PATCH 16/41] Route create_mock_data through mock_spec pipeline --- NEWS.md | 3 + R/create_mock_data.R | 109 ++++++++++++++++++++- tests/testthat/test-create-mock-data-v04.R | 105 ++++++++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 tests/testthat/test-create-mock-data-v04.R diff --git a/NEWS.md b/NEWS.md index 3fcdd78..c92e2a5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -33,6 +33,9 @@ - The optional `simstudy` backend now rejects variables named `id`, which conflicts with `simstudy`'s generated row identifier, and normalizes categorical output through an explicit label-or-index validation path. +- `create_mock_data()` now attempts the v0.4 `mock_spec` pipeline in strict + mode for supported recodeflow metadata, while retaining the legacy `create_*` + dispatch path for unsupported v0.4 backend features and lenient generation. - Added forward-compatible specification fields: `spec_version`, `provenance`, and `model_hint`. - Existing v0.3 generator APIs remain available while v0.4 internals are built. diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 2f5494c..158dc41 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -1,3 +1,83 @@ +#' @noRd +.create_mock_data_v04_database_filter <- function(variables, databaseStart) { + if (!is.null(databaseStart) && "databaseStart" %in% names(variables)) { + return(databaseStart) + } + + NULL +} + +#' @noRd +.create_mock_data_v04_native_supported <- function(spec) { + all(vapply(spec$variables, function(variable) { + formula <- variable$formula + has_formula <- !is.null(formula) && + !(is.character(formula) && length(formula) == 1 && (is.na(formula) || trimws(formula) == "")) + if (has_formula) { + return(FALSE) + } + + distribution <- tolower(variable$distribution %||% "uniform") + if (variable$type == "continuous") { + return(distribution %in% c("uniform", "normal")) + } + if (variable$type == "categorical") { + return(TRUE) + } + if (variable$type == "date") { + return(distribution == "uniform" && identical(variable$source_format %||% "analysis", "analysis")) + } + + FALSE + }, logical(1))) +} + +#' @noRd +.create_mock_data_v04 <- function(databaseStart, + variables, + variable_details, + n, + seed, + verbose = FALSE) { + if (!is.null(databaseStart) && + !"databaseStart" %in% names(variables) && + "databaseStart" %in% names(variable_details)) { + if (isTRUE(verbose)) { + message( + "v0.4 mock_spec pipeline requires variable-level databaseStart when ", + "detail-level databaseStart filtering is needed; using legacy ", + "create_* dispatch." + ) + } + return(NULL) + } + + spec <- mock_spec_from_recodeflow( + variables = variables, + variable_details = variable_details, + databaseStart = .create_mock_data_v04_database_filter(variables, databaseStart), + role = "enabled" + ) + + if (!.create_mock_data_v04_native_supported(spec)) { + if (isTRUE(verbose)) { + message( + "v0.4 mock_spec pipeline does not yet support every requested ", + "variable; using legacy create_* dispatch." + ) + } + return(NULL) + } + + if (isTRUE(verbose)) { + message("Generating via v0.4 mock_spec pipeline.") + } + + 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) +} + #' Create mock data from configuration files #' #' @description @@ -38,8 +118,16 @@ #' @return Data frame with n rows and one column per enabled variable. #' #' @details -#' **v0.3.0 API**: This function now follows the "recodeflow pattern" where it passes -#' full metadata data frames to create_* functions, which handle internal filtering. +#' **v0.4.0 transition**: In strict mode, this function first attempts to use +#' the v0.4 `mock_spec` pipeline: [mock_spec_from_recodeflow()], +#' [generate_mock_data_native()], and [postprocess_mock_data()]. If the metadata +#' requests a feature not yet supported by the v0.4 native backend, it falls +#' back to the v0.3 `create_*` dispatch path so existing users can migrate +#' gradually. +#' +#' **v0.3.0 API**: This function follows the "recodeflow pattern" where it passes +#' full metadata data frames to create_* functions, which handle internal +#' filtering. #' #' **Generation process**: #' \enumerate{ @@ -155,6 +243,23 @@ create_mock_data <- function(databaseStart, stop("variables must have a 'variableType' column") } + # ========== v0.4 PIPELINE PATH ========== + + if (isTRUE(validate) && !is.null(variable_details)) { + 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) + } + } + # ========== FILTER FOR ENABLED VARIABLES ========== if (verbose) message("Filtering for enabled variables...") diff --git a/tests/testthat/test-create-mock-data-v04.R b/tests/testthat/test-create-mock-data-v04.R new file mode 100644 index 0000000..916a82b --- /dev/null +++ b/tests/testthat/test-create-mock-data-v04.R @@ -0,0 +1,105 @@ +test_that("create_mock_data uses the v0.4 pipeline for strict supported metadata", { + variables <- data.frame( + variable = "smoking", + variableType = "Categorical", + rType = "character", + role = "enabled", + garbage_low_prop = 0.1, + garbage_low_range = "[-2, 0]", + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = "smoking", + recStart = c("1", "2", "97"), + recEnd = c("copy", "copy", "NA::b"), + proportion = c(0.6, 0.3, 0.1), + stringsAsFactors = FALSE + ) + + result <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 100, + seed = 101 + ) + diagnostics <- attr(result, "mockdata_diagnostics")$variables$smoking + + expect_equal(names(result), "smoking") + expect_true(all(result$smoking %in% c("1", "2", "97", "-2", "-1", "0"))) + expect_equal(length(diagnostics$assigned_missing_indices), 10) + expect_equal(length(diagnostics$assigned_garbage_indices$low), 9) + expect_length(intersect( + diagnostics$assigned_missing_indices, + diagnostics$assigned_garbage_indices$low + ), 0) +}) + +test_that("create_mock_data keeps legacy fallback for unsupported v0.4 backend features", { + variables <- data.frame( + variable = "time_to_visit", + variableType = "Continuous", + rType = "double", + role = "enabled", + distribution = "exponential", + rate = 0.5, + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = "time_to_visit", + recStart = "[0, 10]", + recEnd = "copy", + proportion = 1, + stringsAsFactors = FALSE + ) + + expect_message( + result <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 50, + seed = 202, + verbose = TRUE + ), + "legacy create_\\* dispatch" + ) + + expect_equal(names(result), "time_to_visit") + expect_equal(nrow(result), 50) + expect_true(is.numeric(result$time_to_visit)) + expect_null(attr(result, "mockdata_diagnostics")) +}) + +test_that("create_mock_data keeps legacy detail-level databaseStart filtering", { + variables <- data.frame( + variable = "smoking", + variableType = "Categorical", + rType = "character", + role = "enabled", + stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = c("smoking", "smoking"), + recStart = c("1", "2"), + recEnd = c("copy", "copy"), + proportion = c(1, 1), + databaseStart = c("cycle1", "cycle10"), + stringsAsFactors = FALSE + ) + + expect_message( + result <- create_mock_data( + databaseStart = "cycle1", + variables = variables, + variable_details = variable_details, + n = 10, + seed = 303, + verbose = TRUE + ), + "detail-level databaseStart filtering" + ) + + expect_equal(unique(result$smoking), "1") + expect_null(attr(result, "mockdata_diagnostics")) +}) From ca00377c84d5035e176743a830f9cfb3a1a8a63e Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 07:16:49 -0400 Subject: [PATCH 17/41] Document and test create_mock_data v04 routing --- NEWS.md | 4 + R/create_mock_data.R | 60 ++++++++--- tests/testthat/test-create-mock-data-v04.R | 111 ++++++++++++++++++++- 3 files changed, 160 insertions(+), 15 deletions(-) diff --git a/NEWS.md b/NEWS.md index c92e2a5..434a5e8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -36,6 +36,10 @@ - `create_mock_data()` now attempts the v0.4 `mock_spec` pipeline in strict mode for supported recodeflow metadata, while retaining the legacy `create_*` dispatch path for unsupported v0.4 backend features and lenient generation. + The v0.4 path attaches `mockdata_diagnostics` and uses `seed` for baseline + generation plus `seed + 1` for post-processing, so exact seeded output may + differ from v0.3.x even when the public seed is unchanged. Verbose mode now + reports whether the v0.4 or legacy path was chosen. - Added forward-compatible specification fields: `spec_version`, `provenance`, and `model_hint`. - Existing v0.3 generator APIs remain available while v0.4 internals are built. diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 158dc41..2ba23ec 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -8,28 +8,35 @@ } #' @noRd -.create_mock_data_v04_native_supported <- function(spec) { - all(vapply(spec$variables, function(variable) { +.create_mock_data_v04_unsupported_variables <- function(spec) { + unsupported <- vapply(spec$variables, function(variable) { formula <- variable$formula has_formula <- !is.null(formula) && !(is.character(formula) && length(formula) == 1 && (is.na(formula) || trimws(formula) == "")) if (has_formula) { - return(FALSE) + return(TRUE) } distribution <- tolower(variable$distribution %||% "uniform") if (variable$type == "continuous") { - return(distribution %in% c("uniform", "normal")) + return(!distribution %in% c("uniform", "normal")) } if (variable$type == "categorical") { - return(TRUE) + return(FALSE) } if (variable$type == "date") { - return(distribution == "uniform" && identical(variable$source_format %||% "analysis", "analysis")) + return(!(distribution == "uniform" && identical(variable$source_format %||% "analysis", "analysis"))) } - FALSE - }, logical(1))) + TRUE + }, logical(1)) + + names(spec$variables)[unsupported] +} + +#' @noRd +.create_mock_data_v04_native_supported <- function(spec) { + length(.create_mock_data_v04_unsupported_variables(spec)) == 0 } #' @noRd @@ -59,11 +66,13 @@ role = "enabled" ) - if (!.create_mock_data_v04_native_supported(spec)) { + unsupported <- .create_mock_data_v04_unsupported_variables(spec) + if (length(unsupported) > 0) { if (isTRUE(verbose)) { message( "v0.4 mock_spec pipeline does not yet support every requested ", - "variable; using legacy create_* dispatch." + "variable; using legacy create_* dispatch. Unsupported variable(s): ", + paste(unsupported, collapse = ", ") ) } return(NULL) @@ -74,6 +83,9 @@ } 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) } @@ -115,7 +127,10 @@ #' affected variable is skipped. #' @param verbose Logical. Whether to print progress messages (default FALSE). #' -#' @return Data frame with n rows and one column per enabled variable. +#' @return Data frame with n rows and one column per enabled variable. When the +#' v0.4 `mock_spec` path is used, the result also carries a +#' `mockdata_diagnostics` attribute from [postprocess_mock_data()]. Legacy +#' fallback paths return plain data frames without that attribute. #' #' @details #' **v0.4.0 transition**: In strict mode, this function first attempts to use @@ -125,6 +140,16 @@ #' back to the v0.3 `create_*` dispatch path so existing users can migrate #' gradually. #' +#' The wrapper deliberately stays on the legacy path when `validate = FALSE`, +#' when `variable_details = NULL`, when detail-level `databaseStart` filtering is +#' needed but the variables metadata has no `databaseStart` column, or when a +#' variable uses a feature not yet supported by the v0.4 native backend. Set +#' `verbose = TRUE` to see which path was chosen. +#' +#' 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. +#' #' **v0.3.0 API**: This function follows the "recodeflow pattern" where it passes #' full metadata data frames to create_* functions, which handle internal #' filtering. @@ -194,6 +219,9 @@ #' } #' #' @family generators +#' @family mock generation APIs +#' @seealso [mock_spec_from_recodeflow()], [generate_mock_data_native()], +#' [postprocess_mock_data()], [generate_mock_data_simstudy()], [mock_spec()] #' @export create_mock_data <- function(databaseStart, variables, @@ -245,7 +273,15 @@ create_mock_data <- function(databaseStart, # ========== v0.4 PIPELINE PATH ========== - if (isTRUE(validate) && !is.null(variable_details)) { + if (!isTRUE(validate)) { + if (verbose) { + message("validate = FALSE requested; using legacy create_* dispatch.") + } + } else if (is.null(variable_details)) { + if (verbose) { + message("variable_details = NULL; using legacy create_* fallback dispatch.") + } + } else { v04_result <- .create_mock_data_v04( databaseStart = databaseStart, variables = variables, diff --git a/tests/testthat/test-create-mock-data-v04.R b/tests/testthat/test-create-mock-data-v04.R index 916a82b..781ffde 100644 --- a/tests/testthat/test-create-mock-data-v04.R +++ b/tests/testthat/test-create-mock-data-v04.R @@ -27,8 +27,10 @@ test_that("create_mock_data uses the v0.4 pipeline for strict supported metadata expect_equal(names(result), "smoking") expect_true(all(result$smoking %in% c("1", "2", "97", "-2", "-1", "0"))) - expect_equal(length(diagnostics$assigned_missing_indices), 10) - expect_equal(length(diagnostics$assigned_garbage_indices$low), 9) + expect_true(length(diagnostics$assigned_missing_indices) >= 8) + expect_true(length(diagnostics$assigned_missing_indices) <= 12) + expect_true(length(diagnostics$assigned_garbage_indices$low) >= 7) + expect_true(length(diagnostics$assigned_garbage_indices$low) <= 11) expect_length(intersect( diagnostics$assigned_missing_indices, diagnostics$assigned_garbage_indices$low @@ -62,7 +64,7 @@ test_that("create_mock_data keeps legacy fallback for unsupported v0.4 backend f seed = 202, verbose = TRUE ), - "legacy create_\\* dispatch" + "Unsupported variable\\(s\\): time_to_visit" ) expect_equal(names(result), "time_to_visit") @@ -103,3 +105,106 @@ test_that("create_mock_data keeps legacy detail-level databaseStart filtering", expect_equal(unique(result$smoking), "1") expect_null(attr(result, "mockdata_diagnostics")) }) + +test_that("create_mock_data announces validate FALSE legacy path", { + 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_message( + result <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 20, + seed = 404, + validate = FALSE, + verbose = TRUE + ), + "validate = FALSE requested" + ) + + expect_equal(names(result), "smoking") + expect_null(attr(result, "mockdata_diagnostics")) +}) + +test_that("create_mock_data announces variable_details NULL legacy fallback path", { + variables <- data.frame( + variable = "age", + variableType = "Continuous", + rType = "integer", + role = "enabled", + stringsAsFactors = FALSE + ) + + expect_warning( + expect_message( + result <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = NULL, + n = 20, + seed = 505, + verbose = TRUE + ), + "variable_details = NULL" + ), + "No variable_details rows found" + ) + + expect_equal(names(result), "age") + expect_null(attr(result, "mockdata_diagnostics")) +}) + +test_that("create_mock_data v0.4 and legacy paths are distributionally aligned", { + 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.65, 0.35), + stringsAsFactors = FALSE + ) + + v04 <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 5000, + seed = 606 + ) + legacy <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 5000, + seed = 606, + validate = FALSE + ) + + expect_equal(sort(unique(v04$smoking)), sort(unique(legacy$smoking))) + expect_equal( + unname(prop.table(table(v04$smoking))[c("1", "2")]), + unname(prop.table(table(legacy$smoking))[c("1", "2")]), + tolerance = 0.03 + ) + expect_type(attr(v04, "mockdata_diagnostics"), "list") + expect_null(attr(legacy, "mockdata_diagnostics")) +}) From 54047316b57c765fa8f79eafc9120041b1a948bf Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 08:57:07 -0400 Subject: [PATCH 18/41] Add v04 functions to pkgdown reference index --- _pkgdown.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/_pkgdown.yml b/_pkgdown.yml index 5347b43..d17d53a 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -13,6 +13,32 @@ footer: developed_by:

Developed by Juan Li, Douglas Manuel, and recodeflow contributors.

reference: +- title: v0.4 specification API + desc: > + Build and validate normalized `mock_spec` objects using direct helpers or + composable variable constructors. + contents: + - mock_spec + - mock_continuous + - mock_categorical + - mock_date + - mock_spec_continuous + - mock_spec_categorical + - mock_spec_date + - is_mock_spec + - validate_mock_spec + - mock_spec_model_hints + +- title: v0.4 adapters and generation pipeline + desc: > + Convert recodeflow metadata into `mock_spec` objects, generate baseline data, + and apply post-processing diagnostics. + contents: + - mock_spec_from_recodeflow + - generate_mock_data_native + - generate_mock_data_simstudy + - postprocess_mock_data + - title: Main generation functions desc: > Generate categorical, continuous, date, and survival variables. Use `create_mock_data()` From a7c224eb5d79de345c81edf22cafa9f15c96459f Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 09:10:04 -0400 Subject: [PATCH 19/41] Start v04 documentation sprint --- DESCRIPTION | 2 +- README.md | 50 ++++++- _pkgdown.yml | 1 + development/v04-documentation-sprint.md | 57 +++++++ vignettes/getting-started-v04.qmd | 189 ++++++++++++++++++++++++ 5 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 development/v04-documentation-sprint.md create mode 100644 vignettes/getting-started-v04.qmd diff --git a/DESCRIPTION b/DESCRIPTION index dbb0f20..b10b0fc 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: MockData Title: Generate Mock Data from Metadata Specifications -Version: 0.3.0 +Version: 0.4.0.9000 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/README.md b/README.md index fef0d79..715f74a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Lifecycle: experimental](https://img.shields.io/badge/lifecycle-experimental-orange.svg)](https://lifecycle.r-lib.org/articles/stages.html#experimental) -[![Version: 0.3.0](https://img.shields.io/badge/version-0.3.0-blue.svg)](https://github.com/Big-Life-Lab/MockData) +[![Version: 0.4.0-dev](https://img.shields.io/badge/version-0.4.0--dev-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) @@ -12,10 +12,12 @@ **Status: Experimental, pre-release software** MockData is a work-in-progress R package for generating mock testing data from -small metadata specifications. 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. +small metadata specifications. The `dev` branch now contains the v0.4 +`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, +especially when paired with recodeflow-style metadata (see below), but it should +be treated as experimental infrastructure rather than a stable released package. 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 @@ -33,10 +35,45 @@ the generated data before using it in any workflow that matters. **Current development limitations:** - APIs may change before a formal release -- Error handling is too permissive and can fail with warnings instead of stopping +- Some legacy v0.3-compatible paths still fall back with warnings; the v0.4 + `mock_spec` path is stricter and records diagnostics - The test suite does not yet cover every important edge case - Generated data should be manually checked against your intended metadata rules +**v0.4 direct API example** + +The v0.4 API separates specification, baseline generation, and post-processing. +That makes the generated values easier to inspect and audit. + +```r +library(MockData) + +spec <- mock_spec( + mock_spec_continuous( + "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "integer" + ), + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character", + missing_codes = "unknown", + missing_proportions = 0.05 + ) +) + +baseline <- generate_mock_data_native(spec, n = 100, seed = 1) +mock_data <- postprocess_mock_data(baseline, spec, seed = 2) + +head(mock_data) +attr(mock_data, "mockdata_diagnostics")$variables$smoking +``` + **30-second standalone example** For a quick numeric variable, `create_con_var()` can use two small @@ -221,6 +258,7 @@ devtools::install_local("~/github/mock-data") **Tutorials:** +- [v0.4 getting started](vignettes/getting-started-v04.qmd) - Direct `mock_spec`, recodeflow adapter, and diagnostics workflow - [Getting started](vignettes/getting-started.qmd) - Complete tutorial from single variables to full datasets - [For recodeflow users](vignettes/for-recodeflow-users.qmd) - Using MockData with existing metadata - [Survival data](vignettes/tutorial-survival-data.qmd) - Time-to-event data and temporal patterns diff --git a/_pkgdown.yml b/_pkgdown.yml index d17d53a..8c02821 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -100,6 +100,7 @@ articles: desc: Learning-oriented step-by-step guides navbar: Tutorials contents: + - getting-started-v04 - getting-started - tutorial-categorical-continuous - tutorial-dates diff --git a/development/v04-documentation-sprint.md b/development/v04-documentation-sprint.md new file mode 100644 index 0000000..ae78e00 --- /dev/null +++ b/development/v04-documentation-sprint.md @@ -0,0 +1,57 @@ +# MockData v0.4 Documentation Sprint + +This sprint treats documentation as implementation validation. The goal is not +only to explain the v0.4 API, but to run realistic user workflows during +vignette and pkgdown builds. + +## Principles + +- Use Divio's four documentation needs: tutorials, how-to guides, reference, and + explanation. +- Keep vignette code executable unless the code genuinely depends on an + external package or private data. +- Prefer small, focused vignettes over one large tour. +- Use seeds in every stochastic example so rendered output is stable. +- Include at least one diagnostics example because the v0.4 pipeline's + auditability contract is a central design change. + +## First pass + +- `getting-started-v04.qmd`: tutorial for the v0.4 `mock_spec` workflow. +- README: update the top-level status and quick example so users see v0.4 + immediately. +- `_pkgdown.yml`: expose the new v0.4 tutorial in site navigation. + +## Follow-up vignettes + +Tutorial: + +- `getting-started-v04.qmd`: linear first-use path. + +How-to: + +- `recodeflow-metadata-v04.qmd`: use existing `variables.csv` and + `variable_details.csv`. +- `diagnostics-and-garbage-v04.qmd`: inspect missing-code and garbage + diagnostics. +- `choosing-a-backend-v04.qmd`: native vs optional `simstudy`. +- `migrating-from-v03-v04.qmd`: seed behavior, diagnostics attribute, + fallback conditions, and compatibility wrappers. + +Explanation: + +- `design-philosophy-v04.qmd`: distill the architecture review, hybrid backend + decision, and mock-data versus synthetic-data boundary. + +Reference: + +- Keep roxygen pages and `_pkgdown.yml` synchronized with exported functions. +- Keep `NEWS.md` as the release-note source of truth. + +## Review checklist + +- Does every vignette render locally? +- Does every code chunk either run or clearly justify `eval: false`? +- Does each vignette commit to one Divio purpose? +- Do examples use the public API exactly as users should use it? +- Are error messages and diagnostics understandable in rendered output? diff --git a/vignettes/getting-started-v04.qmd b/vignettes/getting-started-v04.qmd new file mode 100644 index 0000000..ac95417 --- /dev/null +++ b/vignettes/getting-started-v04.qmd @@ -0,0 +1,189 @@ +--- +title: "Getting started with MockData v0.4" +format: html +vignette: > + %\VignetteIndexEntry{Getting started with MockData v0.4} + %\VignetteEngine{quarto::html} + %\VignetteEncoding{UTF-8} +--- + +```{r} +#| label: setup +#| include: false +input_file <- tryCatch(knitr::current_input(dir = TRUE), error = function(e) NULL) +candidate_roots <- unique(c( + ".", + "..", + if (!is.null(input_file)) file.path(dirname(input_file), "..") +)) +package_root <- NULL +for (candidate in candidate_roots) { + description <- file.path(candidate, "DESCRIPTION") + if (file.exists(description) && + any(grepl("^Package:\\s+MockData\\s*$", readLines(description, warn = FALSE)))) { + package_root <- candidate + break + } +} + +if (!is.null(package_root)) { + devtools::load_all(package_root, quiet = TRUE) +} else { + library(MockData) +} +``` + +::: {.vignette-about} +**About this vignette:** This tutorial introduces the v0.4 `mock_spec` +workflow. All code is executed when the vignette builds, so this page also +serves as a user-flow test for the public API. +::: + +## The v0.4 workflow + +MockData v0.4 separates data generation into three steps: + +1. Create a `mock_spec` +2. Generate baseline valid values +3. Apply missing-code and garbage-value post-processing + +That separation makes it easier to inspect what was requested and what was +changed after generation. + +## Specify variables directly + +Use the direct helpers when you want a small mock dataset without creating CSV +metadata files. + +```{r} +spec <- mock_spec( + mock_spec_continuous( + "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "integer" + ), + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character", + missing_codes = "unknown", + missing_proportions = 0.05 + ) +) + +validate_mock_spec(spec) +``` + +Generate baseline values first. These are values within the intended valid +space. + +```{r} +baseline <- generate_mock_data_native(spec, n = 100, seed = 101) +head(baseline) +``` + +Then apply missing-code and garbage-value rules. This step adds diagnostics as +an attribute on the returned data frame. + +```{r} +mock_data <- postprocess_mock_data(baseline, spec, seed = 102) +head(mock_data) +``` + +```{r} +diagnostics <- attr(mock_data, "mockdata_diagnostics") +diagnostics$variables$smoking +``` + +The diagnostics distinguish values assigned by post-processing from values that +were drawn naturally during baseline generation. + +## Use recodeflow metadata + +If you already have recodeflow-style `variables` and `variable_details` +metadata, adapt those tables to the same `mock_spec` shape. + +```{r} +variables <- data.frame( + variable = c("age", "smoking"), + variableType = c("Continuous", "Categorical"), + rType = c("integer", "character"), + role = c("enabled", "enabled"), + stringsAsFactors = FALSE +) + +variable_details <- data.frame( + variable = c("age", "smoking", "smoking", "smoking"), + recStart = c("[18, 85]", "1", "2", "97"), + recEnd = c("copy", "copy", "copy", "NA::b"), + proportion = c(1, 0.6, 0.3, 0.1), + stringsAsFactors = FALSE +) + +spec_from_metadata <- mock_spec_from_recodeflow(variables, variable_details) +names(spec_from_metadata$variables) +``` + +The adapter preserves recodeflow semantics: valid ranges, categorical +proportions, `recEnd` missing-code rows, and garbage settings. + +```{r} +metadata_baseline <- generate_mock_data_native( + spec_from_metadata, + n = 100, + seed = 201 +) + +metadata_mock <- postprocess_mock_data( + metadata_baseline, + spec_from_metadata, + seed = 202 +) + +head(metadata_mock) +``` + +```{r} +metadata_diag <- attr(metadata_mock, "mockdata_diagnostics") +metadata_diag$variables$smoking$assigned_missing_indices[1:5] +``` + +## Use the compatibility wrapper + +`create_mock_data()` remains available for v0.3-style workflows. In strict mode, +it attempts the v0.4 pipeline for supported metadata and falls back to the legacy +`create_*` dispatch path for features that are not yet supported by the v0.4 +native backend. + +```{r} +wrapped <- create_mock_data( + databaseStart = "example", + variables = variables, + variable_details = variable_details, + n = 100, + seed = 301 +) + +head(wrapped) +``` + +```{r} +!is.null(attr(wrapped, "mockdata_diagnostics")) +``` + +When the v0.4 path is used, `create_mock_data()` returns diagnostics. Legacy +fallback paths return plain data frames without that attribute. + +## Choosing the next function + +- Use `mock_*()` or `mock_spec_*()` for small examples and tests. +- Use `mock_spec_from_recodeflow()` when metadata already exists. +- Use `generate_mock_data_native()` for the default MIT-licensed backend. +- Use `generate_mock_data_simstudy()` only when the optional `simstudy` package + is installed and you want to test that backend. +- Use `postprocess_mock_data()` when you need missing codes, garbage values, or + diagnostics. From 028692f73506b8ebf724b86f2327792035ea6726 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 12:41:06 -0400 Subject: [PATCH 20/41] Add v04 maintainer communication note --- development/v04-documentation-sprint.md | 5 + development/v04-phase-c-comms-note.md | 151 ++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 development/v04-phase-c-comms-note.md diff --git a/development/v04-documentation-sprint.md b/development/v04-documentation-sprint.md index ae78e00..4c138d7 100644 --- a/development/v04-documentation-sprint.md +++ b/development/v04-documentation-sprint.md @@ -21,6 +21,8 @@ vignette and pkgdown builds. - README: update the top-level status and quick example so users see v0.4 immediately. - `_pkgdown.yml`: expose the new v0.4 tutorial in site navigation. +- `v04-phase-c-comms-note.md`: maintainer-facing note for cchsflow, + chmsflow, and recodeflow testing while v0.4 sits on `dev`. ## Follow-up vignettes @@ -42,6 +44,9 @@ Explanation: - `design-philosophy-v04.qmd`: distill the architecture review, hybrid backend decision, and mock-data versus synthetic-data boundary. +- `development/v04-phase-c-comms-note.md`: Phase C maintainer communication + source material; fold relevant parts into migration and recodeflow how-to + docs after maintainer feedback. Reference: diff --git a/development/v04-phase-c-comms-note.md b/development/v04-phase-c-comms-note.md new file mode 100644 index 0000000..0cf79e2 --- /dev/null +++ b/development/v04-phase-c-comms-note.md @@ -0,0 +1,151 @@ +# MockData v0.4 Phase C Maintainer Communication Note + +**Audience**: cchsflow, chmsflow, recodeflow, and MockData maintainers +**Status**: draft for maintainer review +**Branch for testing**: `dev` + +## Short Version + +MockData v0.4 is now available on the `dev` branch for maintainer testing. The +main change is architectural: MockData now normalizes inputs into a `mock_spec`, +then generates data through a native backend, an optional `simstudy` backend, and +a MockData-owned post-processing layer for missing codes, garbage values, and +diagnostics. + +The goal is to make MockData more reliable for recodeflow-style metadata while +preserving the existing public API. No sibling package needs to migrate +immediately for v0.4.0. + +## What Changed + +- `mock_spec` is the normalized internal representation for mock-data + specifications. +- New direct helper APIs exist for simple use cases: + `mock_continuous()`, `mock_categorical()`, and `mock_date()`. +- `mock_spec_from_recodeflow()` converts recodeflow-style `variables.csv` and + `variable_details.csv` metadata into a `mock_spec`. +- `generate_mock_data_native()` generates data from `mock_spec` without optional + dependencies. +- `postprocess_mock_data()` applies MockData-owned missing-code and garbage-data + semantics and attaches a `mockdata_diagnostics` attribute. +- `generate_mock_data_simstudy()` is available for supported advanced cases when + `simstudy >= 0.8.1` is installed. +- `create_mock_data()` now routes supported metadata through the v0.4 pipeline + and falls back to the legacy path for unsupported or explicitly lenient cases. + +## What Did Not Change + +- Existing v0.3 public functions remain available in v0.4.0. +- `create_mock_data()` keeps its existing signature. +- MockData remains focused on mock data for package development, QA, + documentation, examples, and training. +- MockData is not positioning v0.4 as synthetic data for privacy release, + inference, or population-valid analysis. +- MockData remains MIT licensed. `simstudy` is GPL-3 and optional through + `Suggests`, not a required dependency. +- No public function removals are planned before v0.5.0. + +## Compatibility Notes + +- `validate = TRUE` is the default strict path. It uses the v0.4 pipeline when + the requested metadata is supported. +- `validate = FALSE` deliberately uses the legacy, more permissive path. +- `variable_details = NULL` also uses the legacy fallback path. +- The v0.4 path returns a regular data frame with an optional + `mockdata_diagnostics` attribute. Legacy fallback output does not include this + attribute. +- Seeded output may differ from v0.3 even when the same seed is supplied. + v0.4 uses the requested seed for baseline generation and `seed + 1L` for + post-processing so missing-code and garbage injection are reproducible without + sharing the same RNG stream as baseline generation. +- Formula-derived variables, multi-group correlations, and advanced survival + models are intentionally deferred. Unsupported cases should either fail loudly + or route through the legacy path, depending on the public entry point. + +## What We Need Maintainers To Test + +Please test against representative metadata from cchsflow, chmsflow, and +recodeflow projects, especially files that include: + +- categorical variables with `recEnd` missing-code semantics; +- continuous variables with ranges or distribution parameters; +- date variables; +- garbage or invalid-value rules; +- role and `databaseStart` filtering; +- any variables that sibling packages expect MockData to generate today. + +Suggested smoke test: + +```r +devtools::load_all() + +vars <- read.csv("path/to/variables.csv") +details <- read.csv("path/to/variable_details.csv") + +mock <- create_mock_data( + variables = vars, + variable_details = details, + databaseStart = "cycle1", + n = 100, + seed = 123, + validate = TRUE, + verbose = TRUE +) + +str(mock) +attr(mock, "mockdata_diagnostics") +``` + +Also useful: + +```r +spec <- mock_spec_from_recodeflow( + variables = vars, + variable_details = details, + databaseStart = "cycle1" +) + +validate_mock_spec(spec, strict = TRUE) +``` + +## What To Report + +Please report: + +- metadata files or patterns that unexpectedly fall back to the legacy path; +- variables that generated correctly in v0.3 but fail in v0.4; +- variables that generate but have surprising values, types, or missing-code + behavior; +- diagnostics that are hard to interpret; +- any cchsflow/chmsflow/recodeflow assumptions about MockData output that v0.4 + appears to change; +- API ergonomics issues that make the new path hard to explain in documentation. + +## Proposed Timeline + +- v0.4 sits on `dev` while sibling maintainers test representative metadata. +- Documentation sprint work continues on a separate branch and PR. +- After checks, documentation, and maintainer smoke tests are complete, v0.4.0 + can be tagged and merged forward to `main`. +- Any lifecycle deprecation warnings for older APIs should wait until v0.4.x and + only after sibling package maintainers have a clear migration path. + +## Message Template + +Subject: MockData v0.4 available on `dev` for sibling-package testing + +MockData v0.4 is now on the `dev` branch for maintainer testing. It keeps the +existing `create_mock_data()` API, but internally routes supported metadata +through a new `mock_spec` pipeline with native generation and MockData-owned +post-processing diagnostics. + +No immediate migration is required for cchsflow/chmsflow/recodeflow, and no +public function removals are planned before v0.5.0. The main ask is to try +representative `variables.csv` and `variable_details.csv` files against +`create_mock_data(validate = TRUE, verbose = TRUE)` and report any unexpected +fallbacks, failures, or output changes. + +The key user-visible differences are that v0.4 output may include a +`mockdata_diagnostics` attribute, seeded output can differ from v0.3 because +post-processing uses `seed + 1L`, and optional `simstudy` support remains in +`Suggests` rather than becoming a required dependency. From 200630c592d435d2d3b2229ac2c7b667f672e7bf Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 12:56:36 -0400 Subject: [PATCH 21/41] Add recodeflow metadata how-to --- _pkgdown.yml | 1 + development/v04-documentation-sprint.md | 2 + vignettes/recodeflow-metadata-v04.qmd | 268 ++++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 vignettes/recodeflow-metadata-v04.qmd diff --git a/_pkgdown.yml b/_pkgdown.yml index 8c02821..8329e1d 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -112,6 +112,7 @@ articles: desc: Task-oriented practical examples navbar: How-to guides contents: + - recodeflow-metadata-v04 - for-recodeflow-users - title: Explanation diff --git a/development/v04-documentation-sprint.md b/development/v04-documentation-sprint.md index 4c138d7..97b8862 100644 --- a/development/v04-documentation-sprint.md +++ b/development/v04-documentation-sprint.md @@ -18,6 +18,8 @@ vignette and pkgdown builds. ## First pass - `getting-started-v04.qmd`: tutorial for the v0.4 `mock_spec` workflow. +- `recodeflow-metadata-v04.qmd`: how-to for generating mock data from + recodeflow-style CSV metadata. - README: update the top-level status and quick example so users see v0.4 immediately. - `_pkgdown.yml`: expose the new v0.4 tutorial in site navigation. diff --git a/vignettes/recodeflow-metadata-v04.qmd b/vignettes/recodeflow-metadata-v04.qmd new file mode 100644 index 0000000..a36b8be --- /dev/null +++ b/vignettes/recodeflow-metadata-v04.qmd @@ -0,0 +1,268 @@ +--- +title: "Use recodeflow metadata with MockData v0.4" +format: html +vignette: > + %\VignetteIndexEntry{Use recodeflow metadata with MockData v0.4} + %\VignetteEngine{quarto::html} + %\VignetteEncoding{UTF-8} +--- + +```{r} +#| label: setup +#| include: false +input_file <- tryCatch(knitr::current_input(dir = TRUE), error = function(e) NULL) +candidate_roots <- unique(c( + ".", + "..", + if (!is.null(input_file)) file.path(dirname(input_file), "..") +)) +package_root <- NULL +for (candidate in candidate_roots) { + description <- file.path(candidate, "DESCRIPTION") + if (file.exists(description) && + any(grepl("^Package:\\s+MockData\\s*$", readLines(description, warn = FALSE)))) { + package_root <- candidate + break + } +} + +if (!is.null(package_root)) { + devtools::load_all(package_root, quiet = TRUE) +} else { + library(MockData) +} +``` + +::: {.vignette-about} +**About this vignette:** This how-to shows how to generate mock data from +recodeflow-style `variables.csv` and `variable_details.csv` files. The code +writes temporary CSV files and reads them back, so the vignette exercises the +same path as a file-based user workflow. +::: + +## Starting point + +Use this path when you already have recodeflow metadata. MockData reads the +metadata, converts it to a v0.4 `mock_spec`, generates baseline values, and +applies missing-code and garbage-value post-processing. + +For a compact example, define three variables: + +- `age`: continuous integer with a normal distribution and one missing code +- `smoking`: categorical code with one `recEnd = "NA::b"` missing-code row +- `interview_date`: date variable with a valid calendar range + +```{r} +variables <- data.frame( + variable = c("age", "smoking", "interview_date"), + label = c("Age in years", "Smoking status", "Interview date"), + variableType = c("Continuous", "Categorical", "Date"), + rType = c("integer", "factor", "date"), + role = c("enabled,table1", "enabled,table1", "enabled"), + position = c(10, 20, 30), + databaseStart = c("cycle1", "cycle1", "cycle1"), + distribution = c("normal", NA, "uniform"), + mean = c(50, NA, NA), + sd = c(12, NA, NA), + garbage_low_prop = c(0.02, NA, NA), + garbage_low_range = c("[0, 17]", NA, NA), + stringsAsFactors = FALSE +) + +variable_details <- data.frame( + variable = c( + "age", + "age", + "smoking", + "smoking", + "smoking", + "smoking", + "interview_date" + ), + recStart = c( + "[18, 85]", + "999", + "1", + "2", + "3", + "7", + "[2020-01-01, 2020-12-31]" + ), + recEnd = c("copy", "NA::b", "copy", "copy", "copy", "NA::b", "copy"), + catLabel = c( + "Valid age range", + "Not stated", + "Never smoker", + "Former smoker", + "Current smoker", + "Don't know", + "Interview date range" + ), + proportion = c(0.95, 0.05, 0.50, 0.30, 0.17, 0.03, 1), + databaseStart = "cycle1", + stringsAsFactors = FALSE +) +``` + +## Write metadata as CSV files + +In a real project, these files already exist. Here we write them to a temporary +directory so this vignette remains self-contained. + +```{r} +metadata_dir <- tempfile("mockdata-recodeflow-") +dir.create(metadata_dir) + +variables_file <- file.path(metadata_dir, "variables.csv") +details_file <- file.path(metadata_dir, "variable_details.csv") + +write.csv(variables, variables_file, row.names = FALSE, na = "") +write.csv(variable_details, details_file, row.names = FALSE, na = "") +``` + +## Inspect the normalized specification + +`mock_spec_from_recodeflow()` reads either data frames or CSV file paths. It +returns a validated `mock_spec` without generating data. + +```{r} +spec <- mock_spec_from_recodeflow( + variables = variables_file, + variable_details = details_file, + databaseStart = "cycle1" +) + +names(spec$variables) +``` + +The spec preserves the recodeflow pieces MockData needs: variable types, +categorical levels, proportions, valid ranges, missing-code rows, and garbage +rules. + +```{r} +spec$variables$smoking$levels +spec$variables$smoking$missing_codes +spec$variables$age$range +spec$variables$age$garbage_rules +``` + +## Generate mock data with the compatibility wrapper + +Most recodeflow users should start with `create_mock_data()`. In strict mode +(`validate = TRUE`, the default), supported metadata routes through the v0.4 +pipeline. + +```{r} +mock_data <- create_mock_data( + databaseStart = "cycle1", + variables = variables_file, + variable_details = details_file, + n = 200, + seed = 123, + verbose = TRUE +) + +head(mock_data) +``` + +The output is a regular data frame. + +```{r} +str(mock_data) +``` + +## Check diagnostics + +When `create_mock_data()` uses the v0.4 path, the returned data frame has a +`mockdata_diagnostics` attribute. The attribute records which rows were changed +during missing-code and garbage-value post-processing. + +```{r} +diagnostics <- attr(mock_data, "mockdata_diagnostics") +names(diagnostics$variables) +``` + +For example, `smoking` has a missing-code rule for code `7`, and `age` has a +low garbage rule. + +```{r} +length(diagnostics$variables$smoking$assigned_missing_indices) +diagnostics$variables$smoking$assigned_missing_indices[1:6] + +length(diagnostics$variables$age$assigned_garbage_indices$low) +diagnostics$variables$age$assigned_garbage_indices$low +``` + +Use the diagnostics as an audit trail, not as columns in the mock dataset. Some +base R operations and downstream tools can drop attributes, so inspect or save +diagnostics before heavy reshaping. + +## Generate explicitly from the spec + +The wrapper is convenient, but the v0.4 pipeline can also be called step by +step. This is useful when you want to inspect baseline values before +post-processing. + +```{r} +baseline <- generate_mock_data_native(spec, n = 200, seed = 123) +head(baseline) +``` + +```{r} +postprocessed <- postprocess_mock_data(baseline, spec, seed = 124) +head(postprocessed) +``` + +The wrapper uses the same idea: the public seed controls baseline generation, +and `seed + 1L` controls post-processing. + +## Database filtering + +`databaseStart` filtering is exact token matching. A variable tagged for +`cycle10` will not accidentally match `cycle1`. + +```{r} +variables_cycle10 <- variables +variables_cycle10$variable[1] <- "age_cycle10" +variables_cycle10$databaseStart[1] <- "cycle10" + +combined_variables <- rbind(variables, variables_cycle10[1, ]) +combined_details <- rbind( + variable_details, + transform(variable_details[variable_details$variable == "age", ], + variable = "age_cycle10") +) + +filtered_spec <- mock_spec_from_recodeflow( + variables = combined_variables, + variable_details = combined_details, + databaseStart = "cycle1" +) + +names(filtered_spec$variables) +``` + +## Troubleshooting + +If `create_mock_data()` cannot use the v0.4 path, set `verbose = TRUE` to see +which path was chosen. + +```{r} +#| eval: false +mock_data <- create_mock_data( + databaseStart = "cycle1", + variables = variables_file, + variable_details = details_file, + n = 200, + seed = 123, + verbose = TRUE +) +``` + +Common reasons for legacy fallback include `validate = FALSE`, +`variable_details = NULL`, detail-level `databaseStart` filtering without a +variable-level `databaseStart` column, and features that are intentionally +deferred from the v0.4 native backend. + +For deeper diagnostics examples, see the diagnostics and garbage how-to when it +lands in the v0.4 documentation sprint. From 750bf13052a52118d2ef2571bee8dc7b2e46d671 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 16:26:29 -0400 Subject: [PATCH 22/41] Add diagnostics and garbage how-to --- _pkgdown.yml | 1 + development/v04-documentation-sprint.md | 2 + vignettes/diagnostics-and-garbage-v04.qmd | 247 ++++++++++++++++++++++ vignettes/recodeflow-metadata-v04.qmd | 4 + 4 files changed, 254 insertions(+) create mode 100644 vignettes/diagnostics-and-garbage-v04.qmd diff --git a/_pkgdown.yml b/_pkgdown.yml index 8329e1d..7b4851c 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -113,6 +113,7 @@ articles: navbar: How-to guides contents: - recodeflow-metadata-v04 + - diagnostics-and-garbage-v04 - for-recodeflow-users - title: Explanation diff --git a/development/v04-documentation-sprint.md b/development/v04-documentation-sprint.md index 97b8862..584a0fa 100644 --- a/development/v04-documentation-sprint.md +++ b/development/v04-documentation-sprint.md @@ -20,6 +20,8 @@ vignette and pkgdown builds. - `getting-started-v04.qmd`: tutorial for the v0.4 `mock_spec` workflow. - `recodeflow-metadata-v04.qmd`: how-to for generating mock data from recodeflow-style CSV metadata. +- `diagnostics-and-garbage-v04.qmd`: how-to for reading diagnostics and + auditing garbage/missing-code post-processing. - README: update the top-level status and quick example so users see v0.4 immediately. - `_pkgdown.yml`: expose the new v0.4 tutorial in site navigation. diff --git a/vignettes/diagnostics-and-garbage-v04.qmd b/vignettes/diagnostics-and-garbage-v04.qmd new file mode 100644 index 0000000..259f0de --- /dev/null +++ b/vignettes/diagnostics-and-garbage-v04.qmd @@ -0,0 +1,247 @@ +--- +title: "Inspect diagnostics and garbage rules in MockData v0.4" +format: html +vignette: > + %\VignetteIndexEntry{Inspect diagnostics and garbage rules in MockData v0.4} + %\VignetteEngine{quarto::html} + %\VignetteEncoding{UTF-8} +--- + +```{r} +#| label: setup +#| include: false +input_file <- tryCatch(knitr::current_input(dir = TRUE), error = function(e) NULL) +candidate_roots <- unique(c( + ".", + "..", + if (!is.null(input_file)) file.path(dirname(input_file), "..") +)) +package_root <- NULL +for (candidate in candidate_roots) { + description <- file.path(candidate, "DESCRIPTION") + if (file.exists(description) && + any(grepl("^Package:\\s+MockData\\s*$", readLines(description, warn = FALSE)))) { + package_root <- candidate + break + } +} + +if (!is.null(package_root)) { + devtools::load_all(package_root, quiet = TRUE) +} else { + library(MockData) +} +``` + +::: {.vignette-about} +**About this vignette:** This how-to shows how to inspect the +`mockdata_diagnostics` attribute added by the v0.4 post-processing layer. The +examples focus on audit trails for missing-code collisions and garbage-value +rules. +::: + +## Why diagnostics matter + +Mock data often needs two kinds of unusual values: + +- missing codes, such as `97` or `999` +- garbage values, such as impossible ages used to test validation code + +Sometimes a value can be both meaningful and suspicious. For example, code `97` +could be a valid category in one source file and also a declared missing code in +another. MockData records diagnostics so you can tell whether a value was drawn +naturally by the baseline generator or assigned later by post-processing. + +## Create a collision case + +Start with a categorical variable where `97` is both a valid level and a +declared missing code. This is deliberately awkward; it is the case diagnostics +are designed to make auditable. + +```{r} +response_spec <- mock_categorical( + "response", + levels = c("1", "97"), + proportions = c(0.65, 0.35), + rtype = "character", + missing_codes = "97", + missing_proportions = 0.20 +) + +baseline <- generate_mock_data_native(response_spec, n = 200, seed = 11) +table(baseline$response) +``` + +The baseline already contains some `97` values because `97` is a valid level. +Now apply post-processing. + +```{r} +processed <- postprocess_mock_data(baseline, response_spec, seed = 12) +table(processed$response) +``` + +## Read the diagnostics + +Diagnostics live in a data-frame attribute. + +```{r} +diagnostics <- attr(processed, "mockdata_diagnostics") +names(diagnostics$variables) +``` + +For a variable, two fields are especially important: + +- `preexisting_missing_code_indices`: rows whose baseline value already matched + a declared missing code +- `assigned_missing_indices`: rows changed by post-processing to a missing code + +```{r} +response_diag <- diagnostics$variables$response + +length(response_diag$preexisting_missing_code_indices) +length(response_diag$assigned_missing_indices) +``` + +These two sets should be distinct. + +```{r} +intersect( + response_diag$preexisting_missing_code_indices, + response_diag$assigned_missing_indices +) +``` + +Both groups contain `97` in the final data, but they mean different things. + +```{r} +head(processed$response[response_diag$preexisting_missing_code_indices]) +head(processed$response[response_diag$assigned_missing_indices]) +``` + +Use the diagnostics when your tests need to distinguish a naturally drawn +collision from a missing code assigned by MockData. + +## Add garbage rules + +Garbage rules deliberately inject invalid or out-of-range values. Here `age` +has one missing code and two garbage rules: + +- `low`: values below the valid age range +- `high`: values above the valid age range + +```{r} +age_spec <- mock_continuous( + "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "integer", + missing_codes = 999, + missing_proportions = 0.05, + garbage_rules = list( + high = list(proportion = 0.03, range = "[120, 150]"), + low = list(proportion = 0.04, range = "[0, 17]") + ) +) + +age_baseline <- generate_mock_data_native(age_spec, n = 200, seed = 21) +age_processed <- postprocess_mock_data(age_baseline, age_spec, seed = 22) +``` + +MockData applies garbage rules in canonical order: `low`, then `high`, then any +other named rules in caller order. The diagnostics use the same order. + +```{r} +age_diag <- attr(age_processed, "mockdata_diagnostics")$variables$age +names(age_diag$assigned_garbage_indices) +``` + +Inspect the assigned rows. + +```{r} +low_idx <- age_diag$assigned_garbage_indices$low +high_idx <- age_diag$assigned_garbage_indices$high + +length(low_idx) +range(age_processed$age[low_idx]) + +length(high_idx) +range(age_processed$age[high_idx]) +``` + +Missing-code rows are protected from garbage assignment. + +```{r} +intersect(age_diag$assigned_missing_indices, low_idx) +intersect(age_diag$assigned_missing_indices, high_idx) +``` + +## Combine variables in one pipeline + +Most workflows generate several variables together. The same diagnostics shape +is used for every variable in the spec. + +```{r} +spec <- mock_spec( + response_spec$variables$response, + age_spec$variables$age +) + +combined_baseline <- generate_mock_data_native(spec, n = 200, seed = 31) +combined_processed <- postprocess_mock_data(combined_baseline, spec, seed = 32) + +combined_diag <- attr(combined_processed, "mockdata_diagnostics") +names(combined_diag$variables) +``` + +A compact audit summary can be built from the diagnostics. + +```{r} +data.frame( + variable = names(combined_diag$variables), + preexisting_missing = vapply( + combined_diag$variables, + function(x) length(x$preexisting_missing_code_indices), + integer(1) + ), + assigned_missing = vapply( + combined_diag$variables, + function(x) length(x$assigned_missing_indices), + integer(1) + ), + assigned_garbage = vapply( + combined_diag$variables, + function(x) sum(lengths(x$assigned_garbage_indices)), + integer(1) + ) +) +``` + +## Preserve diagnostics before reshaping + +Diagnostics are stored as an attribute on the returned data frame. Some +downstream operations keep attributes and others drop them. If diagnostics are +part of your QA workflow, save them before heavy reshaping or joins. + +```{r} +saved_diagnostics <- attr(combined_processed, "mockdata_diagnostics") + +subset_data <- combined_processed[1:5, ] +is.null(attr(subset_data, "mockdata_diagnostics")) + +names(saved_diagnostics$variables) +``` + +## Re-running post-processing + +`postprocess_mock_data()` is intentionally not idempotent. Running it again on a +data frame that already has `mockdata_diagnostics` would double-contaminate the +data, so MockData stops loudly. + +```{r} +#| error: true +postprocess_mock_data(combined_processed, spec, seed = 33) +``` + +Start again from baseline data when you want a fresh post-processing draw. diff --git a/vignettes/recodeflow-metadata-v04.qmd b/vignettes/recodeflow-metadata-v04.qmd index a36b8be..51774fb 100644 --- a/vignettes/recodeflow-metadata-v04.qmd +++ b/vignettes/recodeflow-metadata-v04.qmd @@ -104,6 +104,10 @@ variable_details <- data.frame( ) ``` +When `databaseStart` filtering is requested, include `databaseStart` in both +metadata tables. If the detail metadata has the filter column but the variables +metadata does not, `create_mock_data()` uses the legacy path for compatibility. + ## Write metadata as CSV files In a real project, these files already exist. Here we write them to a temporary From 3d7873f73fe65577f66e34855e8facb80f79c9f4 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 16:35:35 -0400 Subject: [PATCH 23/41] Add v03 to v04 migration how-to --- _pkgdown.yml | 1 + development/v04-documentation-sprint.md | 2 + vignettes/migrating-from-v03-v04.qmd | 276 ++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 vignettes/migrating-from-v03-v04.qmd diff --git a/_pkgdown.yml b/_pkgdown.yml index 7b4851c..215f895 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -114,6 +114,7 @@ articles: contents: - recodeflow-metadata-v04 - diagnostics-and-garbage-v04 + - migrating-from-v03-v04 - for-recodeflow-users - title: Explanation diff --git a/development/v04-documentation-sprint.md b/development/v04-documentation-sprint.md index 584a0fa..8e05cfa 100644 --- a/development/v04-documentation-sprint.md +++ b/development/v04-documentation-sprint.md @@ -22,6 +22,8 @@ vignette and pkgdown builds. recodeflow-style CSV metadata. - `diagnostics-and-garbage-v04.qmd`: how-to for reading diagnostics and auditing garbage/missing-code post-processing. +- `migrating-from-v03-v04.qmd`: how-to for compatibility behavior, fallback + routing, diagnostics, and seed differences. - README: update the top-level status and quick example so users see v0.4 immediately. - `_pkgdown.yml`: expose the new v0.4 tutorial in site navigation. diff --git a/vignettes/migrating-from-v03-v04.qmd b/vignettes/migrating-from-v03-v04.qmd new file mode 100644 index 0000000..48744f9 --- /dev/null +++ b/vignettes/migrating-from-v03-v04.qmd @@ -0,0 +1,276 @@ +--- +title: "Migrate from MockData v0.3 to v0.4" +format: html +vignette: > + %\VignetteIndexEntry{Migrate from MockData v0.3 to v0.4} + %\VignetteEngine{quarto::html} + %\VignetteEncoding{UTF-8} +--- + +```{r} +#| label: setup +#| include: false +input_file <- tryCatch(knitr::current_input(dir = TRUE), error = function(e) NULL) +candidate_roots <- unique(c( + ".", + "..", + if (!is.null(input_file)) file.path(dirname(input_file), "..") +)) +package_root <- NULL +for (candidate in candidate_roots) { + description <- file.path(candidate, "DESCRIPTION") + if (file.exists(description) && + any(grepl("^Package:\\s+MockData\\s*$", readLines(description, warn = FALSE)))) { + package_root <- candidate + break + } +} + +if (!is.null(package_root)) { + devtools::load_all(package_root, quiet = TRUE) +} else { + library(MockData) +} +``` + +::: {.vignette-about} +**About this vignette:** This how-to is for users moving existing +`create_mock_data()` workflows from v0.3 to v0.4. It focuses on the compatibility +wrapper, routing messages, diagnostics, and reproducibility differences. +::: + +## What stayed the same + +The main entry point is still `create_mock_data()`, and the existing arguments +are still available. + +```{r} +variables <- data.frame( + variable = c("age", "smoking"), + variableType = c("Continuous", "Categorical"), + rType = c("integer", "character"), + role = c("enabled", "enabled"), + position = c(10, 20), + distribution = c("normal", NA), + mean = c(50, NA), + sd = c(12, NA), + stringsAsFactors = FALSE +) + +variable_details <- data.frame( + variable = c("age", "age", "smoking", "smoking", "smoking"), + recStart = c("[18, 85]", "999", "1", "2", "7"), + recEnd = c("copy", "NA::b", "copy", "copy", "NA::b"), + proportion = c(0.95, 0.05, 0.60, 0.35, 0.05), + stringsAsFactors = FALSE +) +``` + +```{r} +mock_data <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 100, + seed = 123 +) + +head(mock_data) +``` + +For supported metadata, v0.4 routes this call through the new `mock_spec` +pipeline. + +## See which path ran + +Use `verbose = TRUE` when migrating. The message tells you whether the v0.4 path +or the legacy path was used. + +```{r} +strict_data <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 50, + seed = 456, + verbose = TRUE +) +``` + +The v0.4 path returns a data frame with a diagnostics attribute. + +```{r} +!is.null(attr(strict_data, "mockdata_diagnostics")) +``` + +## Opt into legacy behavior + +Set `validate = FALSE` when you need the legacy v0.3 dispatch path during +migration. This is the explicit compatibility opt-out. + +```{r} +legacy_data <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 50, + seed = 456, + validate = FALSE, + verbose = TRUE +) +``` + +Legacy output is a plain data frame without the v0.4 diagnostics attribute. + +```{r} +is.null(attr(legacy_data, "mockdata_diagnostics")) +``` + +The strict and legacy paths should agree on the broad shape of supported data, +but exact values can differ. + +```{r} +names(strict_data) +names(legacy_data) + +table(strict_data$smoking) +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. + +That makes both stages reproducible, but it means exact values may differ from +v0.3 even when you pass the same seed. + +```{r} +strict_again <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = variable_details, + n = 50, + seed = 456 +) + +identical(strict_data, strict_again) +``` + +When testing migrations, compare structure, types, ranges, and proportions +rather than expecting row-for-row equality with v0.3 output. + +```{r} +str(strict_data) +prop.table(table(strict_data$smoking)) +``` + +## Know the fallback conditions + +`create_mock_data()` deliberately uses the legacy path when: + +- `validate = FALSE` +- `variable_details = NULL` +- detail-level `databaseStart` filtering is needed but `variables` has no + `databaseStart` column +- the requested metadata uses a feature not yet supported by the v0.4 native + backend + +For example, `variable_details = NULL` keeps the simple legacy fallback. + +```{r} +fallback_data <- create_mock_data( + databaseStart = "study", + variables = variables[1, ], + variable_details = NULL, + n = 20, + seed = 789, + verbose = TRUE +) + +head(fallback_data) +``` + +```{r} +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. + +```{r} +exp_variables <- data.frame( + variable = "time_to_visit", + variableType = "Continuous", + rType = "double", + role = "enabled", + distribution = "exponential", + rate = 0.5, + stringsAsFactors = FALSE +) + +exp_details <- data.frame( + variable = "time_to_visit", + recStart = "[0, 10]", + recEnd = "copy", + proportion = 1, + stringsAsFactors = FALSE +) + +exp_data <- create_mock_data( + databaseStart = "study", + variables = exp_variables, + variable_details = exp_details, + n = 20, + seed = 321, + verbose = TRUE +) + +head(exp_data) +``` + +## Inspect the v0.4 path directly + +When debugging a migration, split the wrapper into its three v0.4 steps: + +```{r} +spec <- mock_spec_from_recodeflow(variables, variable_details) +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) + +identical(strict_data, postprocessed) +``` + +This makes it easier to tell whether an issue is coming from metadata parsing, +baseline generation, or post-processing. + +## What to check in sibling packages + +For cchsflow, chmsflow, and recodeflow workflows, test representative +`variables.csv` and `variable_details.csv` files with: + +```{r} +#| eval: false +mock <- create_mock_data( + databaseStart = "your-cycle", + variables = "variables.csv", + variable_details = "variable_details.csv", + n = 100, + seed = 123, + validate = TRUE, + verbose = TRUE +) + +str(mock) +attr(mock, "mockdata_diagnostics") +``` + +Report cases where metadata unexpectedly falls back to legacy dispatch, where a +variable generated in v0.3 but errors in v0.4, or where the generated values, +types, or diagnostics are surprising. From 9b00ff38e41a2142ff8806b423ef97a01d397832 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 16:47:00 -0400 Subject: [PATCH 24/41] Add backend choice how-to --- _pkgdown.yml | 1 + development/v04-documentation-sprint.md | 2 + vignettes/choosing-a-backend-v04.qmd | 250 ++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 vignettes/choosing-a-backend-v04.qmd diff --git a/_pkgdown.yml b/_pkgdown.yml index 215f895..c6811fe 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -115,6 +115,7 @@ articles: - recodeflow-metadata-v04 - diagnostics-and-garbage-v04 - migrating-from-v03-v04 + - choosing-a-backend-v04 - for-recodeflow-users - title: Explanation diff --git a/development/v04-documentation-sprint.md b/development/v04-documentation-sprint.md index 8e05cfa..59f3381 100644 --- a/development/v04-documentation-sprint.md +++ b/development/v04-documentation-sprint.md @@ -24,6 +24,8 @@ vignette and pkgdown builds. auditing garbage/missing-code post-processing. - `migrating-from-v03-v04.qmd`: how-to for compatibility behavior, fallback routing, diagnostics, and seed differences. +- `choosing-a-backend-v04.qmd`: how-to for native versus optional `simstudy` + backend selection. - README: update the top-level status and quick example so users see v0.4 immediately. - `_pkgdown.yml`: expose the new v0.4 tutorial in site navigation. diff --git a/vignettes/choosing-a-backend-v04.qmd b/vignettes/choosing-a-backend-v04.qmd new file mode 100644 index 0000000..30685bc --- /dev/null +++ b/vignettes/choosing-a-backend-v04.qmd @@ -0,0 +1,250 @@ +--- +title: "Choose a MockData v0.4 backend" +format: html +vignette: > + %\VignetteIndexEntry{Choose a MockData v0.4 backend} + %\VignetteEngine{quarto::html} + %\VignetteEncoding{UTF-8} +--- + +```{r} +#| label: setup +#| include: false +input_file <- tryCatch(knitr::current_input(dir = TRUE), error = function(e) NULL) +candidate_roots <- unique(c( + ".", + "..", + if (!is.null(input_file)) file.path(dirname(input_file), "..") +)) +package_root <- NULL +for (candidate in candidate_roots) { + description <- file.path(candidate, "DESCRIPTION") + if (file.exists(description) && + any(grepl("^Package:\\s+MockData\\s*$", readLines(description, warn = FALSE)))) { + package_root <- candidate + break + } +} + +if (!is.null(package_root)) { + devtools::load_all(package_root, quiet = TRUE) +} else { + library(MockData) +} +``` + +::: {.vignette-about} +**About this vignette:** This how-to explains when to use the default native +backend and when to try the optional `simstudy` backend. The `simstudy` examples +run when `simstudy >= 0.8.1` is installed and otherwise render a clear message. +::: + +## The short version + +Use the native backend by default. + +```{r} +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" + ) +) + +native_data <- generate_mock_data_native(spec, n = 100, seed = 101) +head(native_data) +``` + +The native backend is always available, stays within MockData's MIT-licensed +code, and is the backend used by `create_mock_data()` for supported v0.4 +metadata. + +Use the optional `simstudy` backend when you want to exercise that engine path +or when future MockData features need simulation mechanics that `simstudy` +already provides. + +## Check whether simstudy is available + +MockData keeps `simstudy` optional. It is listed in `Suggests`, not `Imports`, +so installing MockData does not require installing `simstudy`. + +```{r} +simstudy_available <- requireNamespace("simstudy", quietly = TRUE) && + utils::packageVersion("simstudy") >= "0.8.1" + +simstudy_available +``` + +If `simstudy` is unavailable, use `generate_mock_data_native()`. + +```{r} +if (!simstudy_available) { + message( + "The optional simstudy backend is not available in this R environment; ", + "using generate_mock_data_native() is the recommended path." + ) +} +``` + +## Run the same spec through both backends + +For categorical variables and uniform continuous variables, both backends can +generate the baseline data. + +```{r} +native_large <- generate_mock_data_native(spec, n = 2000, seed = 202) + +if (simstudy_available) { + simstudy_large <- generate_mock_data_simstudy(spec, n = 2000, seed = 202) + head(simstudy_large) +} else { + simstudy_large <- NULL +} +``` + +When `simstudy` is installed, compare broad properties rather than expecting +row-for-row equality. The engines use different internals. + +```{r} +if (simstudy_available) { + c( + native_mean_age = mean(native_large$age), + simstudy_mean_age = mean(simstudy_large$age) + ) +} +``` + +```{r} +if (simstudy_available) { + rbind( + native = prop.table(table(factor( + native_large$smoking, + levels = c("never", "former", "current") + ))), + simstudy = prop.table(table(factor( + simstudy_large$smoking, + levels = c("never", "former", "current") + ))) + ) +} +``` + +## Mixed specs are allowed + +The optional backend uses `simstudy` only for pieces it can currently generate +safely. Other variables route through MockData's native backend inside the same +call. + +```{r} +mixed_spec <- mock_spec( + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ), + mock_spec_continuous( + "bmi", + range = c(15, 50), + distribution = "normal", + mean = 27, + sd = 5, + rtype = "double" + ), + mock_spec_date( + "interview_date", + range = as.Date(c("2020-01-01", "2020-12-31")) + ) +) + +mixed_native <- generate_mock_data_native(mixed_spec, n = 100, seed = 303) +head(mixed_native) +``` + +```{r} +if (simstudy_available) { + mixed_simstudy <- generate_mock_data_simstudy(mixed_spec, n = 100, seed = 303) + head(mixed_simstudy) +} +``` + +In this example, `smoking` can be generated through `simstudy`; `bmi` and +`interview_date` stay native because MockData owns the truncated normal and +calendar-date contracts in v0.4. + +## Post-processing stays MockData-owned + +Missing codes, garbage values, and diagnostics are applied after baseline +generation. That is true for both backends. + +```{r} +post_spec <- mock_categorical( + "response", + levels = c("1", "97"), + proportions = c(0.6, 0.4), + rtype = "character", + missing_codes = "97", + missing_proportions = 0.2, + garbage_rules = list(low = list(proportion = 0.1, range = "[-2, 0]")) +) + +native_baseline <- generate_mock_data_native(post_spec, n = 100, seed = 404) +native_processed <- postprocess_mock_data(native_baseline, post_spec, seed = 405) + +names(attr(native_processed, "mockdata_diagnostics")$variables$response) +``` + +```{r} +if (simstudy_available) { + simstudy_baseline <- generate_mock_data_simstudy(post_spec, n = 100, seed = 404) + simstudy_processed <- postprocess_mock_data(simstudy_baseline, post_spec, seed = 405) + + names(attr(simstudy_processed, "mockdata_diagnostics")$variables$response) +} +``` + +The diagnostics shape is the same because post-processing is not delegated to +`simstudy`. + +## License and dependency posture + +MockData is MIT licensed. `simstudy` is GPL-3 licensed. Keeping `simstudy` +optional lets MockData keep the core package MIT while still allowing users to +try the advanced backend when that dependency is acceptable in their project. + +If your workflow needs no optional dependency, use: + +```{r} +generate_mock_data_native(spec, n = 10, seed = 1) +``` + +If your workflow explicitly wants to test the optional backend and `simstudy` is +installed, use: + +```{r} +if (simstudy_available) { + generate_mock_data_simstudy(spec, n = 10, seed = 1) +} +``` + +## Decision guide + +Choose the native backend when: + +- you want the default v0.4 behavior; +- you need MockData to work without optional dependencies; +- you are generating categorical, continuous, date, missing-code, or garbage + examples covered by the native pipeline; +- you want the simplest path for package tests and vignettes. + +Try the optional `simstudy` backend when: + +- `simstudy >= 0.8.1` is already acceptable in your project; +- you want to exercise the optional engine path; +- you are preparing for future features where `simstudy` provides mature + simulation mechanics; +- you still want MockData to own missing-code, garbage-value, and diagnostics + semantics after generation. From d3f47c7c74c02a8e6e3512749e826b1976a30408 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 20 May 2026 16:55:43 -0400 Subject: [PATCH 25/41] Add v04 design philosophy vignette --- _pkgdown.yml | 1 + development/v04-documentation-sprint.md | 2 + vignettes/choosing-a-backend-v04.qmd | 2 +- vignettes/design-philosophy-v04.qmd | 293 ++++++++++++++++++++++++ 4 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 vignettes/design-philosophy-v04.qmd diff --git a/_pkgdown.yml b/_pkgdown.yml index c6811fe..b3169e3 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -122,6 +122,7 @@ articles: desc: Understanding concepts and design decisions navbar: Explanation contents: + - design-philosophy-v04 - advanced-topics - title: Reference diff --git a/development/v04-documentation-sprint.md b/development/v04-documentation-sprint.md index 59f3381..4d7d074 100644 --- a/development/v04-documentation-sprint.md +++ b/development/v04-documentation-sprint.md @@ -26,6 +26,8 @@ vignette and pkgdown builds. routing, diagnostics, and seed differences. - `choosing-a-backend-v04.qmd`: how-to for native versus optional `simstudy` backend selection. +- `design-philosophy-v04.qmd`: explanation of v0.4 design choices and scope + boundaries. - README: update the top-level status and quick example so users see v0.4 immediately. - `_pkgdown.yml`: expose the new v0.4 tutorial in site navigation. diff --git a/vignettes/choosing-a-backend-v04.qmd b/vignettes/choosing-a-backend-v04.qmd index 30685bc..d0bff62 100644 --- a/vignettes/choosing-a-backend-v04.qmd +++ b/vignettes/choosing-a-backend-v04.qmd @@ -73,7 +73,7 @@ so installing MockData does not require installing `simstudy`. ```{r} simstudy_available <- requireNamespace("simstudy", quietly = TRUE) && - utils::packageVersion("simstudy") >= "0.8.1" + utils::packageVersion("simstudy") >= numeric_version("0.8.1") simstudy_available ``` diff --git a/vignettes/design-philosophy-v04.qmd b/vignettes/design-philosophy-v04.qmd new file mode 100644 index 0000000..fc67ded --- /dev/null +++ b/vignettes/design-philosophy-v04.qmd @@ -0,0 +1,293 @@ +--- +title: "MockData v0.4 design philosophy" +format: html +vignette: > + %\VignetteIndexEntry{MockData v0.4 design philosophy} + %\VignetteEngine{quarto::html} + %\VignetteEncoding{UTF-8} +--- + +```{r} +#| label: setup +#| include: false +input_file <- tryCatch(knitr::current_input(dir = TRUE), error = function(e) NULL) +candidate_roots <- unique(c( + ".", + "..", + if (!is.null(input_file)) file.path(dirname(input_file), "..") +)) +package_root <- NULL +for (candidate in candidate_roots) { + description <- file.path(candidate, "DESCRIPTION") + if (file.exists(description) && + any(grepl("^Package:\\s+MockData\\s*$", readLines(description, warn = FALSE)))) { + package_root <- candidate + break + } +} + +if (!is.null(package_root)) { + devtools::load_all(package_root, quiet = TRUE) +} else { + library(MockData) +} +``` + +::: {.vignette-about} +**About this vignette:** This explanation describes why MockData v0.4 is shaped +around `mock_spec`, native generation, optional `simstudy`, and MockData-owned +post-processing. It is not a tutorial; start with the v0.4 getting-started +vignette if you want a first workflow. +::: + +## Mock data, not synthetic data + +MockData generates mock data for package development, QA, documentation, +examples, and training. Its output is meant to exercise code paths. It is not +intended for privacy release, inference, or population-valid statistical +analysis. + +That boundary is deliberate. In health-data and survey-data settings, +"synthetic data" can imply privacy review, data-sharing obligations, or +statistical validity claims. MockData avoids that claim. It helps you test a +pipeline before you have access to real data; it does not replace real data for +analysis. + +The working sentence is: + +> Give a recodeflow-style specification a body, so you can test the recoding +> before you have the data. + +## The people v0.4 is trying to serve + +Three user groups shaped the v0.4 design. + +First, recodeflow ecosystem maintainers need data frames that can run through +cchsflow, chmsflow, and recodeflow examples and tests. They already have +`variables.csv` and `variable_details.csv`, so MockData should read those +metadata files rather than invent a competing file format. + +Second, methodologists and package authors need examples and vignettes that run +without restricted data. They may want a small, readable direct API rather than +a full metadata table. + +Third, QA developers need deliberately bad data. Out-of-range ages, declared +missing codes, impossible dates, and invalid category values are not incidental +features; they are the point when testing validation code. + +v0.4 tries to serve all three without making any one workflow the only workflow. + +## Why `mock_spec` exists + +Before v0.4, MockData's generators read metadata, parsed ranges, generated +values, applied missing codes, injected garbage, coerced types, and assembled +columns in one path. That was useful while the package was young, but it made +validation, backend choice, and diagnostics hard to reason about. + +v0.4 introduces `mock_spec` as the normalized internal representation. Different +front doors can produce the same spec: + +- direct helpers, such as `mock_continuous()` and `mock_categorical()` +- composable constructors, such as `mock_spec_continuous()` +- recodeflow metadata through `mock_spec_from_recodeflow()` + +The spec is then consumed by generation and post-processing layers. + +```{r} +spec <- mock_spec( + mock_spec_continuous( + "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "integer" + ), + mock_spec_categorical( + "smoking", + levels = c("never", "former", "current"), + proportions = c(0.5, 0.3, 0.2), + rtype = "character" + ) +) + +names(spec$variables) +``` + +This is the main architectural move: parse once, validate once, then generate +from the normalized shape. + +## Two tiers, one model + +The direct helpers are there for the first ten minutes. + +```{r} +one_variable <- mock_continuous( + "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "integer" +) + +names(one_variable$variables) +``` + +The lower-level constructors are there when you want to compose multiple +variables or build adapters. + +```{r} +same_variable <- mock_spec( + mock_spec_continuous( + "age", + range = c(18, 85), + distribution = "normal", + mean = 50, + sd = 12, + rtype = "integer" + ) +) + +names(same_variable$variables) +``` + +Those are two surface syntaxes for the same internal model. That is why the +package can support small hand-written examples and recodeflow metadata without +duplicating generation logic. + +## Why the backend is hybrid + +MockData v0.4 has a native backend and an optional `simstudy` backend. + +The native backend is the default. It is always available, keeps MockData usable +without optional dependencies, and owns the simple cases that are central to the +package: categorical values, continuous values, dates, missing-code semantics, +garbage values, and diagnostics. + +`simstudy` is optional. It is a mature GPL-3 simulation package with useful +machinery for future advanced features, but MockData remains MIT licensed by +keeping `simstudy` in `Suggests` and soft-gating the backend. + +```{r} +native_data <- generate_mock_data_native(spec, n = 5, seed = 1) +native_data +``` + +```{r} +simstudy_available <- requireNamespace("simstudy", quietly = TRUE) && + utils::packageVersion("simstudy") >= numeric_version("0.8.1") + +if (simstudy_available) { + generate_mock_data_simstudy(spec, n = 5, seed = 1) +} else { + message("simstudy is not installed; the native backend remains available.") +} +``` + +This split is intentionally conservative. MockData should not reimplement a +large simulation library when a good one exists, but it also should not make a +GPL-3 package mandatory for users who only need the core mock-data path. + +## Why post-processing is separate + +Missing codes and garbage values are not just another distribution. They are QA +semantics layered on top of otherwise valid generated data. + +v0.4 therefore generates baseline values first, then applies missing-code and +garbage rules in a separate post-processing pass. + +```{r} +qa_spec <- mock_categorical( + "response", + levels = c("1", "97"), + proportions = c(0.7, 0.3), + rtype = "character", + missing_codes = "97", + missing_proportions = 0.2 +) + +baseline <- generate_mock_data_native(qa_spec, n = 100, seed = 11) +processed <- postprocess_mock_data(baseline, qa_spec, seed = 12) + +diagnostics <- attr(processed, "mockdata_diagnostics") +names(diagnostics$variables$response) +``` + +The diagnostics matter because a value can naturally collide with a declared +missing code. In the example above, `97` is both a valid level and a missing +code. MockData records which rows naturally drew `97` and which rows were +assigned `97` during post-processing. + +```{r} +response_diag <- diagnostics$variables$response + +c( + preexisting = length(response_diag$preexisting_missing_code_indices), + assigned = length(response_diag$assigned_missing_indices) +) +``` + +That distinction is what makes the output auditable for QA workflows. + +## Why strictness increased + +Earlier MockData versions were often permissive: warn, skip a variable, and +return whatever could be generated. That behavior was convenient in exploratory +work, but risky in package tests and documentation. A silently missing column +can make a vignette or downstream test look successful while testing the wrong +thing. + +v0.4 moves toward strict generation for the new pipeline. Unsupported features +should either fail loudly or route through an explicit compatibility path. + +`create_mock_data()` keeps compatibility by retaining legacy fallback routes, +especially for `validate = FALSE`, `variable_details = NULL`, detail-level +`databaseStart` filtering, and unsupported native-backend features. Use +`verbose = TRUE` while migrating so the chosen path is visible. + +## What is deliberately deferred + +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. + +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 +generate statistically realistic joint distributions. + +Table 1 bootstrap is also future work. It is a natural third adapter: take +published descriptive statistics and produce a `mock_spec`. That is useful, but +it should not be squeezed into the recodeflow adapter. + +LinkML or another schema-first model remains a possible north star for the +larger recodeflow ecosystem. v0.4 keeps the internal spec abstract enough that a +future schema adapter could produce it. + +These are roadmap items, not hidden guarantees. + +## How the v0.4 refactor was reviewed + +The v0.4 architecture was developed through a spike, milestone PRs, and repeated +review of code, tests, silent-failure paths, and documentation. That process +changed the design in concrete ways: + +- strict-by-default behavior became more important than permissive fallback; +- diagnostics became a first-class auditability contract; +- `simstudy` stayed optional to preserve MockData's dependency and license + posture; +- executable vignettes became part of validation, not just prose. + +The development notes in `development/` and `.tmp/` preserve more of that review +trail for maintainers. This vignette distills the user-facing design choices. + +## The design in one paragraph + +MockData v0.4 normalizes inputs into `mock_spec`, validates that shape, generates +baseline values through a native backend or optional `simstudy` backend, and +then applies MockData-owned post-processing for missing codes, garbage values, +and diagnostics. It keeps recodeflow metadata central, adds simpler direct APIs, +and preserves the public `create_mock_data()` wrapper for compatibility. It is +mock data for development and QA, not synthetic data for inference. From 5907d55107e09b34575b7d9176515f7065ad723a Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Thu, 21 May 2026 13:47:58 -0400 Subject: [PATCH 26/41] Prepare v04 docs for tag --- DESCRIPTION | 2 +- README.md | 4 ++-- development/adr/v04-hybrid-backend.md | 27 ++++++++++++++++--------- development/simstudy-v04.md | 22 +++++++++++++++++++- development/v04-documentation-sprint.md | 4 ++++ vignettes/design-philosophy-v04.qmd | 20 ++++++++++-------- 6 files changed, 57 insertions(+), 22 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index b10b0fc..99e61e5 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: MockData Title: Generate Mock Data from Metadata Specifications -Version: 0.4.0.9000 +Version: 0.4.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/README.md b/README.md index 715f74a..3b19f31 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ [![Lifecycle: experimental](https://img.shields.io/badge/lifecycle-experimental-orange.svg)](https://lifecycle.r-lib.org/articles/stages.html#experimental) -[![Version: 0.4.0-dev](https://img.shields.io/badge/version-0.4.0--dev-blue.svg)](https://github.com/Big-Life-Lab/MockData) +[![Version: 0.4.0](https://img.shields.io/badge/version-0.4.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, pre-release software** +**Status: Experimental v0.4.0 release candidate** MockData is a work-in-progress R package for generating mock testing data from small metadata specifications. The `dev` branch now contains the v0.4 diff --git a/development/adr/v04-hybrid-backend.md b/development/adr/v04-hybrid-backend.md index 63fc032..9d9b71f 100644 --- a/development/adr/v04-hybrid-backend.md +++ b/development/adr/v04-hybrid-backend.md @@ -1,7 +1,7 @@ # ADR: v0.4 Hybrid Backend Architecture -**Status**: draft -**Date**: 2026-05-18 +**Status**: accepted and implemented in PR #28 +**Date**: 2026-05-18 **Decision owner**: MockData maintainers ## Context @@ -23,6 +23,9 @@ MockData-specific semantics as post-processing. Three review rounds converged on the same conclusion: the hybrid architecture is ready for production refactor planning. +The production refactor was implemented in PR #28 and merged to `dev` for +sibling-package testing before a v0.4.0 tag. + ## Decision MockData v0.4 will move toward a hybrid backend architecture: @@ -92,18 +95,22 @@ Tradeoffs: formula syntax, custom distribution registry, and correlation merging. - Maintaining wrappers will add short-term complexity. -## Implementation Direction +## Implementation Status -Production refactor should proceed in layers: +The production refactor proceeded in layers: 1. `mock_spec` constructors and validators. 2. Direct and recodeflow input adapters. -3. Formula/dependency evaluator. -4. Native backend. -5. Post-processing layer. -6. Promotion of spike assertions to `testthat`. -7. Optional `simstudy` backend. -8. Current API wrappers. +3. Native backend. +4. Post-processing layer and diagnostics. +5. Promotion of spike assertions to `testthat`. +6. Optional `simstudy` backend. +7. Current API wrappers. +8. Divio documentation sprint and Phase C maintainer communication. + +Formula/dependency evaluation, multi-group correlations, Table 1 adapters, and +schema-first integration remain deferred roadmap items rather than v0.4.0 +commitments. ## Open Follow-Up Decisions diff --git a/development/simstudy-v04.md b/development/simstudy-v04.md index 5772839..6d2a85d 100644 --- a/development/simstudy-v04.md +++ b/development/simstudy-v04.md @@ -1,9 +1,15 @@ # MockData v0.4 Production Refactor Plan +**Status**: implemented in PR #28 and superseded by the v0.4 documentation +sprint. This document is retained as the production-refactor plan and should be +read as historical implementation context rather than an active task list. + ## 1. Write The ADR First Write a short architecture decision record before production code changes. +**Status**: complete. See `development/adr/v04-hybrid-backend.md`. + The ADR should lock these decisions: - **Decision**: MockData adopts a hybrid backend architecture. @@ -31,6 +37,9 @@ The ADR should lock these decisions: Each layer should have focused tests before the next layer starts. +**Status**: complete for the v0.4.0 scope. Formula/dependency evaluation, +multi-group correlation, and Table 1 input remain deferred roadmap items. + 1. **`mock_spec` core** - Constructors and validators. - Stable fields for names, types, ranges, levels, proportions, missing codes, @@ -81,6 +90,10 @@ Each layer should have focused tests before the next layer starts. ## 3. Keep The Current API Alive +**Status**: complete. The v0.3 public functions remain available, and +`create_mock_data()` now routes supported metadata through the v0.4 pipeline +while preserving legacy fallback paths. + Existing public functions should remain available in v0.4.0: - `create_mock_data()` @@ -95,6 +108,11 @@ synchronized release. ## 4. Carry-Forward Design Issues +**Status**: partly resolved. The diagnostics shape, seed discipline, native vs +`simstudy` parity tests, and optional `simstudy` posture were settled for v0.4.0. +The remaining items below should be treated as v0.5+ roadmap candidates or issue +backlog material. + Settle in the ADR or the first design note: - Multi-group correlation merge strategy. @@ -116,6 +134,9 @@ Track as implementation issues: ## 5. Communication +**Status**: complete as a draft communication artifact. See +`development/v04-phase-c-comms-note.md`. + Before v0.4.0 lands, write a short communication note for cchsflow, chmsflow, and recodeflow maintainers: @@ -125,4 +146,3 @@ and recodeflow maintainers: - What migration is optional in v0.4.0. - When deprecation warnings may begin. - How the mock-data framing remains distinct from synthetic-data release. - diff --git a/development/v04-documentation-sprint.md b/development/v04-documentation-sprint.md index 4d7d074..7293021 100644 --- a/development/v04-documentation-sprint.md +++ b/development/v04-documentation-sprint.md @@ -1,5 +1,9 @@ # MockData v0.4 Documentation Sprint +**Status**: complete for the v0.4.0 documentation sprint. Remaining work before +tagging is package checks, maintainer smoke testing, and any follow-up edits from +review. + This sprint treats documentation as implementation validation. The goal is not only to explain the v0.4 API, but to run realistic user workflows during vignette and pkgdown builds. diff --git a/vignettes/design-philosophy-v04.qmd b/vignettes/design-philosophy-v04.qmd index fc67ded..8af0afe 100644 --- a/vignettes/design-philosophy-v04.qmd +++ b/vignettes/design-philosophy-v04.qmd @@ -278,16 +278,20 @@ changed the design in concrete ways: - diagnostics became a first-class auditability contract; - `simstudy` stayed optional to preserve MockData's dependency and license posture; +- a Phase C communication note made sibling-package testing part of the release + process; - executable vignettes became part of validation, not just prose. -The development notes in `development/` and `.tmp/` preserve more of that review -trail for maintainers. This vignette distills the user-facing design choices. +The development notes in `development/` and maintainer-only review notes +preserve more of that review trail. This vignette distills the user-facing +design choices. ## The design in one paragraph -MockData v0.4 normalizes inputs into `mock_spec`, validates that shape, generates -baseline values through a native backend or optional `simstudy` backend, and -then applies MockData-owned post-processing for missing codes, garbage values, -and diagnostics. It keeps recodeflow metadata central, adds simpler direct APIs, -and preserves the public `create_mock_data()` wrapper for compatibility. It is -mock data for development and QA, not synthetic data for inference. +MockData v0.4 normalizes inputs into `mock_spec`, validates that shape strictly +by default, generates baseline values through a native backend or optional +`simstudy` backend, and then applies MockData-owned post-processing for missing +codes, garbage values, and diagnostics. It keeps recodeflow metadata central, +adds simpler direct APIs, and preserves the public `create_mock_data()` wrapper +for compatibility. It is mock data for development and QA, not synthetic data +for inference. From 7bc0981fcc93c29f77e2818c7a7532e02d9e1661 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Tue, 9 Jun 2026 23:58:44 -0400 Subject: [PATCH 27/41] Fix config file name in README: mock_data_config.csv --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3b19f31..d7350ef 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ MockData uses a three-file architecture that separates project data dictionaries - Transformation rules (recStart, recEnd, copy, catLabel) - Example: `uvariable, recStart, catLabel` -3. **MockData-specific parameters** (`mock_config.csv`, optional) +3. **MockData-specific parameters** (`mock_data_config.csv`, optional) - Proportions of variable categories - Event occurrence probabilities (`event_occurs`) From 76043e49d8c78f4490da84ffa57915932da12576 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 00:02:06 -0400 Subject: [PATCH 28/41] Replace dplyr::case_when with base R and drop dplyr from Imports --- DESCRIPTION | 4 ++-- R/mockdata_helpers.R | 15 ++++++++------- tests/testthat/test-rtype-coercion.R | 12 ++++++++++++ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 99e61e5..19753c4 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -25,10 +25,10 @@ VignetteBuilder: quarto Depends: R (>= 4.2.0) Imports: - stats, - dplyr + stats Suggests: testthat (>= 3.0.0), + dplyr, readr, stringr, lubridate, diff --git a/R/mockdata_helpers.R b/R/mockdata_helpers.R index b35b8a1..f52d622 100644 --- a/R/mockdata_helpers.R +++ b/R/mockdata_helpers.R @@ -920,13 +920,14 @@ apply_rtype_defaults <- function(details) { # Apply defaults based on type type_lower <- tolower(details[[type_col]]) - details$rType <- dplyr::case_when( - type_lower %in% c("cont", "continuous") ~ "double", # Continuous → double (default) - type_lower %in% c("cat", "categorical") ~ "factor", # Categorical → factor (default) - type_lower == "date" ~ "date", # Date -> date (default) - type_lower == "logical" ~ "logical", # Logical → logical - TRUE ~ "character" # Fallback - ) + # Fallback first, then overwrite recognized types. %in% is used for every + # comparison (including single values) because it maps NA to FALSE, which + # `==` does not — NA in a logical subscript assignment is an error. + details$rType <- "character" + details$rType[type_lower %in% c("cont", "continuous")] <- "double" + details$rType[type_lower %in% c("cat", "categorical")] <- "factor" + details$rType[type_lower %in% "date"] <- "date" + details$rType[type_lower %in% "logical"] <- "logical" } else { # No type column found - default to character details$rType <- "character" diff --git a/tests/testthat/test-rtype-coercion.R b/tests/testthat/test-rtype-coercion.R index 46bb144..5e65baa 100644 --- a/tests/testthat/test-rtype-coercion.R +++ b/tests/testthat/test-rtype-coercion.R @@ -317,3 +317,15 @@ test_that("apply_rtype_defaults validates rType values", { "Invalid rType values found" ) }) + +test_that("rType defaults handle NA and unknown variableType values", { + details <- data.frame( + variable = c("a", "b", "c", "d"), + variableType = c("Continuous", NA, "weird-type", "Date"), + stringsAsFactors = FALSE + ) + + result <- apply_rtype_defaults(details) + + expect_equal(result$rType, c("double", "character", "character", "date")) +}) From 7e4316208427dedca89ff9c065b0e82dd1efffd4 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 00:08:54 -0400 Subject: [PATCH 29/41] Correct rType comment and extend characterization test per review --- R/mockdata_helpers.R | 7 ++++--- tests/testthat/test-rtype-coercion.R | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/R/mockdata_helpers.R b/R/mockdata_helpers.R index f52d622..b8931e6 100644 --- a/R/mockdata_helpers.R +++ b/R/mockdata_helpers.R @@ -920,9 +920,10 @@ apply_rtype_defaults <- function(details) { # Apply defaults based on type type_lower <- tolower(details[[type_col]]) - # Fallback first, then overwrite recognized types. %in% is used for every - # comparison (including single values) because it maps NA to FALSE, which - # `==` does not — NA in a logical subscript assignment is an error. + # Fallback first, then overwrite recognized types. The four %in% sets are + # disjoint, so assignment order does not matter. %in% maps NA to FALSE, + # sending NA types explicitly to the "character" fallback rather than + # relying on R's silent skipping of NA subscripts in scalar assignments. details$rType <- "character" details$rType[type_lower %in% c("cont", "continuous")] <- "double" details$rType[type_lower %in% c("cat", "categorical")] <- "factor" diff --git a/tests/testthat/test-rtype-coercion.R b/tests/testthat/test-rtype-coercion.R index 5e65baa..54c8c99 100644 --- a/tests/testthat/test-rtype-coercion.R +++ b/tests/testthat/test-rtype-coercion.R @@ -318,14 +318,14 @@ test_that("apply_rtype_defaults validates rType values", { ) }) -test_that("rType defaults handle NA and unknown variableType values", { +test_that("apply_rtype_defaults handles NA, unknown, and logical variableType values", { details <- data.frame( - variable = c("a", "b", "c", "d"), - variableType = c("Continuous", NA, "weird-type", "Date"), + variable = c("a", "b", "c", "d", "e"), + variableType = c("Continuous", NA, "weird-type", "Date", "logical"), stringsAsFactors = FALSE ) result <- apply_rtype_defaults(details) - expect_equal(result$rType, c("double", "character", "character", "date")) + expect_equal(result$rType, c("double", "character", "character", "date", "logical")) }) From f84e293311973029965a0b786b2a9eed25d059f0 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 00:14:02 -0400 Subject: [PATCH 30/41] Centralize metadata loading in .load_metadata_df helper --- R/create_cat_var.R | 8 ++----- R/create_con_var.R | 8 ++----- R/create_date_var.R | 8 ++----- R/create_mock_data.R | 26 +++++--------------- R/create_wide_survival_data.R | 7 ++++++ R/load_metadata.R | 28 ++++++++++++++++++++++ tests/testthat/test-load-metadata.R | 37 +++++++++++++++++++++++++++++ 7 files changed, 84 insertions(+), 38 deletions(-) create mode 100644 R/load_metadata.R create mode 100644 tests/testthat/test-load-metadata.R diff --git a/R/create_cat_var.R b/R/create_cat_var.R index 37440e5..ef49d6c 100644 --- a/R/create_cat_var.R +++ b/R/create_cat_var.R @@ -133,12 +133,8 @@ create_cat_var <- function(var, # ========== PARAMETER VALIDATION ========== # Load metadata from file paths if needed - if (is.character(variables) && length(variables) == 1) { - variables <- read.csv(variables, stringsAsFactors = FALSE, check.names = FALSE) - } - if (is.character(variable_details) && length(variable_details) == 1) { - variable_details <- read.csv(variable_details, stringsAsFactors = FALSE, check.names = FALSE) - } + variables <- .load_metadata_df(variables, "variables") + variable_details <- .load_metadata_df(variable_details, "variable_details") # ========== INTERNAL FILTERING (recodeflow pattern) ========== diff --git a/R/create_con_var.R b/R/create_con_var.R index 199c9bf..3d09e3a 100644 --- a/R/create_con_var.R +++ b/R/create_con_var.R @@ -130,12 +130,8 @@ create_con_var <- function(var, # ========== PARAMETER VALIDATION ========== # Load metadata from file paths if needed - if (is.character(variables) && length(variables) == 1) { - variables <- read.csv(variables, stringsAsFactors = FALSE, check.names = FALSE) - } - if (is.character(variable_details) && length(variable_details) == 1) { - variable_details <- read.csv(variable_details, stringsAsFactors = FALSE, check.names = FALSE) - } + variables <- .load_metadata_df(variables, "variables") + variable_details <- .load_metadata_df(variable_details, "variable_details") # ========== INTERNAL FILTERING (recodeflow pattern) ========== diff --git a/R/create_date_var.R b/R/create_date_var.R index 685278e..9e54212 100644 --- a/R/create_date_var.R +++ b/R/create_date_var.R @@ -132,12 +132,8 @@ create_date_var <- function(var, # ========== PARAMETER VALIDATION ========== # Load metadata from file paths if needed - if (is.character(variables) && length(variables) == 1) { - variables <- read.csv(variables, stringsAsFactors = FALSE, check.names = FALSE) - } - if (is.character(variable_details) && length(variable_details) == 1) { - variable_details <- read.csv(variable_details, stringsAsFactors = FALSE, check.names = FALSE) - } + variables <- .load_metadata_df(variables, "variables") + variable_details <- .load_metadata_df(variable_details, "variable_details") # ========== INTERNAL FILTERING (recodeflow pattern) ========== diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 2ba23ec..09a0c2e 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -233,26 +233,12 @@ create_mock_data <- function(databaseStart, # ========== LOAD METADATA ========== - # Load variables from file path if needed - if (is.character(variables) && length(variables) == 1) { - if (!file.exists(variables)) { - stop("Configuration file does not exist: ", variables) - } - if (verbose) message("Reading variables file: ", variables) - variables <- read.csv(variables, stringsAsFactors = FALSE, check.names = FALSE) - } - - # Load variable_details from file path if needed - if (!is.null(variable_details)) { - if (is.character(variable_details) && length(variable_details) == 1) { - if (!file.exists(variable_details)) { - stop("Details file does not exist: ", variable_details) - } - if (verbose) message("Reading variable_details file: ", variable_details) - variable_details <- read.csv(variable_details, stringsAsFactors = FALSE, check.names = FALSE) - } - } else { - if (verbose) message("No details file provided - using simple fallback generation") + variables <- .load_metadata_df(variables, "variables", verbose = verbose) + variable_details <- .load_metadata_df( + variable_details, "variable_details", verbose = verbose + ) + if (is.null(variable_details) && verbose) { + message("No details file provided - using simple fallback generation") } variables <- .migrate_garbage_aliases(variables) diff --git a/R/create_wide_survival_data.R b/R/create_wide_survival_data.R index 6f6d987..45091a6 100644 --- a/R/create_wide_survival_data.R +++ b/R/create_wide_survival_data.R @@ -183,6 +183,13 @@ create_wide_survival_data <- function(var_entry_date, if (missing(databaseStart) || is.null(databaseStart)) { stop("databaseStart parameter is required") } + # Load metadata from file paths if needed + if (!missing(variables)) { + variables <- .load_metadata_df(variables, "variables") + } + if (!missing(variable_details)) { + variable_details <- .load_metadata_df(variable_details, "variable_details") + } if (missing(variables) || !is.data.frame(variables)) { stop("variables must be a data frame (full metadata, not pre-filtered)") } diff --git a/R/load_metadata.R b/R/load_metadata.R new file mode 100644 index 0000000..0d64635 --- /dev/null +++ b/R/load_metadata.R @@ -0,0 +1,28 @@ +#' Load metadata from a file path or pass a data frame through +#' +#' Internal helper shared by create_mock_data() and the create_* generators. +#' Accepts a data frame (returned unchanged), NULL (returned unchanged, for +#' optional variable_details), or a single CSV file path (read with +#' check.names = FALSE to preserve recodeflow column names). +#' +#' @param x data.frame, NULL, or length-1 character file path. +#' @param what Character. Argument name used in messages ("variables", +#' "variable_details"). +#' @param verbose Logical. Emit a message when reading from file. +#' +#' @return data.frame or NULL. +#' @noRd +.load_metadata_df <- function(x, what, verbose = FALSE) { + if (is.null(x) || is.data.frame(x)) { + return(x) + } + if (is.character(x) && length(x) == 1) { + if (!file.exists(x)) { + stop(what, " file does not exist: ", x, call. = FALSE) + } + if (verbose) message("Reading ", what, " file: ", x) + return(read.csv(x, stringsAsFactors = FALSE, check.names = FALSE)) + } + stop("`", what, "` must be a data frame or a single CSV file path", + call. = FALSE) +} diff --git a/tests/testthat/test-load-metadata.R b/tests/testthat/test-load-metadata.R new file mode 100644 index 0000000..66c724d --- /dev/null +++ b/tests/testthat/test-load-metadata.R @@ -0,0 +1,37 @@ +# Tests for .load_metadata_df() — shared metadata-loading helper + +test_that(".load_metadata_df passes data frames and NULL through unchanged", { + df <- data.frame(variable = "age", stringsAsFactors = FALSE) + expect_identical(MockData:::.load_metadata_df(df, "variables"), df) + expect_null(MockData:::.load_metadata_df(NULL, "variable_details")) +}) + +test_that(".load_metadata_df reads a CSV path with check.names = FALSE", { + path <- tempfile(fileext = ".csv") + on.exit(unlink(path)) + write.csv( + data.frame(`odd name` = 1:2, check.names = FALSE), + path, row.names = FALSE + ) + result <- MockData:::.load_metadata_df(path, "variables") + expect_s3_class(result, "data.frame") + expect_named(result, "odd name") +}) + +test_that(".load_metadata_df errors clearly on a missing file", { + expect_error( + MockData:::.load_metadata_df("no/such/file.csv", "variables"), + "variables file does not exist" + ) +}) + +test_that(".load_metadata_df rejects non-path, non-data-frame input", { + expect_error( + MockData:::.load_metadata_df(c("a.csv", "b.csv"), "variables"), + "must be a data frame or a single CSV file path" + ) + expect_error( + MockData:::.load_metadata_df(42, "variables"), + "must be a data frame or a single CSV file path" + ) +}) From 361c3f5038b5ab15f2e17c81a5c2c5315c83f659 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 00:17:55 -0400 Subject: [PATCH 31/41] Document file-path input for create_wide_survival_data metadata args Co-Authored-By: Claude Fable 5 --- R/create_wide_survival_data.R | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/R/create_wide_survival_data.R b/R/create_wide_survival_data.R index 45091a6..1fe9ffd 100644 --- a/R/create_wide_survival_data.R +++ b/R/create_wide_survival_data.R @@ -16,10 +16,12 @@ #' censoring date. Set to NULL to skip. #' @param databaseStart character. Required. Database identifier for filtering metadata #' (used with databaseStart column in variable_details). -#' @param variables data.frame. Required. Full variables metadata (not pre-filtered). +#' @param variables data.frame or character. Full variables metadata (not pre-filtered). #' Must contain columns: variable, variableType. -#' @param variable_details data.frame. Required. Full variable details metadata +#' Can also be a file path (character) to variables.csv. +#' @param variable_details data.frame or character. Full variable details metadata #' (not pre-filtered). Will be filtered internally using databaseStart column. +#' Can also be a file path (character) to variable_details.csv. #' @param df_mock data.frame. Optional. The current mock data to check if variables #' already exist and to use as anchor_date source. Default: NULL. #' @param n integer. Required. Number of observations to generate. From ed114dbc982eee6a3a03e150f808020c951ef242 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 00:23:11 -0400 Subject: [PATCH 32/41] Cross-reference CSV loaders, reject directory paths, pin verbose message Add cross-reference comments between .load_metadata_df() and .read_recodeflow_table() to document their intentional differences. Guard .load_metadata_df() against directory path inputs. Pin new tests for verbose messaging and directory-path rejection. --- R/load_metadata.R | 7 ++++++- R/mock_spec_recodeflow.R | 2 ++ tests/testthat/test-load-metadata.R | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/R/load_metadata.R b/R/load_metadata.R index 0d64635..5317ca3 100644 --- a/R/load_metadata.R +++ b/R/load_metadata.R @@ -5,6 +5,11 @@ #' optional variable_details), or a single CSV file path (read with #' check.names = FALSE to preserve recodeflow column names). #' +#' Note: the v0.4 pipeline has its own reader, .read_recodeflow_table() +#' (R/mock_spec_recodeflow.R), which additionally maps "" and "NA" cells to NA +#' via na.strings. This helper keeps read.csv defaults to preserve the legacy +#' create_* generators' behaviour. Keep the two in mind if consolidating. +#' #' @param x data.frame, NULL, or length-1 character file path. #' @param what Character. Argument name used in messages ("variables", #' "variable_details"). @@ -17,7 +22,7 @@ return(x) } if (is.character(x) && length(x) == 1) { - if (!file.exists(x)) { + if (!file.exists(x) || dir.exists(x)) { stop(what, " file does not exist: ", x, call. = FALSE) } if (verbose) message("Reading ", what, " file: ", x) diff --git a/R/mock_spec_recodeflow.R b/R/mock_spec_recodeflow.R index bcf0f52..1c82bed 100644 --- a/R/mock_spec_recodeflow.R +++ b/R/mock_spec_recodeflow.R @@ -6,6 +6,8 @@ # ============================================================================== #' @noRd +# See also .load_metadata_df() (R/load_metadata.R): the legacy loader with +# read.csv default na.strings. The two differ intentionally. .read_recodeflow_table <- function(x, label) { if (is.data.frame(x)) { return(x) diff --git a/tests/testthat/test-load-metadata.R b/tests/testthat/test-load-metadata.R index 66c724d..bf32a4c 100644 --- a/tests/testthat/test-load-metadata.R +++ b/tests/testthat/test-load-metadata.R @@ -35,3 +35,26 @@ test_that(".load_metadata_df rejects non-path, non-data-frame input", { "must be a data frame or a single CSV file path" ) }) + +test_that(".load_metadata_df emits the reading message when verbose", { + path <- tempfile(fileext = ".csv") + on.exit(unlink(path)) + write.csv(data.frame(variable = "age"), path, row.names = FALSE) + + expect_message( + MockData:::.load_metadata_df(path, "variables", verbose = TRUE), + "Reading variables file: " + ) + expect_silent(MockData:::.load_metadata_df(path, "variables")) +}) + +test_that(".load_metadata_df rejects a directory path", { + dir_path <- tempfile() + dir.create(dir_path) + on.exit(unlink(dir_path, recursive = TRUE)) + + expect_error( + MockData:::.load_metadata_df(dir_path, "variables"), + "variables file does not exist" + ) +}) From 9b397a969a79fd725890b5f73b5768533fe894d5 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 00:28:01 -0400 Subject: [PATCH 33/41] Replace rType dispatch switch with generator lookup map --- R/create_mock_data.R | 81 ++++++---------------------- tests/testthat/test-rtype-coercion.R | 47 ++++++++++++++++ 2 files changed, 63 insertions(+), 65 deletions(-) diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 09a0c2e..6275f84 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -343,6 +343,17 @@ create_mock_data <- function(databaseStart, df_mock <- data.frame(row.names = seq_len(n)) skipped_vars <- character(0) + # rType → generator dispatch map. Keys double as the supported-rTypes list. + generator_map <- list( + factor = create_cat_var, + character = create_cat_var, + logical = create_cat_var, + integer = create_con_var, + double = create_con_var, + numeric = create_con_var, + date = create_date_var + ) + # Generate variables in order for (i in seq_len(nrow(enabled_vars))) { var_row <- enabled_vars[i, ] @@ -368,15 +379,11 @@ create_mock_data <- function(databaseStart, var_name, " (", var_type, ")") } - supported_rtypes <- c( - "factor", "character", "logical", "integer", "double", "numeric", "date" - ) - - if (!var_type %in% supported_rtypes) { + if (!var_type %in% names(generator_map)) { msg <- paste0( "Unknown variable type '", var_type, "' for variable: ", var_name, - "\n Supported rType values: factor, character, logical, integer, ", - "double, numeric, date" + "\n Supported rType values: ", + paste(names(generator_map), collapse = ", ") ) if (validate) { @@ -389,9 +396,8 @@ create_mock_data <- function(databaseStart, } else { # Dispatch to type-specific generator var_data <- tryCatch({ - switch(var_type, - # v0.2 schema rType values - "factor" = create_cat_var( + generator <- generator_map[[var_type]] + generator( var = var_name, databaseStart = databaseStart, variables = variables, @@ -399,61 +405,6 @@ create_mock_data <- function(databaseStart, df_mock = df_mock, n = n, seed = NULL # Global seed already set - ), - "character" = create_cat_var( - var = var_name, - databaseStart = databaseStart, - variables = variables, - variable_details = variable_details, - df_mock = df_mock, - n = n, - seed = NULL - ), - "logical" = create_cat_var( - var = var_name, - databaseStart = databaseStart, - variables = variables, - variable_details = variable_details, - df_mock = df_mock, - n = n, - seed = NULL - ), - "integer" = create_con_var( - var = var_name, - databaseStart = databaseStart, - variables = variables, - variable_details = variable_details, - df_mock = df_mock, - n = n, - seed = NULL - ), - "double" = create_con_var( - var = var_name, - databaseStart = databaseStart, - variables = variables, - variable_details = variable_details, - df_mock = df_mock, - n = n, - seed = NULL - ), - "numeric" = create_con_var( - var = var_name, - databaseStart = databaseStart, - variables = variables, - variable_details = variable_details, - df_mock = df_mock, - n = n, - seed = NULL - ), - "date" = create_date_var( - var = var_name, - databaseStart = databaseStart, - variables = variables, - variable_details = variable_details, - df_mock = df_mock, - n = n, - seed = NULL - ) ) }, error = function(e) { msg <- paste0("Error generating variable ", var_name, ": ", e$message) diff --git a/tests/testthat/test-rtype-coercion.R b/tests/testthat/test-rtype-coercion.R index 54c8c99..a6bb74e 100644 --- a/tests/testthat/test-rtype-coercion.R +++ b/tests/testthat/test-rtype-coercion.R @@ -257,6 +257,53 @@ test_that("create_cat_var defaults to character when rType not specified", { expect_type(result$smoking, "character") }) +# ============================================================================== +# create_mock_data() DISPATCH: rTypes not covered elsewhere through the +# orchestrator (characterization tests for the legacy dispatch path) +# ============================================================================== +# These fixtures omit databaseStart from `variables` while including it in +# `variable_details`, which routes create_mock_data() to the legacy create_* +# dispatch (the v0.4 pipeline declines detail-level databaseStart filtering). + +test_that("create_mock_data dispatches rType = 'numeric' to the continuous generator", { + variables <- data.frame( + variable = "bmi", variableType = "Continuous", rType = "numeric", + role = "enabled", stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = "bmi", recStart = "[15,40]", recEnd = "copy", + proportion = 1, databaseStart = "study", stringsAsFactors = FALSE + ) + result <- create_mock_data( + databaseStart = "study", variables = variables, + variable_details = variable_details, n = 25, seed = 42 + ) + expect_true(is.numeric(result$bmi)) + expect_equal(nrow(result), 25) +}) + +test_that("create_mock_data dispatches rType = 'logical' to the categorical generator", { + variables <- data.frame( + variable = "eligible", variableType = "Categorical", rType = "logical", + role = "enabled", stringsAsFactors = FALSE + ) + variable_details <- data.frame( + variable = c("eligible", "eligible"), + recStart = c("TRUE", "FALSE"), + recEnd = c("TRUE", "FALSE"), + catLabel = c("Eligible", "Not eligible"), + proportion = c(0.5, 0.5), + databaseStart = c("study", "study"), + stringsAsFactors = FALSE + ) + result <- create_mock_data( + databaseStart = "study", variables = variables, + variable_details = variable_details, n = 25, seed = 42 + ) + expect_true(is.logical(result$eligible)) + expect_equal(nrow(result), 25) +}) + # ============================================================================== # HELPER: apply_rtype_defaults() # ============================================================================== From 3a5a6a3e36723a93bfd3fb5f40129bea746505cb Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 06:13:23 -0400 Subject: [PATCH 34/41] Make generators stop on missing variables and warn on duplicates --- NEWS.md | 9 +++ R/create_cat_var.R | 11 ++-- R/create_con_var.R | 13 ++-- R/create_date_var.R | 21 +++--- tests/testthat/test-critical-regressions.R | 74 ++++++++++++++++++++++ tests/testthat/test-rtype-coercion.R | 2 +- 6 files changed, 108 insertions(+), 22 deletions(-) diff --git a/NEWS.md b/NEWS.md index 434a5e8..64727e3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,14 @@ # MockData 0.4.0 +## Breaking changes + +- `create_cat_var()`, `create_con_var()`, and `create_date_var()` now stop + with an error when the requested variable is not found in `variables` + metadata, instead of warning and returning `NULL`. Within + `create_mock_data()`, set `validate = FALSE` to convert these errors to + warn-and-skip behaviour. Duplicate `variables` rows for the same variable + now produce a warning before the first row is used. + ## Development - Started the v0.4 production refactor around a normalized `mock_spec` diff --git a/R/create_cat_var.R b/R/create_cat_var.R index ef49d6c..fdac771 100644 --- a/R/create_cat_var.R +++ b/R/create_cat_var.R @@ -142,12 +142,13 @@ create_cat_var <- function(var, var_row <- variables[variables$variable == var, ] if (nrow(var_row) == 0) { - warning(paste0("Variable '", var, "' not found in variables metadata")) - return(NULL) + stop("Variable '", var, "' not found in variables metadata", call. = FALSE) } - # Take first row if multiple matches if (nrow(var_row) > 1) { + warning("Multiple variables rows found for '", var, + "' (", nrow(var_row), " rows); using the first row.", + call. = FALSE) var_row <- var_row[1, ] } @@ -188,7 +189,7 @@ create_cat_var <- function(var, "No variable_details rows found for variable '", var, "' and databaseStart '", databaseStart, "'. Using fallback categories c('1', '2')." - )) + ), call. = FALSE) # Generate simple 2-category variable with uniform distribution values <- sample(c("1", "2"), size = n, replace = TRUE) # Fallback still honors rType so output contracts match configured metadata. @@ -219,7 +220,7 @@ create_cat_var <- function(var, # Check if we have valid categories if (length(props$categories) == 0) { - warning(paste0("No valid categories found for ", var)) + warning(paste0("No valid categories found for ", var), call. = FALSE) return(NULL) } diff --git a/R/create_con_var.R b/R/create_con_var.R index 3d09e3a..b576ed6 100644 --- a/R/create_con_var.R +++ b/R/create_con_var.R @@ -139,12 +139,13 @@ create_con_var <- function(var, var_row <- variables[variables$variable == var, ] if (nrow(var_row) == 0) { - warning(paste0("Variable '", var, "' not found in variables metadata")) - return(NULL) + stop("Variable '", var, "' not found in variables metadata", call. = FALSE) } - # Take first row if multiple matches if (nrow(var_row) > 1) { + warning("Multiple variables rows found for '", var, + "' (", nrow(var_row), " rows); using the first row.", + call. = FALSE) var_row <- var_row[1, ] } @@ -185,7 +186,7 @@ create_con_var <- function(var, "No variable_details rows found for variable '", var, "' and databaseStart '", databaseStart, "'. Using fallback uniform range [0, 100]." - )) + ), call. = FALSE) values <- runif(n, min = 0, max = 100) # Fallback still honors rType so output contracts match configured metadata. if ("rType" %in% names(var_row)) { @@ -286,13 +287,13 @@ create_con_var <- function(var, "Variable '", var, "' requested normal distribution but mean and/or sd are missing. ", "Using uniform distribution instead." - )) + ), call. = FALSE) } else if (distribution_type == "exponential") { warning(paste0( "Variable '", var, "' requested exponential distribution but rate is missing. ", "Using uniform distribution instead." - )) + ), call. = FALSE) } # Uniform distribution (default) diff --git a/R/create_date_var.R b/R/create_date_var.R index 9e54212..c893b9e 100644 --- a/R/create_date_var.R +++ b/R/create_date_var.R @@ -141,12 +141,13 @@ create_date_var <- function(var, var_row <- variables[variables$variable == var, ] if (nrow(var_row) == 0) { - warning(paste0("Variable '", var, "' not found in variables metadata")) - return(NULL) + stop("Variable '", var, "' not found in variables metadata", call. = FALSE) } - # Take first row if multiple matches if (nrow(var_row) > 1) { + warning("Multiple variables rows found for '", var, + "' (", nrow(var_row), " rows); using the first row.", + call. = FALSE) var_row <- var_row[1, ] } @@ -211,7 +212,7 @@ create_date_var <- function(var, "No variable_details rows found for variable '", var, "' and databaseStart '", databaseStart, "'. Using fallback date range [2000-01-01, 2025-12-31]." - )) + ), call. = FALSE) # Default range: 2000-01-01 to 2025-12-31 date_start <- as.Date("2000-01-01") date_end <- as.Date("2025-12-31") @@ -243,7 +244,7 @@ create_date_var <- function(var, "Variable '", var, "' is a survival variable (has followup_min/max/event_prop), ", "but df_mock does not contain 'anchor_date' column. ", "Cannot generate survival dates without anchor dates." - )) + ), call. = FALSE) return(NULL) } @@ -251,7 +252,7 @@ create_date_var <- function(var, warning(paste0( "Variable '", var, "': df_mock has ", nrow(df_mock), " rows but n=", n, ". ", "For survival variables, df_mock row count must match n." - )) + ), call. = FALSE) return(NULL) } @@ -264,7 +265,7 @@ create_date_var <- function(var, warning(paste0( "Variable '", var, "': followup_min, followup_max, or event_prop is NA. ", "Cannot generate survival dates." - )) + ), call. = FALSE) return(NULL) } @@ -275,7 +276,7 @@ create_date_var <- function(var, warning(paste0( "Variable '", var, "': Some anchor_date values are NA. ", "Cannot compute event dates." - )) + ), call. = FALSE) return(NULL) } @@ -356,7 +357,7 @@ create_date_var <- function(var, } if (length(rec_start_values) == 0) { - warning(paste0("Variable '", var, "': No valid date range found in variable_details")) + warning(paste0("Variable '", var, "': No valid date range found in variable_details"), call. = FALSE) return(NULL) } @@ -367,7 +368,7 @@ create_date_var <- function(var, warning(paste0( "Variable '", var, "': Cannot parse date range from recStart. ", "Expected format: [01JAN2001,31DEC2020], [2001-01-01,2020-12-31], or [2017-03-31,inf]" - )) + ), call. = FALSE) return(NULL) } diff --git a/tests/testthat/test-critical-regressions.R b/tests/testthat/test-critical-regressions.R index 82192c5..34c0a9a 100644 --- a/tests/testthat/test-critical-regressions.R +++ b/tests/testthat/test-critical-regressions.R @@ -486,3 +486,77 @@ test_that("create_mock_data summarizes skipped variables when validate is FALSE" expect_equal(nrow(result), 5) expect_equal(ncol(result), 0) }) + +test_that("generators stop when the variable is missing from variables metadata", { + variables <- data.frame( + variable = "age", variableType = "Continuous", rType = "integer", + stringsAsFactors = FALSE + ) + details <- data.frame( + variable = "age", recStart = "[18,85]", recEnd = "copy", + proportion = 1, databaseStart = "study", stringsAsFactors = FALSE + ) + + expect_error( + create_con_var( + var = "no_such_var", databaseStart = "study", + variables = variables, variable_details = details, n = 10 + ), + "not found in variables metadata" + ) + expect_error( + create_cat_var( + var = "no_such_var", databaseStart = "study", + variables = variables, variable_details = details, n = 10 + ), + "not found in variables metadata" + ) + expect_error( + create_date_var( + var = "no_such_var", databaseStart = "study", + variables = variables, variable_details = details, n = 10 + ), + "not found in variables metadata" + ) +}) + +test_that("generators warn when duplicate variables rows match", { + variables <- data.frame( + variable = c("age", "age"), variableType = "Continuous", + rType = "integer", stringsAsFactors = FALSE + ) + details <- data.frame( + variable = "age", recStart = "[18,85]", recEnd = "copy", + proportion = 1, databaseStart = "study", stringsAsFactors = FALSE + ) + + expect_warning( + result <- create_con_var( + var = "age", databaseStart = "study", + variables = variables, variable_details = details, n = 10, seed = 1 + ), + "Multiple variables rows" + ) + expect_s3_class(result, "data.frame") +}) + +test_that("create_mock_data validate = FALSE path still returns a data frame", { + # Smoke test that the orchestrator's tryCatch + validate = FALSE contract + # survives the generator changes. (Missing-from-variables cannot be + # triggered through the orchestrator itself, since it derives var names + # from the variables data frame — this guards the happy path under the + # permissive flag.) + variables <- data.frame( + variable = "age", variableType = "Continuous", rType = "integer", + role = "enabled", stringsAsFactors = FALSE + ) + details <- data.frame( + variable = "age", recStart = "[18,85]", recEnd = "copy", + proportion = 1, databaseStart = "study", stringsAsFactors = FALSE + ) + result <- create_mock_data( + databaseStart = "study", variables = variables, + variable_details = details, n = 10, seed = 1, validate = FALSE + ) + expect_s3_class(result, "data.frame") +}) diff --git a/tests/testthat/test-rtype-coercion.R b/tests/testthat/test-rtype-coercion.R index a6bb74e..948a022 100644 --- a/tests/testthat/test-rtype-coercion.R +++ b/tests/testthat/test-rtype-coercion.R @@ -278,7 +278,7 @@ test_that("create_mock_data dispatches rType = 'numeric' to the continuous gener databaseStart = "study", variables = variables, variable_details = variable_details, n = 25, seed = 42 ) - expect_true(is.numeric(result$bmi)) + expect_type(result$bmi, "double") expect_equal(nrow(result), 25) }) From 248244c2f0e3bd5916f4b6e7f11316aa9827f894 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 06:25:43 -0400 Subject: [PATCH 35/41] Refine failure-mode docs, NEWS framing, and duplicate-row tests --- NEWS.md | 15 +++++--- R/create_cat_var.R | 8 ++-- R/create_con_var.R | 15 ++++---- R/create_date_var.R | 13 +++++-- tests/testthat/test-critical-regressions.R | 44 +++++++++++++++++++++- 5 files changed, 74 insertions(+), 21 deletions(-) diff --git a/NEWS.md b/NEWS.md index 64727e3..39e3ee6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -3,11 +3,16 @@ ## Breaking changes - `create_cat_var()`, `create_con_var()`, and `create_date_var()` now stop - with an error when the requested variable is not found in `variables` - metadata, instead of warning and returning `NULL`. Within - `create_mock_data()`, set `validate = FALSE` to convert these errors to - warn-and-skip behaviour. Duplicate `variables` rows for the same variable - now produce a warning before the first row is used. + with the error `Variable '' not found in variables metadata` when the + requested variable is absent, instead of warning and returning `NULL`. This + affects direct generator calls and `create_wide_survival_data()` (a + misspelled date-variable name now errors instead of being skipped with a + warning). `create_mock_data()` itself derives variable names from the + `variables` metadata, so it cannot trigger this error; its `validate = FALSE` + flag continues to convert any generator error to warn-and-skip on the legacy + path. Duplicate `variables` rows for the same variable now produce a warning + in the legacy `create_*` path before the first row is used (the v0.4 + `mock_spec` path already errors on duplicate names). ## Development diff --git a/R/create_cat_var.R b/R/create_cat_var.R index fdac771..852a0c7 100644 --- a/R/create_cat_var.R +++ b/R/create_cat_var.R @@ -31,11 +31,13 @@ #' #' @return data.frame with one column (the generated categorical variable), or NULL if: #' \itemize{ -#' \item Variable not found in metadata #' \item Variable already exists in df_mock #' \item No valid categories found in variable_details #' } #' +#' Errors if the variable is not found in the variables metadata. Warns and +#' uses the first row if multiple variables rows match. +#' #' @details #' **v0.3.0 API**: This function now accepts full metadata data frames and filters #' internally for the specified variable and database. This is the "recodeflow pattern" @@ -146,8 +148,8 @@ create_cat_var <- function(var, } if (nrow(var_row) > 1) { - warning("Multiple variables rows found for '", var, - "' (", nrow(var_row), " rows); using the first row.", + warning("Multiple rows found for '", var, "' in variables metadata (", + nrow(var_row), " rows); using the first row.", call. = FALSE) var_row <- var_row[1, ] } diff --git a/R/create_con_var.R b/R/create_con_var.R index b576ed6..3976852 100644 --- a/R/create_con_var.R +++ b/R/create_con_var.R @@ -30,12 +30,11 @@ #' @param n integer. Number of observations to generate. #' @param seed integer. Optional. Random seed for reproducibility. #' -#' @return data.frame with one column (the generated continuous variable), or NULL if: -#' \itemize{ -#' \item Variable not found in metadata -#' \item Variable already exists in df_mock -#' \item No valid range found in variable_details -#' } +#' @return data.frame with one column (the generated continuous variable), or NULL if +#' the variable already exists in df_mock. +#' +#' Errors if the variable is not found in the variables metadata. Warns and +#' uses the first row if multiple variables rows match. #' #' @details #' **v0.3.0 API**: This function now accepts full metadata data frames and filters @@ -143,8 +142,8 @@ create_con_var <- function(var, } if (nrow(var_row) > 1) { - warning("Multiple variables rows found for '", var, - "' (", nrow(var_row), " rows); using the first row.", + warning("Multiple rows found for '", var, "' in variables metadata (", + nrow(var_row), " rows); using the first row.", call. = FALSE) var_row <- var_row[1, ] } diff --git a/R/create_date_var.R b/R/create_date_var.R index c893b9e..8f55aaf 100644 --- a/R/create_date_var.R +++ b/R/create_date_var.R @@ -33,11 +33,16 @@ #' #' @return data.frame with one column (the generated date variable), or NULL if: #' \itemize{ -#' \item Variable not found in metadata #' \item Variable already exists in df_mock -#' \item No valid date range found in variable_details +#' \item No valid date range found in variable_details, or the date +#' range cannot be parsed +#' \item Survival-variable preconditions are not met (e.g. df_mock lacks +#' an anchor_date column, or followup parameters are NA) #' } #' +#' Errors if the variable is not found in the variables metadata. Warns and +#' uses the first row if multiple variables rows match. +#' #' @details #' **v0.3.0 API**: This function now accepts full metadata data frames and filters #' internally for the specified variable and database. This is the "recodeflow pattern" @@ -145,8 +150,8 @@ create_date_var <- function(var, } if (nrow(var_row) > 1) { - warning("Multiple variables rows found for '", var, - "' (", nrow(var_row), " rows); using the first row.", + warning("Multiple rows found for '", var, "' in variables metadata (", + nrow(var_row), " rows); using the first row.", call. = FALSE) var_row <- var_row[1, ] } diff --git a/tests/testthat/test-critical-regressions.R b/tests/testthat/test-critical-regressions.R index 34c0a9a..632ac76 100644 --- a/tests/testthat/test-critical-regressions.R +++ b/tests/testthat/test-critical-regressions.R @@ -535,9 +535,49 @@ test_that("generators warn when duplicate variables rows match", { var = "age", databaseStart = "study", variables = variables, variable_details = details, n = 10, seed = 1 ), - "Multiple variables rows" + "Multiple rows found" ) expect_s3_class(result, "data.frame") + + variables_cat <- data.frame( + variable = c("smoke", "smoke"), variableType = "Categorical", + rType = "factor", stringsAsFactors = FALSE + ) + details_cat <- data.frame( + variable = "smoke", recStart = c("1", "2"), recEnd = c("1", "2"), + proportion = c(0.5, 0.5), databaseStart = "study", + stringsAsFactors = FALSE + ) + + expect_warning( + result_cat <- create_cat_var( + var = "smoke", databaseStart = "study", + variables = variables_cat, variable_details = details_cat, + n = 10, seed = 1 + ), + "Multiple rows found" + ) + expect_s3_class(result_cat, "data.frame") + + variables_date <- data.frame( + variable = c("entry_date", "entry_date"), variableType = "Date", + rType = "date", stringsAsFactors = FALSE + ) + details_date <- data.frame( + variable = "entry_date", recStart = "[2020-01-01,2024-12-31]", + recEnd = "copy", proportion = 1, databaseStart = "study", + stringsAsFactors = FALSE + ) + + expect_warning( + result_date <- create_date_var( + var = "entry_date", databaseStart = "study", + variables = variables_date, variable_details = details_date, + n = 10, seed = 1 + ), + "Multiple rows found" + ) + expect_s3_class(result_date, "data.frame") }) test_that("create_mock_data validate = FALSE path still returns a data frame", { @@ -559,4 +599,6 @@ test_that("create_mock_data validate = FALSE path still returns a data frame", { variable_details = details, n = 10, seed = 1, validate = FALSE ) expect_s3_class(result, "data.frame") + expect_true("age" %in% names(result)) + expect_equal(nrow(result), 10) }) From 6f698e6565f92e33b337233d12f29650de0545a0 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 06:35:18 -0400 Subject: [PATCH 36/41] Make self-contained roxygen examples runnable --- R/add_garbage.R | 34 +++++++++++++++----------------- R/create_cat_var.R | 46 ++++++++++++++++++++------------------------ R/create_con_var.R | 35 +++++++++++++++++---------------- R/create_mock_data.R | 33 ++++++++++--------------------- 4 files changed, 64 insertions(+), 84 deletions(-) diff --git a/R/add_garbage.R b/R/add_garbage.R index 71aa674..49705d6 100644 --- a/R/add_garbage.R +++ b/R/add_garbage.R @@ -82,31 +82,27 @@ #' @export #' #' @examples -#' \dontrun{ -#' # Load metadata -#' variables <- read.csv( -#' system.file("extdata/minimal-example/variables.csv", -#' package = "MockData"), -#' stringsAsFactors = FALSE, check.names = FALSE +#' variables <- data.frame( +#' variable = c("age", "smoking"), +#' variableType = c("Continuous", "Categorical"), +#' stringsAsFactors = FALSE #' ) #' -#' # Add garbage to age (high-range only) -#' vars <- add_garbage(variables, "age", -#' garbage_high_prop = 0.03, garbage_high_range = "[150, 200]") -#' -#' # Add garbage to smoking (low-range only) -#' vars <- add_garbage(vars, "smoking", -#' garbage_low_prop = 0.02, garbage_low_range = "[-2, 0]") -#' -#' # Add garbage to BMI (two-sided invalid values) -#' vars <- add_garbage(vars, "BMI", -#' garbage_low_prop = 0.02, garbage_low_range = "[-10, 15)", -#' garbage_high_prop = 0.01, garbage_high_range = "[60, 150]") +#' # Add high-range garbage to age and low-range garbage to smoking +#' vars_with_garbage <- variables |> +#' add_garbage("age", +#' garbage_high_prop = 0.03, garbage_high_range = "[150, 200]" +#' ) |> +#' add_garbage("smoking", +#' garbage_low_prop = 0.02, garbage_low_range = "[-2, 0]" +#' ) +#' vars_with_garbage #' +#' \dontrun{ #' # Generate data with garbage #' mock_data <- create_mock_data( #' databaseStart = "minimal-example", -#' variables = vars, +#' variables = vars_with_garbage, #' variable_details = variable_details, #' n = 1000, #' seed = 123 diff --git a/R/create_cat_var.R b/R/create_cat_var.R index 852a0c7..e4ef8d0 100644 --- a/R/create_cat_var.R +++ b/R/create_cat_var.R @@ -78,39 +78,35 @@ #' } #' #' @examples -#' \dontrun{ -#' # Basic usage with metadata data frames -#' smoking <- create_cat_var( -#' var = "smoking", -#' databaseStart = "cchs2001_p", -#' variables = variables, -#' variable_details = variable_details, -#' n = 1000, -#' seed = 123 +#' variables <- data.frame( +#' variable = "smoking", +#' variableType = "Categorical", +#' rType = "factor", +#' stringsAsFactors = FALSE +#' ) +#' variable_details <- data.frame( +#' variable = "smoking", +#' recStart = c("1", "2", "3", "7"), +#' recEnd = c("1", "2", "3", "NA::b"), +#' proportion = c(0.5, 0.3, 0.17, 0.03), +#' catLabel = c( +#' "Never smoker", "Former smoker", "Current smoker", "Don't know" +#' ), +#' stringsAsFactors = FALSE #' ) #' -#' # Expected output: data.frame with 1000 rows, 1 column ("smoking") -#' # Values: Factor with levels from metadata (e.g., "1", "2", "3", "7") -#' # Distribution: Based on proportions in variable_details -#' # Example: -#' # smoking -#' # 1 1 -#' # 2 3 -#' # 3 2 -#' # 4 1 -#' # 5 7 -#' # ... -#' -#' # With missing data (uses proportions from metadata) #' smoking <- create_cat_var( #' var = "smoking", -#' databaseStart = "cchs2001_p", +#' databaseStart = "example", #' variables = variables, #' variable_details = variable_details, -#' n = 1000 +#' n = 100, +#' seed = 123 #' ) -#' # Missing codes (recEnd = "NA::b") automatically included based on proportions +#' # Missing codes (recEnd = "NA::b") are included based on proportions +#' table(smoking$smoking) #' +#' \dontrun{ #' # With file paths instead of data frames #' result <- create_cat_var( #' var = "smoking", diff --git a/R/create_con_var.R b/R/create_con_var.R index 3976852..2403477 100644 --- a/R/create_con_var.R +++ b/R/create_con_var.R @@ -81,30 +81,31 @@ #' } #' #' @examples -#' \dontrun{ -#' # Basic usage with metadata data frames +#' variables <- data.frame( +#' variable = "age", +#' variableType = "Continuous", +#' rType = "integer", +#' stringsAsFactors = FALSE +#' ) +#' variable_details <- data.frame( +#' variable = "age", +#' recStart = "[18,85]", +#' recEnd = "copy", +#' proportion = 1, +#' stringsAsFactors = FALSE +#' ) +#' #' age <- create_con_var( #' var = "age", -#' databaseStart = "cchs2001_p", +#' databaseStart = "example", #' variables = variables, #' variable_details = variable_details, -#' n = 1000, +#' n = 100, #' seed = 123 #' ) +#' head(age) #' -#' # Expected output: data.frame with 1000 rows, 1 column ("age") -#' # Values: Numeric based on distribution in metadata -#' # Example for age with normal(50, 15): -#' # age -#' # 1 45 -#' # 2 52 -#' # 3 48 -#' # 4 61 -#' # 5 39 -#' # ... -#' # Distribution: Normal(mean=50, sd=15), clipped to [18,100] -#' # Type: Integer (if rType="integer" in metadata) -#' +#' \dontrun{ #' # With file paths instead of data frames #' result <- create_con_var( #' var = "BMI", diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 6275f84..ec435a0 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -181,30 +181,21 @@ #' see \code{vignette("reference-config", package = "MockData")}. #' #' @examples -#' \dontrun{ -#' # Basic usage with file paths -#' mock_data <- create_mock_data( -#' databaseStart = "minimal-example", -#' variables = "inst/extdata/minimal-example/variables.csv", -#' variable_details = "inst/extdata/minimal-example/variable_details.csv", -#' n = 1000, -#' seed = 123 -#' ) -#' -#' # With data frames instead of file paths -#' variables <- read.csv("inst/extdata/minimal-example/variables.csv", -#' stringsAsFactors = FALSE) -#' variable_details <- read.csv("inst/extdata/minimal-example/variable_details.csv", -#' stringsAsFactors = FALSE) -#' #' mock_data <- create_mock_data( #' databaseStart = "minimal-example", -#' variables = variables, -#' variable_details = variable_details, -#' n = 1000, +#' variables = system.file("extdata/minimal-example/variables.csv", +#' package = "MockData" +#' ), +#' variable_details = system.file("extdata/minimal-example/variable_details.csv", +#' package = "MockData" +#' ), +#' n = 100, #' seed = 123 #' ) +#' head(mock_data) +#' str(mock_data) #' +#' \dontrun{ #' # Fallback mode (uniform distributions, no variable_details) #' mock_data <- create_mock_data( #' databaseStart = "minimal-example", @@ -212,10 +203,6 @@ #' variable_details = NULL, #' n = 500 #' ) -#' -#' # View structure -#' str(mock_data) -#' head(mock_data) #' } #' #' @family generators From 6756afe1a641aa04a33aa8707b26bcb947fb53f2 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 07:09:07 -0400 Subject: [PATCH 37/41] Clarify example output and prune broken dontrun snippet --- R/add_garbage.R | 12 ++---------- R/create_cat_var.R | 4 +++- R/create_con_var.R | 1 + R/create_mock_data.R | 15 ++++++++++----- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/R/add_garbage.R b/R/add_garbage.R index 49705d6..c5da8f8 100644 --- a/R/add_garbage.R +++ b/R/add_garbage.R @@ -98,16 +98,8 @@ #' ) #' vars_with_garbage #' -#' \dontrun{ -#' # Generate data with garbage -#' mock_data <- create_mock_data( -#' databaseStart = "minimal-example", -#' variables = vars_with_garbage, -#' variable_details = variable_details, -#' n = 1000, -#' seed = 123 -#' ) -#' } +#' # Pass the result as the `variables` argument of create_mock_data() — +#' # see ?create_mock_data. add_garbage <- function(variables, var, garbage_low_prop = NULL, garbage_low_range = NULL, garbage_high_prop = NULL, garbage_high_range = NULL) { diff --git a/R/create_cat_var.R b/R/create_cat_var.R index e4ef8d0..9468c08 100644 --- a/R/create_cat_var.R +++ b/R/create_cat_var.R @@ -103,10 +103,12 @@ #' n = 100, #' seed = 123 #' ) -#' # Missing codes (recEnd = "NA::b") are included based on proportions +#' # Code 7 ("Don't know") is an NA::b missing code, generated at its +#' # configured proportion alongside the substantive categories. #' table(smoking$smoking) #' #' \dontrun{ +#' # Not run: requires your own metadata CSV files #' # With file paths instead of data frames #' result <- create_cat_var( #' var = "smoking", diff --git a/R/create_con_var.R b/R/create_con_var.R index 2403477..07f7a30 100644 --- a/R/create_con_var.R +++ b/R/create_con_var.R @@ -106,6 +106,7 @@ #' head(age) #' #' \dontrun{ +#' # Not run: requires your own metadata CSV files #' # With file paths instead of data frames #' result <- create_con_var( #' var = "BMI", diff --git a/R/create_mock_data.R b/R/create_mock_data.R index ec435a0..e975fe9 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -181,6 +181,9 @@ #' see \code{vignette("reference-config", package = "MockData")}. #' #' @examples +#' # The packaged minimal example includes deliberately messy metadata +#' # (auto-normalized proportions, survival dates without an anchor): the +#' # warnings it generates are expected and demonstrate MockData's diagnostics. #' mock_data <- create_mock_data( #' databaseStart = "minimal-example", #' variables = system.file("extdata/minimal-example/variables.csv", @@ -192,18 +195,20 @@ #' n = 100, #' seed = 123 #' ) -#' head(mock_data) #' str(mock_data) #' -#' \dontrun{ -#' # Fallback mode (uniform distributions, no variable_details) +#' # Columns with straightforward metadata generate cleanly: +#' head(mock_data[, c("age", "smoking", "interview_date")]) +#' +#' # Fallback mode: no variable_details, simple default generators #' mock_data <- create_mock_data( #' databaseStart = "minimal-example", -#' variables = "inst/extdata/minimal-example/variables.csv", +#' variables = system.file("extdata/minimal-example/variables.csv", +#' package = "MockData" +#' ), #' variable_details = NULL, #' n = 500 #' ) -#' } #' #' @family generators #' @family mock generation APIs From 40f4ba9a53cbfd231980c6b8bb356467edebdff3 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 07:53:25 -0400 Subject: [PATCH 38/41] Fix check failures: portable test fixture paths and Rd link escape --- R/create_mock_data.R | 2 +- tests/testthat/test-recodeflow-mock-spec.R | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/R/create_mock_data.R b/R/create_mock_data.R index e975fe9..9a3b0ce 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -168,7 +168,7 @@ #' #' **Fallback mode**: If variable_details = NULL, uses simple default generators #' for enabled variables (two-category categorical values, continuous values from -#' [0, 100], and dates from 2000-01-01 to 2025-12-31). +#' `[0, 100]`, and dates from 2000-01-01 to 2025-12-31). #' #' **Variable types supported**: #' \itemize{ diff --git a/tests/testthat/test-recodeflow-mock-spec.R b/tests/testthat/test-recodeflow-mock-spec.R index 51fdcc4..0b0e797 100644 --- a/tests/testthat/test-recodeflow-mock-spec.R +++ b/tests/testthat/test-recodeflow-mock-spec.R @@ -1,5 +1,7 @@ minimal_example_path <- function(...) { - file.path("..", "..", "inst", "extdata", "minimal-example", ...) + path <- system.file("extdata", "minimal-example", ..., package = "MockData") + skip_if(path == "", "minimal-example fixtures not found") + path } test_that("mock_spec_from_recodeflow converts minimal metadata", { From 1ef3ecc48c4d30edad7474298d4676a969248907 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 07:54:44 -0400 Subject: [PATCH 39/41] Polish docs, NEWS, and loader messages from final review --- NEWS.md | 3 +++ R/create_con_var.R | 6 ++++-- R/create_wide_survival_data.R | 4 ++-- R/load_metadata.R | 5 ++++- tests/testthat/test-load-metadata.R | 2 +- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/NEWS.md b/NEWS.md index 39e3ee6..9e9c4a8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -13,6 +13,9 @@ path. Duplicate `variables` rows for the same variable now produce a warning in the legacy `create_*` path before the first row is used (the v0.4 `mock_spec` path already errors on duplicate names). +- `create_mock_data()` error messages for missing metadata files changed from + `Configuration file does not exist:` / `Details file does not exist:` to + `variables file does not exist:` / `variable_details file does not exist:`. ## Development diff --git a/R/create_con_var.R b/R/create_con_var.R index 07f7a30..14599e6 100644 --- a/R/create_con_var.R +++ b/R/create_con_var.R @@ -30,8 +30,10 @@ #' @param n integer. Number of observations to generate. #' @param seed integer. Optional. Random seed for reproducibility. #' -#' @return data.frame with one column (the generated continuous variable), or NULL if -#' the variable already exists in df_mock. +#' @return data.frame with one column (the generated continuous variable), or NULL if: +#' \itemize{ +#' \item Variable already exists in df_mock +#' } #' #' Errors if the variable is not found in the variables metadata. Warns and #' uses the first row if multiple variables rows match. diff --git a/R/create_wide_survival_data.R b/R/create_wide_survival_data.R index 1fe9ffd..81a6e27 100644 --- a/R/create_wide_survival_data.R +++ b/R/create_wide_survival_data.R @@ -193,10 +193,10 @@ create_wide_survival_data <- function(var_entry_date, variable_details <- .load_metadata_df(variable_details, "variable_details") } if (missing(variables) || !is.data.frame(variables)) { - stop("variables must be a data frame (full metadata, not pre-filtered)") + stop("variables must be a data frame or a CSV file path (full metadata, not pre-filtered)") } if (missing(variable_details) || !is.data.frame(variable_details)) { - stop("variable_details must be a data frame (full metadata, not pre-filtered)") + stop("variable_details must be a data frame or a CSV file path (full metadata, not pre-filtered)") } if (missing(n) || is.null(n) || !is.numeric(n) || n <= 0) { stop("n must be a positive integer") diff --git a/R/load_metadata.R b/R/load_metadata.R index 5317ca3..7332fca 100644 --- a/R/load_metadata.R +++ b/R/load_metadata.R @@ -22,7 +22,10 @@ return(x) } if (is.character(x) && length(x) == 1) { - if (!file.exists(x) || dir.exists(x)) { + if (dir.exists(x)) { + stop(what, " path is a directory, not a CSV file: ", x, call. = FALSE) + } + if (!file.exists(x)) { stop(what, " file does not exist: ", x, call. = FALSE) } if (verbose) message("Reading ", what, " file: ", x) diff --git a/tests/testthat/test-load-metadata.R b/tests/testthat/test-load-metadata.R index bf32a4c..a461af6 100644 --- a/tests/testthat/test-load-metadata.R +++ b/tests/testthat/test-load-metadata.R @@ -55,6 +55,6 @@ test_that(".load_metadata_df rejects a directory path", { expect_error( MockData:::.load_metadata_df(dir_path, "variables"), - "variables file does not exist" + "is a directory, not a CSV file" ) }) From 0bf3b2f86a891e57fbbc7e5044e55054fdbf4ecd Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 08:13:34 -0400 Subject: [PATCH 40/41] Address PR review: survival path tests, skip tracking, de-silence NULLs --- R/create_cat_var.R | 9 ++- R/create_con_var.R | 13 ++-- R/create_date_var.R | 9 ++- R/create_mock_data.R | 11 ++- R/load_metadata.R | 10 +-- tests/testthat/test-critical-regressions.R | 78 ++++++++++++++++++++++ tests/testthat/test-survival-data.R | 61 +++++++++++++++++ 7 files changed, 174 insertions(+), 17 deletions(-) diff --git a/R/create_cat_var.R b/R/create_cat_var.R index 9468c08..c4f4cca 100644 --- a/R/create_cat_var.R +++ b/R/create_cat_var.R @@ -31,7 +31,7 @@ #' #' @return data.frame with one column (the generated categorical variable), or NULL if: #' \itemize{ -#' \item Variable already exists in df_mock +#' \item Variable already exists in df_mock (a message is emitted) #' \item No valid categories found in variable_details #' } #' @@ -139,7 +139,7 @@ create_cat_var <- function(var, # ========== INTERNAL FILTERING (recodeflow pattern) ========== # Filter variables for this var - var_row <- variables[variables$variable == var, ] + var_row <- variables[variables$variable == var, , drop = FALSE] if (nrow(var_row) == 0) { stop("Variable '", var, "' not found in variables metadata", call. = FALSE) @@ -166,15 +166,18 @@ create_cat_var <- function(var, databaseStart, allow_empty = TRUE )), + , + drop = FALSE ] } else { # Fallback: no databaseStart filtering (for simple configs) - details_subset <- variable_details[variable_details$variable == var, ] + details_subset <- variable_details[variable_details$variable == var, , drop = FALSE] } # ========== CHECK IF VARIABLE ALREADY EXISTS ========== if (!is.null(df_mock) && var %in% names(df_mock)) { + message("Variable '", var, "' already exists in df_mock; skipping generation.") return(NULL) } diff --git a/R/create_con_var.R b/R/create_con_var.R index 14599e6..97022e7 100644 --- a/R/create_con_var.R +++ b/R/create_con_var.R @@ -30,10 +30,8 @@ #' @param n integer. Number of observations to generate. #' @param seed integer. Optional. Random seed for reproducibility. #' -#' @return data.frame with one column (the generated continuous variable), or NULL if: -#' \itemize{ -#' \item Variable already exists in df_mock -#' } +#' @return data.frame with one column (the generated continuous variable), or +#' NULL (with a message) if the variable already exists in df_mock. #' #' Errors if the variable is not found in the variables metadata. Warns and #' uses the first row if multiple variables rows match. @@ -139,7 +137,7 @@ create_con_var <- function(var, # ========== INTERNAL FILTERING (recodeflow pattern) ========== # Filter variables for this var - var_row <- variables[variables$variable == var, ] + var_row <- variables[variables$variable == var, , drop = FALSE] if (nrow(var_row) == 0) { stop("Variable '", var, "' not found in variables metadata", call. = FALSE) @@ -166,15 +164,18 @@ create_con_var <- function(var, databaseStart, allow_empty = TRUE )), + , + drop = FALSE ] } else { # Fallback: no databaseStart filtering (for simple configs) - details_subset <- variable_details[variable_details$variable == var, ] + details_subset <- variable_details[variable_details$variable == var, , drop = FALSE] } # ========== CHECK IF VARIABLE ALREADY EXISTS ========== if (!is.null(df_mock) && var %in% names(df_mock)) { + message("Variable '", var, "' already exists in df_mock; skipping generation.") return(NULL) } diff --git a/R/create_date_var.R b/R/create_date_var.R index 8f55aaf..2fe877d 100644 --- a/R/create_date_var.R +++ b/R/create_date_var.R @@ -33,7 +33,7 @@ #' #' @return data.frame with one column (the generated date variable), or NULL if: #' \itemize{ -#' \item Variable already exists in df_mock +#' \item Variable already exists in df_mock (a message is emitted) #' \item No valid date range found in variable_details, or the date #' range cannot be parsed #' \item Survival-variable preconditions are not met (e.g. df_mock lacks @@ -143,7 +143,7 @@ create_date_var <- function(var, # ========== INTERNAL FILTERING (recodeflow pattern) ========== # Filter variables for this var - var_row <- variables[variables$variable == var, ] + var_row <- variables[variables$variable == var, , drop = FALSE] if (nrow(var_row) == 0) { stop("Variable '", var, "' not found in variables metadata", call. = FALSE) @@ -170,15 +170,18 @@ create_date_var <- function(var, databaseStart, allow_empty = TRUE )), + , + drop = FALSE ] } else { # Fallback: no databaseStart filtering (for simple configs) - details_subset <- variable_details[variable_details$variable == var, ] + details_subset <- variable_details[variable_details$variable == var, , drop = FALSE] } # ========== CHECK IF VARIABLE ALREADY EXISTS ========== if (!is.null(df_mock) && var %in% names(df_mock)) { + message("Variable '", var, "' already exists in df_mock; skipping generation.") return(NULL) } diff --git a/R/create_mock_data.R b/R/create_mock_data.R index 9a3b0ce..7533f91 100644 --- a/R/create_mock_data.R +++ b/R/create_mock_data.R @@ -363,6 +363,7 @@ create_mock_data <- function(databaseStart, stop(msg, call. = FALSE) } warning(msg) + skipped_vars <- c(skipped_vars, var_name) next } @@ -415,6 +416,12 @@ create_mock_data <- function(databaseStart, for (col_name in names(var_data)) { df_mock[[col_name]] <- var_data[[col_name]] } + } else if (is.null(var_data) && !var_name %in% names(df_mock)) { + # Generators can return NULL without erroring (e.g. survival-date + # preconditions not met, no valid categories). Track those so the + # end-of-run summary reflects every absent column. The names(df_mock) + # check keeps legitimate already-exists skips out of the summary. + skipped_vars <- c(skipped_vars, var_name) } } @@ -426,7 +433,9 @@ create_mock_data <- function(databaseStart, message(" Variables: ", ncol(df_mock)) } - if (!validate && length(skipped_vars) > 0) { + # Fires in both modes: strict mode can also drop columns when a generator + # returns NULL without erroring. + if (length(skipped_vars) > 0) { skipped_vars <- unique(skipped_vars) message("Skipped variables during mock data generation: ", paste(skipped_vars, collapse = ", ")) diff --git a/R/load_metadata.R b/R/load_metadata.R index 7332fca..5734510 100644 --- a/R/load_metadata.R +++ b/R/load_metadata.R @@ -3,12 +3,14 @@ #' Internal helper shared by create_mock_data() and the create_* generators. #' Accepts a data frame (returned unchanged), NULL (returned unchanged, for #' optional variable_details), or a single CSV file path (read with -#' check.names = FALSE to preserve recodeflow column names). +#' check.names = FALSE to preserve recodeflow column names). A path that +#' points to a directory, or one that does not exist, is an error. #' #' Note: the v0.4 pipeline has its own reader, .read_recodeflow_table() -#' (R/mock_spec_recodeflow.R), which additionally maps "" and "NA" cells to NA -#' via na.strings. This helper keeps read.csv defaults to preserve the legacy -#' create_* generators' behaviour. Keep the two in mind if consolidating. +#' (R/mock_spec_recodeflow.R), which additionally maps empty-string cells +#' ("") to NA via na.strings (read.csv already treats "NA" as missing by +#' default). This helper keeps read.csv defaults to preserve the legacy +#' create_* generators' behaviour. #' #' @param x data.frame, NULL, or length-1 character file path. #' @param what Character. Argument name used in messages ("variables", diff --git a/tests/testthat/test-critical-regressions.R b/tests/testthat/test-critical-regressions.R index 632ac76..245dfd7 100644 --- a/tests/testthat/test-critical-regressions.R +++ b/tests/testthat/test-critical-regressions.R @@ -580,6 +580,84 @@ test_that("generators warn when duplicate variables rows match", { expect_s3_class(result_date, "data.frame") }) +test_that("create_mock_data reports skipped variables when a generator returns NULL under validate = TRUE", { + # A survival-style date variable (followup_min/max/event_prop set) requires + # an anchor_date column in df_mock. create_mock_data never supplies one, so + # create_date_var warns and returns NULL without erroring — the column is + # silently absent. The end-of-run summary must report it even in strict mode. + # (distribution = "gompertz" keeps the v0.4 pipeline from claiming the run, + # so this exercises the legacy dispatch path.) + variables <- data.frame( + variable = c("age", "event_date"), + variableType = c("Continuous", "Date"), + rType = c("integer", "date"), + role = c("enabled", "enabled"), + distribution = c(NA, "gompertz"), + followup_min = c(NA, 365), + followup_max = c(NA, 3650), + event_prop = c(NA, 0.5), + stringsAsFactors = FALSE + ) + details <- data.frame( + variable = c("age", "event_date"), + recStart = c("[18,85]", "[2001-01-01,2005-12-31]"), + recEnd = c("copy", "copy"), + proportion = c(1, 1), + stringsAsFactors = FALSE + ) + + expect_message( + expect_warning( + result <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = details, + n = 5, + seed = 1, + validate = TRUE + ), + "anchor_date" + ), + "Skipped variables.*event_date" + ) + + expect_s3_class(result, "data.frame") + expect_true("age" %in% names(result)) + expect_false("event_date" %in% names(result)) +}) + +test_that("create_mock_data lists missing-rType variables in the skipped summary when validate = FALSE", { + variables <- data.frame( + variable = c("age", "no_rtype_var"), + variableType = c("Continuous", "Continuous"), + rType = c("integer", NA), + role = c("enabled", "enabled"), + stringsAsFactors = FALSE + ) + details <- data.frame( + variable = "age", recStart = "[18,85]", recEnd = "copy", + proportion = 1, stringsAsFactors = FALSE + ) + + expect_message( + expect_warning( + result <- create_mock_data( + databaseStart = "study", + variables = variables, + variable_details = details, + n = 5, + seed = 1, + validate = FALSE + ), + "missing rType" + ), + "Skipped variables.*no_rtype_var" + ) + + expect_true("age" %in% names(result)) + expect_false("no_rtype_var" %in% names(result)) +}) + test_that("create_mock_data validate = FALSE path still returns a data frame", { # Smoke test that the orchestrator's tryCatch + validate = FALSE contract # survives the generator changes. (Missing-from-variables cannot be diff --git a/tests/testthat/test-survival-data.R b/tests/testthat/test-survival-data.R index 16f2bb8..adb578e 100644 --- a/tests/testthat/test-survival-data.R +++ b/tests/testthat/test-survival-data.R @@ -306,3 +306,64 @@ test_that("create_wide_survival_data validates required parameters", { "databaseStart parameter is required" ) }) + +test_that("create_wide_survival_data accepts CSV file paths and propagates generator errors", { + # Same fixture shape as the basic entry + event test above + variables <- data.frame( + variable = c("interview_date", "primary_event_date"), + variableType = c("Date", "Date"), + role = c("enabled", "enabled"), + followup_min = c(NA, 365), + followup_max = c(NA, 3650), + event_prop = c(NA, 1.0), + stringsAsFactors = FALSE + ) + + variable_details <- data.frame( + variable = c("interview_date", "interview_date"), + recStart = c("[2001-01-01,2005-12-31]", NA), + recEnd = c("copy", "NA::b"), + stringsAsFactors = FALSE + ) + + # 1. CSV-path input: metadata supplied as file paths, not data frames + variables_path <- tempfile(fileext = ".csv") + details_path <- tempfile(fileext = ".csv") + write.csv(variables, variables_path, row.names = FALSE) + write.csv(variable_details, details_path, row.names = FALSE) + + result <- create_wide_survival_data( + var_entry_date = "interview_date", + var_event_date = "primary_event_date", + var_death_date = NULL, + var_ltfu = NULL, + var_admin_censor = NULL, + databaseStart = "test", + variables = variables_path, + variable_details = details_path, + n = 100, + seed = 123 + ) + + expect_s3_class(result, "data.frame") + expect_equal(nrow(result), 100) + expect_true("interview_date" %in% names(result)) + expect_true("primary_event_date" %in% names(result)) + + # 2. Error propagation: unknown entry variable surfaces the generator error + expect_error( + create_wide_survival_data( + var_entry_date = "typo_var", + var_event_date = "primary_event_date", + var_death_date = NULL, + var_ltfu = NULL, + var_admin_censor = NULL, + databaseStart = "test", + variables = variables, + variable_details = variable_details, + n = 100, + seed = 123 + ), + "not found in variables metadata" + ) +}) From f69b3abc8057d6aa9bc8b9a7c83e2c8578eb27a5 Mon Sep 17 00:00:00 2001 From: Doug Manuel Date: Wed, 10 Jun 2026 11:14:46 -0400 Subject: [PATCH 41/41] Prepare v0.4.0 release: README status and NEWS heading --- NEWS.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 9e9c4a8..5fbebda 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,4 +1,4 @@ -# MockData 0.4.0 +# MockData 0.4.0 (2026-06-10) ## Breaking changes @@ -17,7 +17,7 @@ `Configuration file does not exist:` / `Details file does not exist:` to `variables file does not exist:` / `variable_details file does not exist:`. -## Development +## New features - Started the v0.4 production refactor around a normalized `mock_spec` architecture. diff --git a/README.md b/README.md index d7350ef..c5bd674 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ -**Status: Experimental v0.4.0 release candidate** +**Status: Experimental v0.4.0 release** MockData is a work-in-progress R package for generating mock testing data from -small metadata specifications. The `dev` branch now contains the v0.4 +small metadata specifications. Version 0.4 introduces 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,