An analyst has the synthetic table and their own code; the custodian keeps the original and the recipe. This vignette covers what the recipe carries, how the analyst’s finished pipeline is run on the original, and what happens when the original holds a value the recipe has never seen.
The masque_recipe object returned by mask()
holds the real labels, the original column names and the seed. With the
synthetic it undoes every alias, so keep it with the original data and
never send it with the synthetic. Get it with recipe().
Two functions make the round-trip. apply_recipe()
recodes the original data into the clone’s labels and column names,
which is how a custodian runs an analyst’s finished pipeline on it.
unmask() goes the other way and turns a result in the
clone’s labels back into the original ones.
save_recipe()/read_recipe() persist a recipe
to a single .rds file, and reveal_maps()
prints the level maps that a redacted print()
withholds.
m <- mask(df, roles, mode = "collaborate", seed = 1L)
rec <- recipe(m)
class(rec)
#> [1] "masque::masque_recipe" "S7_object"A recipe holds:
masque_version, created_at,
mode: provenance.seed: the seed of the call. With it the same call
reproduces the same alias maps, so keep it as private as the maps.roles: the two-axis role and action table, with the
original column names.conditional, ladder,
conditioning_cols, conditioning_used,
conditioning_dropped, conditioning_shifted,
fallback_frac: what a conditional clone asked for, the
stratum it got, the columns it dropped or carried as shifts, and the
share of rows pooled.level_maps: the per-column original-to-alias maps. The
sensitive part.column_name_map: the column-name aliases, when
alias_names was used (otherwise NULL).storage_classes, factor_meta: the original
column classes, factor levels and ordering, for faithful
reconstruction.cleaning: the tidying done before masking (column names
made valid R names, whitespace trimmed), so apply_recipe()
re-applies the same tidying.coords: one entry per coordinate pair declared to
mask()’s coords argument: the jitter method
and radius, the site grouping it was masked under, and how many sites
the grouping produced.allow_unmasked_coords: TRUE only when the
caller deliberately wrote a real coordinate through unmasked.warnings: the warnings raised while masking, including
any HIGH audit finding.integrity_fp: a SHA-256 of
is.na(original), an integrity fingerprint, not a privacy
guarantee.A recipe written by an older version has no record for the slots
added since, for example coords before 0.10.0 and
allow_unmasked_coords before 0.11.0. The changelog lists
when each slot was added.
It does not hold the copula covariance, the raw observed values, or any file paths or usernames.
A recipe built with coords records how its coordinates
were masked, so an audit of the recipe does not need the original data
back:
df_geo <- df
df_geo$lat <- -34.9 + stats::runif(nrow(df_geo), -0.05, 0.05)
df_geo$lon <- 138.6 + stats::runif(nrow(df_geo), -0.05, 0.05)
roles_geo <- propose_roles(df_geo, mode = "collaborate")
roles_geo <- set_role(roles_geo, "yield", role = "outcome")
roles_geo <- set_role(roles_geo, c("lat", "lon"), action = "keep")
m_geo <- mask(df_geo, roles_geo, mode = "collaborate", seed = 1L,
coords = list(list(lat = "lat", lon = "lon")))
n_sites_recorded <- recipe(m_geo)@coords[[1]]$n_sites
recipe(m_geo)@coords[[1]][c("method", "min_km", "max_km", "n_sites")]
#> $method
#> [1] "donut"
#>
#> $min_km
#> [1] 5
#>
#> $max_km
#> [1] 20
#>
#> $n_sites
#> [1] 72
recipe(m_geo)@allow_unmasked_coords
#> [1] FALSEEach plot here has its own coordinate, so each plot is a site and
n_sites equals the number of plots. Rows that share a
coordinate would share one displacement (see
jitter_coordinates()).
The print method shows the role and action table with a marker for
which columns hold a level map (* mapped, =
not), but never the vocabularies themselves:
rec
#>
#> ── masque_recipe ───────────────────────────────────────────────────────────────────────────────────
#> • Created: 2026-10-17 04:51:23 UTC
#> • Mode: collaborate
#> • Clone fidelity: marginal / structural (global copula)
#> • Seed: present (redacted)
#> • masque version: 0.14.1.9000
#> • Integrity fingerprint: 0cec319ba9e2...
#>
#> ── Columns (7 total; 1 level map; 0 column-name maps) ──
#>
#> = design keep plot (integer)
#> = design keep rep (factor)
#> = design keep block (factor)
#> * treatment alias gen (factor)
#> = outcome scramble yield (numeric)
#> = design keep row (integer)
#> = design keep col (integer)
#>
#> ✖ PRIVATE - never share this recipe alongside the synthetic.
#> Use `reveal_maps(rec)` to inspect level maps explicitly.Anyone holding the recipe can print the maps with an explicit call, which shows a warning first:
The usual pattern is to fit on the synthetic, recode the original
with apply_recipe(), and predict on it:
# Analyst trains on the synthetic clone.
fit <- lm(yield ~ gen, data = synthetic(m))
# Custodian re-targets the same pipeline onto the real data.
orig_in_synth_space <- apply_recipe(df, rec)
preds <- predict(fit, newdata = orig_in_synth_space)
preds_match_nrow <- length(preds) == nrow(df)
preds_match_nrow
#> [1] TRUEunmask() recovers the original labels:
fwd <- apply_recipe(df, rec)
back <- unmask(fwd, rec)
labels_recovered <- identical(as.character(back$gen), as.character(df$gen))
labels_recovered
#> [1] TRUEColumns whose action is keep, numeric columns and
numeric predictions pass through both functions unchanged.
apply_recipe() returns an aliased factor with the clone’s
levels in the clone’s order, so a model fitted on the translated
original has the same reference level as on the clone.
If the data drifts, for example a new treatment level the recipe has
never seen, apply_recipe() stops with an error:
drifted <- df
levels(drifted$gen) <- c(levels(df$gen), "BRAND_NEW")
drifted$gen[1] <- "BRAND_NEW"
apply_recipe(drifted, rec)
#> Error in `.fail_unmapped()`:
#> ! Value not in the recipe's level map in column gen: "BRAND_NEW".
#> ℹ Schema drift, or original levels the recipe has never seen. Unknown values are not coerced to NA
#> (fail-closed).
#> • Rebuild the recipe from a dataset that contains these values, or strip them before retargeting.The message names the column (gen) and the value
("BRAND_NEW"). Either remove the drifted rows before
re-targeting, or mask the data that contains the new value again.
Masking again makes a new recipe and a new synthetic, and the analyst
needs the new synthetic in place of the old one.
save_recipe() writes a single small .rds,
to be stored next to the original at the same security class.
read_recipe() validates the file and notes a version
mismatch without raising an error.
A mask_set() result carries a recipe bundle:
one recipe per table plus the shared cross-table link maps. The same
apply_recipe() and unmask() verbs dispatch on
it, operating table by table over a named list:
set_dir <- system.file("extdata", "met_set", package = "masque")
ms <- mask_set(set_dir, mode = "collaborate", seed = 1L, quiet = TRUE)
#> Warning: Numeric environment column year remains "keep" in collaborate mode.
#> ℹ This preserves environment structure but may disclose year or other numeric labels; review before
#> release.
originals <- read_set(set_dir)
fwd_set <- apply_recipe(originals, recipe(ms))
names(fwd_set)
#> [1] "agronomy" "quality"Because the shared keys were aliased consistently at masking time, the re-targeted tables join on the same keys the synthetic tables do.
The checks above are about labels. The figure shows the numeric column the analyst works with: the original yield against the synthetic yield.
synth_tbl <- synthetic(m)
overlay_df <- rbind(
data.frame(value = df$yield, table = "original"),
data.frame(value = synth_tbl$yield, table = "synthetic")
)
ggplot2::ggplot(overlay_df, ggplot2::aes(x = value, fill = table)) +
ggplot2::geom_density(alpha = 0.5) +
ggplot2::scale_fill_viridis_d(name = "table") +
ggplot2::labs(
x = "yield (t/ha)", y = "density",
title = "Original versus synthetic marginal distribution of yield"
) +
ggplot2::theme_minimal()Density of yield, original trial against the synthetic clone the analyst develops against. The two distributions overlap closely because the default numeric synthesis preserves each column’s marginal distribution.
All three round-trip checks pass: one prediction per original row, the original genotype labels recovered, and the same integrity fingerprint after the recipe is saved and read back. A value the recipe has never seen stops the translation where the data drifted.
The two yield densities overlap closely, so the marginal distribution is kept. That says nothing about whether a treatment effect or a non-linear relationship between columns is kept; see Confidentiality and the threat model.
With the recipe, the custodian can run the analyst’s finished pipeline on the real data without changing it.
This vignette uses one recipe built from one clean public trial, with
one planted drift. A table whose category labels changed over time
(renamed treatments, retired sites) will be refused more often. The
round-trip verbs invert only what the recipe recorded: numeric columns
pass through unchanged, so unmask() cannot undo a numeric
transformation a pipeline made, and a recipe cannot repair a join that
was broken before masking. The leakage audit, the conditional clone and
the coordinate controls are covered in Confidentiality and the
threat model.
Getting started with masque is the custodian’s side of this same handoff: building and reviewing the masking plan that produces the recipe this vignette reads. Confidentiality and the threat model sets out what the recipe and the synthetic together do and do not protect, including the leakage audit and the conditional clone that preserves a treatment effect this vignette’s round-trip checks do not touch.
set.seed(1) is set once for the document, and every
mask() or mask_set() call passes
seed = 1L, so each is reproducible on its own. Package
versions follow.
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C LC_TIME=en_US.UTF-8
#> [4] LC_COLLATE=en_US.UTF-8 LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C LC_ADDRESS=C
#> [10] LC_TELEPHONE=C LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] masque_0.14.1.9000 rmarkdown_2.32
#>
#> loaded via a namespace (and not attached):
#> [1] vctrs_0.7.3 cli_3.6.6 knitr_1.52 rlang_1.3.0
#> [5] xfun_0.61 otel_0.2.0 S7_0.2.2 jsonlite_2.0.0
#> [9] data.table_1.18.6.1 labeling_0.4.3 glue_1.8.1 buildtools_1.0.0
#> [13] htmltools_0.5.9 maketools_1.3.2 sys_3.4.3 sass_0.4.10
#> [17] scales_1.4.0 grid_4.6.1 evaluate_1.0.5 jquerylib_0.1.4
#> [21] fastmap_1.2.0 yaml_2.3.12 lifecycle_1.0.5 compiler_4.6.1
#> [25] RColorBrewer_1.1-3 maps_3.4.3 farver_2.1.2 digest_0.6.39
#> [29] viridisLite_0.4.3 R6_2.6.1 pillar_1.11.1 bslib_0.12.0
#> [33] tools_4.6.1 withr_3.0.3 gtable_0.3.6 agridat_1.26
#> [37] ggplot2_4.0.3 cachem_1.1.0