Skip to contents

What the file contains

A sampling_design describes how to sample without embedding the confidential frame. Saving it creates a versioned JSON recipe that can be inspected, reviewed, and executed later.

Three functions cover the workflow.

Function Role
write_design() Write a design or execution receipt to JSON
read_design() Restore a design from a file or JSON string
design_json() Return the same JSON in memory

The format is native to samplyr and remains experimental. It is not a finalized cross-tool interchange standard.

Save and restore a design

data(bfa_eas)

design <- sampling_design(title = "Burkina Faso EA survey") |>
  stratify_by(region, alloc = "proportional") |>
  draw(
    n = 300,
    method = "systematic",
    control = c(serp(province, commune))
  )

path <- tempfile(fileext = ".json")
write_design(design, path)

restored <- read_design(path)
restored
#> ── Sampling Design: Burkina Faso EA survey ─────────────────────────────────────
#> 
#> ℹ 1 stage
#> 
#> ── Stage 1 ─────────────────────────────────────────────────────────────────────
#> • Strata: region (proportional)
#> • Draw: n = 300 (total), method = systematic, control = serp(province, commune)

Under the same compatible implementation, frame, and seed, the restored design executes like the original.

original_sample <- execute(design, bfa_eas, seed = 123)
restored_sample <- execute(restored, bfa_eas, seed = 123)

identical(original_sample$ea_id, restored_sample$ea_id)
#> [1] TRUE
identical(original_sample$.weight, restored_sample$.weight)
#> [1] TRUE

Execution equality is the contract. Incidental R storage details can differ. For example, JSON does not distinguish integers from whole-valued doubles and restored data-frame specifications are tibbles.

design_json() is useful when the document belongs in a database or API.

json <- design_json(design)
names(jsonlite::fromJSON(json))
#> [1] "format"         "format_version" "schema"         "design"        
#> [5] "frame"          "tools"

Requirements and frame fingerprints

Every file records the variables needed by each stage and their roles. These requirements allow a candidate frame to be checked before selection.

frame_info <- attr(restored, "frame_info")
bind_rows(frame_info$required_variables)
#> # A tibble: 3 × 3
#>   name     role    stage
#>   <chr>    <chr>   <int>
#> 1 region   strata      1
#> 2 province control     1
#> 3 commune  control     1

Passing a frame to write_design() adds a fingerprint without writing the frame data.

write_design(design, path, frame = bfa_eas)

with_frame <- read_design(path)
fingerprint <- attr(with_frame, "frame_info")[["fingerprint"]]

fingerprint$name
#> [1] "bfa_eas"
fingerprint$nrow
#> [1] 44570
fingerprint$hash
#> [1] "dd7602cb0907d556f5d27e31d15c8137"

The hash covers column names, values, and row order. It ignores whether the object is a tibble and ignores column order. It is an integrity check, not a proof that the register has good coverage, stable identifiers, or the correct target-population definition.

validate_frame() reports structural or probability-relevant drift while still checking whether the revised frame can execute the design.

validate_frame(with_frame, bfa_eas[-(1:10), ])
#> Frame differs from what was recorded when the design was saved:
#>  44560 rows instead of the 44570 recorded
#>  This is informational. The design remains executable on any frame that passes
#>   the variable checks.

A compatible but changed frame can run a new selection. It cannot reproduce the old one. replay_design() therefore refuses a fingerprint mismatch by default.

For a design with one register per stage, the file records one fingerprint per register in supplied order. Replay requires the same number and ordering of registers.

Save an execution receipt

Writing a tbl_sample adds the arguments that determined its execution. These include the seed, executed stages, panel or replicate request, selected count, timestamp, package versions, and RNG configuration.

sample <- execute(design, bfa_eas, seed = 2026, panels = 4)
write_design(sample, path, frame = bfa_eas)

saved <- read_design(path)
receipt <- attr(saved, "execution")

receipt$seed
#> [1] 2026
receipt$panels
#> [1] 4
receipt$n_selected
#> [1] 300

Replay is exact when the recorded frame content, method implementation, package behavior, and registered custom methods remain compatible.

replayed <- replay_design(saved, bfa_eas)

identical(replayed$ea_id, sample$ea_id)
#> [1] TRUE
identical(replayed$.weight, sample$.weight)
#> [1] TRUE
identical(replayed$.panel, sample$.panel)
#> [1] TRUE

The execution timestamp is new. The selected rows, their order, design columns, panels, and replicate assignments are reproduced.

The selected records themselves are not stored in the JSON recipe. Save them with an ordinary data format and keep the design document beside them as an audit artifact.

Replay boundaries

The receipt describes one execute() call. A stage continuation or multi-phase pipeline uses several calls, so its final receipt is marked chained and cannot replay the whole pipeline. Save and replay each stage batch or phase separately.

A modified sample is also outside the contract. If rows or design columns changed after execution, the receipt still describes the original selection.

A materialized panel wave is derived from its master and is not independently replayable. Save the master. A rotation_program() is a registry rather than a sampling design, so archive each cohort execution and the schedule used to rebuild the registry.

A sample carrying shared weights from share_weights() has a format of its own, samplyr/shared-sample. It records the source selection and the transformation’s arguments, and nothing else. The links and the target register are supplied again at replay, the way a frame is, so the file contains no target unit and no link:

households <- data.frame(hh = paste0("h", 1:20))
people <- data.frame(
  person = paste0("p", 1:40),
  hh = rep(paste0("h", 1:20), each = 2)
)

shared <- share_weights(
  sampling_design() |> draw(n = 8) |> execute(households, seed = 3),
  targets = people,
  links = people,
  by = c(hh = "hh"),
  to = c(person = "person"),
  within = hh,
  multiplicity = complete_links()
)

shared_path <- tempfile(fileext = ".json")
write_design(shared, shared_path, frame = households)
read_design(shared_path)
#> ── Shared Weight Design ────────────────────────────────────────────────────────
#> 
#> ℹ Source keyed by hh, targets keyed by person
#> • Links grouped within hh
#> • Denominator counted from the supplied links
#> ℹ Replay with the source register, the links and the targets to rebuild the sample.

Replaying it takes the register, the links and the targets. The source selection is re-executed, then the transformation is re-applied:

rebuilt <- replay_design(
  read_design(shared_path),
  frame = households,
  links = people,
  targets = people
)
identical(rebuilt$.weight, shared$.weight)
#> [1] TRUE

Because the links and the targets are not in the file, they cannot be checked before use. The file instead records what the source selection and the result hashed to, and replay is checked against both, so a mismatch says which input was wrong. A source that does not reproduce means the register is wrong; a result that does not means the links or the targets are.

saveRDS() preserves the object whole, though it records an R object rather than a design another tool can read, and it does embed the link structure that the JSON format deliberately leaves out.

A stack_frames() collection has a format of its own, samplyr/frame-stack, because it is one selection per component rather than one in total. Give frame as a list named by component, and each component is fingerprinted against its own register:

population <- data.frame(
  person_id = 1:400,
  has_landline = rep(c(TRUE, FALSE), times = c(250, 150)),
  has_cell = rep(c(FALSE, TRUE), times = c(100, 300))
)
landline_frame <- population[population$has_landline, ]
cell_frame <- population[population$has_cell, ]

frames <- stack_frames(
  landline = sampling_design() |>
    draw(n = 50) |>
    execute(landline_frame, seed = 1),
  cell = sampling_design() |>
    draw(n = 60) |>
    execute(cell_frame, seed = 2),
  membership = c(landline = "has_landline", cell = "has_cell"),
  key = person_id
)

path <- tempfile(fileext = ".json")
write_design(frames, path, frame = list(landline = landline_frame,
                                        cell = cell_frame))

restored <- read_design(path)
restored
#> ── Frame Stack Design ──────────────────────────────────────────────────────────
#> 
#> ℹ 2 frames over key person_id
#> • landline: has_landline, seed 1
#> • cell: has_cell, seed 2
#> ℹ Replay each component against its register to rebuild the collection.

read_design() returns the components’ designs and receipts rather than the collection, which needs the registers. replay_design() supplies them and stacks the results:

replayed <- replay_design(
  restored,
  frame = list(landline = landline_frame, cell = cell_frame)
)
identical(as.data.frame(replayed), as.data.frame(frames))
#> [1] TRUE

Two things a collection cannot carry are refused rather than dropped, because either loss would return a collection that looks complete and estimates differently: overlaps from exante_overlaps(), which resolve to one chance per selected unit when the collection is formed, and a component carrying shared weights, which a collection has nowhere to put a link table for. Overlaps declared with overlap_probabilities() or overlap_weights() name columns of the components and travel with the file.

For PRN-coordinated samples, archive each executed sample and the versioned register containing stable identifiers and PRNs. Those artifacts audit sample selection. They do not preserve fieldwork outcomes, response histories, or later register maintenance.

Format and safety

The JSON stores declarative method descriptors alongside samplyr metadata. Built-in methods use a versioned vocabulary. A custom method is recorded as tool-specific and must be registered with the same implementation before replay.

Control sorting is serialized as a restricted data structure for ascending, descending, and serpentine terms. The reader rebuilds those terms without parsing arbitrary R code. write_design() refuses expressions outside that grammar.

unsupported_control <- sampling_design() |>
  draw(n = 50, control = c(population / households))

design_json(unsupported_control)
#> Error in `design_json()`:
#> ! Cannot serialize the control expression `population/households`.
#>  Only bare column names, `desc()`, and `serp()` calls on bare column names can
#>   be written to a design file.
#>  Namespace prefixes are not supported: write `desc(pop)`, not
#>   `dplyr::desc(pop)`.

Files carry a format_version. A reader refuses newer versions it does not understand and validates the fields required by older versions. Unknown tool namespaces are retained when a file is read and written again, but preservation does not imply cross-tool statistical equivalence.

Archive checklist

Keep these artifacts together:

  1. the executed sample data
  2. the design JSON and execution receipt
  3. the exact frame vintage or an access-controlled reference to it
  4. stable identifiers and PRNs when coordination is used
  5. custom method definitions and package versions
  6. schedules linking separately executed longitudinal cohorts

Use vignette("design-semantics") for the detailed design contract and vignette("rotating-panels") for cohort-specific persistence rules.