Recipe anatomy and the round-trip

Why

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.

What

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.

Example

What a recipe holds

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] FALSE

Each 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()).

Printing is redacted

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:

reveal_maps(rec)
#> ! Revealing sensitive level maps. Proceed at your discretion.
#> 
#> ── gen
#> 
#> ── seed

The round-trip

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] TRUE

unmask() 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] TRUE

Columns 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.

Fail-closed translation

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.

Saving the recipe

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.

tmp <- tempfile(fileext = ".rds")
save_recipe(rec, tmp)
rec2 <- read_recipe(tmp)
fingerprint_survives_roundtrip <- identical(rec@integrity_fp, rec2@integrity_fp)
fingerprint_survives_roundtrip
#> [1] TRUE

Multi-table bundles

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.

Figure: the clone the analyst actually develops against

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.

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.

Results

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.

Limits

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.

Reproduce

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