Getting started with masque

library(masque)

Why

A field agronomist has a confidential 72-plot trial and wants an outside statistician to build a spatial yield model on it, without the statistician seeing the real plot layout or genotype identities. This vignette shows the agronomist’s side of that handoff on the John and Williams (1995) alpha-design trial shipped with the package.

The synthetic table masque produces is a development surrogate for one named collaborator. It is not an anonymised dataset and is not safe for public release. Confidentiality and the threat model sets out what is and is not protected; read it before sharing any output.

What

masque() is the main function. Point it at a table, or a folder or workbook of related tables, and it reads the data, proposes a masking plan and masks. In an interactive session it pauses first so the custodian can review the plan.

The output of masque() is always a pair: the synthetic clone, the only part that crosses the trust boundary, and a private masque_recipe (see recipe()) that never leaves the custodian’s machine. The recipe is what lets a pipeline written against the synthetic re-target to the original later with no code change; its internals and the round-trip verbs are the subject of Recipe anatomy and the round-trip.

Every column in the masking plan has a role and an action, proposed by propose_roles() and changed with set_role(). The role says what the column is: design, treatment, outcome, covariate, date, id, text or other. The action says what happens to it:

  • keep passes it through unchanged;
  • scramble re-simulates numerics through a Gaussian copula and shuffles categories and dates between rows. For a treatment it swaps the labels among the levels, so the allocation is unchanged;
  • alias replaces the labels with opaque codes (a categorical covariate is shuffled between rows first);
  • drop leaves it out.

role_options() lists every role and action pair the package accepts.

The mode argument sets the defaults for both axes at once:

  • local is for the custodian’s own development and keeps labels intact.
  • collaborate is for handing the synthetic to someone else. It aliases treatment and categorical labels, jitters numerics, drops identifiers and free text, and runs the leakage audit.

A per-column action always overrides the mode’s default.

Example

The one-call path

f <- system.file("extdata", "john_alpha.csv", package = "masque")
df <- read.csv(f, stringsAsFactors = TRUE)
knitr::kable(
  head(df),
  caption = "The first six plots of the alpha-design trial."
)
The first six plots of the alpha-design trial.
plot rep block gen yield row col
1 R1 B1 G11 4.1172 1 1
2 R1 B1 G04 4.4461 2 1
3 R1 B1 G05 5.8757 3 1
4 R1 B1 G22 4.5784 4 1
5 R1 B2 G21 4.6540 5 1
6 R1 B2 G10 4.1736 6 1

m <- masque(df, mode = "collaborate", seed = 1L, ask = FALSE)
#> ℹ Using the proposed masking plan (pass `roles` or set `ask = TRUE` to review).
#> ✔ Masked 7 columns in "collaborate" mode - audit: 0 HIGH, 0 medium, 7 low.
#> ℹ Recipe is private - keep it. Review `audit_mask(m)` before any release decision; masque informs that decision, it does not make it.

ask = FALSE skips the interactive review, which is what a vignette needs; in an interactive console, masque(df) shows the proposed plan and a prompt to proceed, edit, or stop. The message above is the summary masque() prints for every guided run: how many columns were masked, in which mode, and the leakage-audit tally.

The result carries the synthetic data and the private recipe:

synth <- synthetic(m)
knitr::kable(
  head(synth),
  caption = paste0(
    "The first six plots of the synthetic clone: `gen` is aliased, ",
    "`yield` is re-simulated."
  )
)
The first six plots of the synthetic clone: gen is aliased, yield is re-simulated.
plot rep block gen yield row col
1 R1 B1 trt_006 4.150347 1 1
2 R1 B1 trt_001 4.248434 2 1
3 R1 B1 trt_019 4.578376 3 1
4 R1 B1 trt_023 5.302792 4 1
5 R1 B2 trt_002 3.992964 5 1
6 R1 B2 trt_014 5.255890 6 1

The masking plan: roles and actions

Under the one-call path, masque() built the roles table without asking. Building and reviewing it directly gives full control:

roles <- propose_roles(df, mode = "collaborate")
knitr::kable(
  roles[, c("col", "role", "action", "kind")],
  caption = paste0(
    "The proposed masking plan for the alpha-design trial ",
    "(collaborate mode)."
  )
)
The proposed masking plan for the alpha-design trial (collaborate mode).
col role action kind
plot design keep integer
rep design keep factor
block design keep factor
gen treatment alias factor
yield covariate scramble numeric
row design keep integer
col design keep integer

propose_roles() fills in a sensible action for each column given the mode, so the table reviewed here is the plan that will run. yield was proposed as a covariate. Naming it the trial’s outcome does not change how it is masked. It records intent, and the conditional clone (see Confidentiality and the threat model) reads it to find the response:

roles <- set_role(roles, "yield", role = "outcome")
knitr::kable(
  roles[, c("col", "role", "action")],
  caption = "The plan after naming `yield` as the trial outcome."
)
The plan after naming yield as the trial outcome.
col role action
plot design keep
rep design keep
block design keep
gen treatment alias
yield outcome scramble
row design keep
col design keep

Re-assigning a role re-resolves the default action; passing an explicit action pins the column instead. There is no requirement to name an outcome: with none marked, every scrambled numeric is re-simulated jointly.

Editing the plan as code

The plan is a data.table, so you can edit it in code. The spreadsheet the guided prompt opens when the custodian chooses e edits the same table. set_role() is vectorised over columns:

r2 <- set_role(roles, c("row", "col"), action = "drop")

Direct edits work too. A direct role edit leaves action untouched, so setting the action back to NA tells mask() to re-resolve the default for the new role:

r2[col == "rep", `:=`(role = "covariate", action = NA_character_)]
knitr::kable(
  r2[, c("col", "role", "action")],
  caption = paste0(
    "A hand-edited plan: `row`/`col` dropped, `rep` re-roled to a ",
    "covariate."
  )
)
A hand-edited plan: row/col dropped, rep re-roled to a covariate.
col role action
plot design keep
rep covariate NA
block design keep
gen treatment alias
yield outcome scramble
row design drop
col design drop

The kind column comes from the column’s class and cannot be edited. If it is wrong, convert the column in the data and run propose_roles() again.

role_options() lists the pairs the package accepts, here for a factor column:

knitr::kable(
  role_options(kind = "factor"),
  caption = paste0(
    "Every (role, action) pair the validator accepts for a factor ",
    "column."
  )
)
Every (role, action) pair the validator accepts for a factor column.
role action kinds notes
design keep all
design alias factor, character, logical design label aliasing requires a factor / character / logical column; numeric design columns can only be kept or dropped
design drop all
treatment keep all
treatment scramble factor, character, logical treatment scramble / alias requires a factor / character / logical column; a numeric treatment (e.g. a dose) can only be kept or dropped
treatment alias factor, character, logical treatment scramble / alias requires a factor / character / logical column; a numeric treatment (e.g. a dose) can only be kept or dropped
treatment drop all
outcome keep all
outcome drop all
covariate keep all
covariate scramble all except other columns of an unsupported class can only be kept or dropped; masque does not know how to synthesise them
covariate alias factor, character, logical covariate aliasing requires a factor / character / logical column; numeric covariates use scramble
covariate drop all
date keep all
date drop all
id keep all
id alias numeric, integer, factor, character id aliasing requires a character, factor, or integer column
id drop all
text keep all
text scramble all except other columns of an unsupported class can only be kept or dropped; masque does not know how to synthesise them
text alias factor, character text aliasing requires a character or factor column
text drop all
other keep all
other drop all

Finally, pii_suspected. propose_roles() sets it from the column name (email, phone, owner, …), and the leakage audit treats a flagged column that survives into the synthetic as a HIGH finding. The scan reads names, not content, so when a harmlessly named column holds sensitive values, flagging it by hand is the correct fix:

roles[col == "comments", pii_suspected := TRUE]

Passing the edited table back to mask() (or to masque(df, roles = roles)) runs the plan:

m <- mask(df, roles, mode = "collaborate", seed = 1L)
synth <- synthetic(m)
byte_identical_plot <- identical(synth$plot, df$plot)
gen_levels_changed <- !setequal(levels(synth$gen), levels(df$gen))
byte_identical_plot
#> [1] TRUE
gen_levels_changed
#> [1] TRUE
head(levels(synth$gen))
#> [1] "trt_001" "trt_002" "trt_003" "trt_004" "trt_005" "trt_006"

A plan the package refuses

plot is a design column. Scrambling it would break the trial’s layout, and mask() refuses:

bad_roles <- set_role(roles, "plot", role = "design", action = "scramble")
mask(df, bad_roles, mode = "collaborate", seed = 1L)
#> Error in `roles_validate()`:
#> ! Incompatible role, action and kind combination:
#> ✖ plot (design + scramble): design columns are structure, not content - they cannot be scrambled;
#>   use keep, alias (labels hidden, structure intact), or drop

The call stops before any masking, names the column and the combination at fault, and lists the three actions that do work for a design column (keep, alias, drop). The same check runs whether the plan came from propose_roles(), a hand edit or the guided spreadsheet.

More than one table

A confidential dataset often arrives as several related files or a multi-sheet workbook. masque() handles those too. Point it at a folder, an .xlsx file, or a named list of tables and it masks every table at once, aliasing any shared key (a site code, a genotype name) identically across tables so a join of the synthetic tables still resolves.

set_dir <- system.file("extdata", "met_set", package = "masque")
ms <- masque(set_dir, mode = "collaborate", seed = 1L, ask = FALSE)
#> 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.
#> ℹ Using the proposed masking plan for agronomy (pass `roles` or set `ask = TRUE` to review).
#> ℹ Using the proposed masking plan for quality (pass `roles` or set `ask = TRUE` to review).
#> 
#> ── Cross-table links (2) ──
#> 
#> • "env" shared across "agronomy, quality" - aliased consistently
#> • "gen" shared across "agronomy, quality" - aliased consistently
#> ✔ Masked 2 tables in "collaborate" mode - audit: 0 HIGH, 0 medium, 12 low.
#> ℹ Recipe is private - keep it. Review `audit_mask(m)` before any release decision; masque informs that decision, it does not make it.
ms
#> 
#> ── masque_set ──────────────────────────────────────────────────────────────────────────────────────
#> • Mode: collaborate
#> • Tables: 2
#> • agronomy: 464 rows x 7 columns
#> • quality: 464 rows x 5 columns
#> 
#> ── Cross-table links (2) ──
#> 
#> • "env" shared across "agronomy, quality"
#> • "gen" shared across "agronomy, quality"
#> Use `synthetic(m)` for the tables; `recipe(m)` for the bundle.
#> 
#> ✖ The recipe bundle is PRIVATE - never share it with the synthetic set.

The genotype column gen appears in both tables and is masked to the same codes in each, so the field and laboratory tables still join. See Confidentiality and the threat model for the set-level controls.

Multi-environment structure

For a multi-environment trial, detect_design() answers three questions separately:

  1. Which rows belong to each environment?
  2. Do the treatments connect the environments, so that they can be compared?
  3. What randomisation structure can be recovered within each environment?

A block or field layout seen in the data suggests the original randomisation but does not confirm it. A small toy with two environments and three genotypes:

met <- expand.grid(
  env = factor(c("E1", "E2")),
  rep = factor(seq_len(2L)),
  gen = factor(c("G1", "G2", "G3"))
)
met$yield <- seq_len(nrow(met)) + rep(c(0, 2), each = 6L)

ds <- detect_design(met)
ds@scope_label
#> [1] "multi_environment"
ds@env_cols
#> [1] "env"
ds@connectivity$status
#> [1] "connected"
ds@within_design_label
#> [1] "RCBD"

Automatic detection is conservative. A column named env or environment, or a common site-year pattern, is taken as the environment only when no other column explains the structure as well. A site column on its own is taken only when treatments are replicated across sites, so a nested block is not mistaken for an environment. When you know the environment columns, name them:

ds_explicit <- detect_design(met, env = "env")

Figure: the multi-environment coverage plot

plot() on a design summary, or plot_design_summary(), draws a compact environment overview in base graphics. With engine = "ggplot2" it returns a ggplot2 object you can save with ggplot2::ggsave(); printed, it is the figure below. It shows whether coverage is even across environments, which the connectivity flag alone does not.

plot(ds_explicit, df = met, engine = "ggplot2")
Genotypes observed per environment (E1, E2) for the toy multi-environment trial; the subtitle carries connectivity status, component count, and the recovered within-environment design.

Genotypes observed per environment (E1, E2) for the toy multi-environment trial; the subtitle carries connectivity status, component count, and the recovered within-environment design.

Both bars are at three treatments, since every genotype in this toy trial appears in both environments. A shorter bar, or a subtitle reporting more than one component, marks environments that cannot go into one connected analysis until you have checked whether the missing coverage is real or an artefact of how the table was put together.

High-confidence environment columns feed the masking plan directly:

  • Local mode keeps environment values byte-identical.
  • Collaborate mode aliases categorical environment labels in place, so each row stays in its environment and missing values stay where they were. The recipe turns the aliases back into the real labels.

A numeric environment, such as year, stays at keep and raises a disclosure warning because its values are still visible. The toy’s env column is categorical:

met_roles <- propose_roles(met, mode = "collaborate")
knitr::kable(
  met_roles[met_roles$col == "env", c("col", "role", "action")],
  caption = "How the masking plan treats the `env` column."
)
How the masking plan treats the env column.
col role action
env design alias

This safeguard protects the allocation a pipeline reads. It does not imply that the synthesised outcomes preserve genotype-by-environment effects: sparse treatment-by-environment cells may fall back to pooled synthesis, and the clone must not be used as a substitute for the original trial in scientific inference.

Results

On this trial the audit graded 0 columns HIGH and 0 medium. A HIGH finding would stop the package writing the synthetic to disk. A design column left at keep comes back unchanged and an aliased treatment comes back as trt_001 to trt_024; a plan that asks for something the package cannot do is refused before it runs. Related tables are masked together and still join.

The agronomist can therefore hand over a clone that keeps the plot layout and the links between tables, with the genotype labels aliased, and the statistician can build a full pipeline on it without seeing the real genotype names. How the finished pipeline runs on the real data is covered in Recipe anatomy and the round-trip.

Limits

This vignette shows the custodian’s side of the handoff, on one small public trial with a clean, complete design; it does not exercise the leakage audit’s failure modes, the geographic-coordinate controls, or the conditional clone that preserves a treatment effect rather than only a marginal distribution, all of which live in Confidentiality and the threat model. A sensitive column with an ordinary name is caught only if someone flags it by hand. Multi-environment detection can miss a real environment when the names and replication are ambiguous; prefer an explicit env whenever you know better. Only one numeric column is re-simulated here, so the vignette does not show what the Gaussian copula keeps of the relationships between columns; that is also in Confidentiality and the threat model.

Reproduce

set.seed(1) is set once for the document, and every masque() or mask() 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