Overview
A samplyr design is a frame-independent specification.
It describes how to sample, not what to sample from.
That makes a design a natural artifact to save, version, reuse, and
share alongside the survey protocol, in a git repository, or with a
collaborator who will execute it on the confidential frame you cannot
send.
Three functions cover this workflow:
| Function | Purpose |
|---|---|
write_design() |
Save a design (or an executed sample’s design) to a JSON file |
read_design() |
Restore a design from a file or JSON string |
design_json() |
Render the same format as an in-memory string (databases, APIs) |
The file format is versioned, human-readable JSON and is diffable in version control, so the sampling plan can be inspected without evaluating R code. It is currently an experimental, samplyr-native persistence format, not the finalized cross-tool interchange standard. Its present guarantee is behavioral: a restored design executes identically to the original in a compatible samplyr environment.
execute(read_design("plan.json"), frame, seed = 1)
# is identical to
execute(design, frame, seed = 1)Saving a design
Any design can be written, from a simple SRS to a multi-stage PPS plan with control sorting:
design <- sampling_design(title = "BFA 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)The file is plain JSON:
cat(readLines(path), sep = "\n")
{
"format": "samplyr/design",
"format_version": 1,
"schema": {
"method_vocabulary": {
"id": "samplyr/common-sampling-method",
"version": 1
}
},
"design": {
"title": "BFA EA Survey",
"stages": [
{
"strata": {
"vars": ["region"],
"alloc": "proportional"
},
"draw": {
"n": 300,
"method": {
"id": "systematic_equal_probability",
"family": "equal_probability",
"algorithm": "systematic",
"replacement": "without_replacement",
"sample_size": "fixed",
"probabilities": "equal",
"standards": [
{
"vocabulary": "DDI SamplingProcedure",
"version": "1.1.4",
"code": "Probability.SystematicRandom",
"uri": "http://rdf-vocabulary.ddialliance.org/cv/SamplingProcedure/1.1.4/f189f62"
}
]
},
"round": "up",
"control": [
{
"type": "serpentine",
"variables": ["province", "commune"]
}
],
"certainty_overflow": "error",
"on_empty": "error"
}
}
]
},
"frame": {
"required_variables": [
{
"name": "region",
"role": "strata",
"stage": 1
},
{
"name": "province",
"role": "control",
"stage": 1
},
{
"name": "commune",
"role": "control",
"stage": 1
}
]
},
"tools": {
"samplyr": {
"version": "0.8.9999",
"language": {
"name": "R",
"version": "4.6.0"
},
"dependencies": {
"sondage": "0.8.8",
"svyplan": "0.8.9"
},
"design": {
"stages": [
{
"method": {
"name": "systematic",
"registry_type": null,
"fixed_size": null,
"variance_family": null,
"probabilities": "exact",
"implementation": null
}
}
]
}
}
}
}Everything the design holds is written out in full, including per-stratum allocations, auxiliary data frames for Neyman or optimal allocation, and certainty thresholds. Nothing is lost between sessions:
design_named <- sampling_design() |>
stratify_by(urban_rural) |>
draw(n = c(Urban = 120, Rural = 180))
cat(design_json(design_named, pretty = TRUE))
#> {
#> "format": "samplyr/design",
#> "format_version": 1,
#> "schema": {
#> "method_vocabulary": {
#> "id": "samplyr/common-sampling-method",
#> "version": 1
#> }
#> },
#> "design": {
#> "stages": [
#> {
#> "strata": {
#> "vars": ["urban_rural"]
#> },
#> "draw": {
#> "n": {
#> "Urban": 120,
#> "Rural": 180
#> },
#> "method": {
#> "id": "simple_random_without_replacement",
#> "family": "equal_probability",
#> "algorithm": "simple_random",
#> "replacement": "without_replacement",
#> "sample_size": "fixed",
#> "probabilities": "equal",
#> "standards": [
#> {
#> "vocabulary": "DDI SamplingProcedure",
#> "version": "1.1.4",
#> "code": "Probability.SimpleRandom",
#> "uri": "http://rdf-vocabulary.ddialliance.org/cv/SamplingProcedure/1.1.4/38e8e88"
#> }
#> ]
#> },
#> "round": "up",
#> "certainty_overflow": "error",
#> "on_empty": "error"
#> }
#> }
#> ]
#> },
#> "frame": {
#> "required_variables": [
#> {
#> "name": "urban_rural",
#> "role": "strata",
#> "stage": 1
#> }
#> ]
#> },
#> "tools": {
#> "samplyr": {
#> "version": "0.8.9999",
#> "language": {
#> "name": "R",
#> "version": "4.6.0"
#> },
#> "dependencies": {
#> "sondage": "0.8.8",
#> "svyplan": "0.8.9"
#> },
#> "design": {
#> "stages": [
#> {
#> "method": {
#> "name": "srswor",
#> "registry_type": null,
#> "fixed_size": null,
#> "variance_family": null,
#> "probabilities": "exact",
#> "implementation": null
#> }
#> }
#> ]
#> }
#> }
#> }
#> }Scalars are written as JSON numbers, named vectors as objects, and data-frame specifications as arrays of rows. The three shapes are unambiguous, so files can be written or edited by other tools without special type annotations.
Declarative design and samplyr metadata
The JSON separates declarative design content from implementation
metadata. design, frame, and
execution avoid executable R expressions, while
tools.samplyr retains exact method names, R classes, frame
hashes, and replay environment details. A samplyr file records both an
internal semantic method descriptor and its exact native spelling:
"method": {
"id": "generalized_brewer_probability_proportional_to_size",
"family": "probability_proportional_to_size",
"algorithm": "generalized_brewer",
"replacement": "without_replacement",
"sample_size": "fixed",
"probabilities": "unequal",
"standards": [{
"vocabulary": "DDI SamplingProcedure",
"version": "1.1.4",
"code": "Probability"
}]
}The internal vocabulary distinguishes the built-in algorithms and
links them to the broader DDI
Sampling Procedure controlled vocabulary. It is useful for
inspecting samplyr files, but it is not presented as the finalized
external method vocabulary. read_design() can restore a
recognized internal method without a tools.samplyr block.
Custom methods use the identifier tool_specific; the same
method must be registered through
sondage::register_method() before replay. The file records
a fingerprint of the registered implementation (formals and body of
sample_fn and joint_fn), and replay refuses a
function that differs from the recorded one even when its registry
metadata matches.
The complete, machine-readable dictionary ships as
schema/sampling-methods-v1.json inside the installed
package. Its identifier and version are declared in each document’s
schema.method_vocabulary block.
Unknown tool namespaces are retained when a design is read and written again, but preservation alone is not a claim of cross-tool design equivalence.
Reading a design back
read_design() restores a sampling_design
that behaves exactly like the original:
restored <- read_design(path)
restored
#> ── Sampling Design: BFA EA Survey ──────────────────────────────────────────────
#>
#> ℹ 1 stage
#>
#> ── Stage 1 ─────────────────────────────────────────────────────────────────────
#> • Strata: region (proportional)
#> • Draw: n = 300 (total), method = systematic, control = serp(province, commune)
s_original <- execute(design, bfa_eas, seed = 123)
s_restored <- execute(restored, bfa_eas, seed = 123)
identical(s_original$ea_id, s_restored$ea_id)
#> [1] TRUE
identical(s_original$.weight, s_restored$.weight)
#> [1] TRUEread_design() also accepts a JSON string directly, which
pairs with design_json() when designs are stored in a
database or sent over an API rather than as files:
json <- design_json(design)
identical(
execute(read_design(json), bfa_eas, seed = 7)$ea_id,
execute(design, bfa_eas, seed = 7)$ea_id
)
#> [1] TRUEFrame requirements and fingerprints
We made the design files frame-independent, so the frame data is never written. Two derived blocks describe the frame instead.
The requirements block is always recorded. It lists the columns each stage needs, tagged by role (stratification, clustering, measure of size, PRN, auxiliary, and control sorting), so that a candidate frame can be checked before execution:
info <- attr(restored, "frame_info")
bind_rows(info$required_variables)
#> # A tibble: 3 × 3
#> name role stage
#> <chr> <chr> <int>
#> 1 region strata 1
#> 2 province control 1
#> 3 commune control 1This complements validate_frame(), which performs the
full check against an actual frame:
validate_frame(restored, bfa_eas)The fingerprint block is written when you pass the
frame to write_design(). Its common part records dimensions
and portable column types such as integer,
categorical, and date. R-specific details (the
expression used for the frame, native R classes, and the
rlang::hash() content hash) are isolated in
tools.samplyr.frame. Together they are enough for samplyr
to verify the exact frame without shipping any data, while another tool
can still use the structural metadata:
write_design(design, path, frame = bfa_eas)
fingerprint <- attr(read_design(path), "frame_info")$fingerprint
fingerprint$name
#> [1] "bfa_eas"
fingerprint$nrow
#> [1] 44570
fingerprint$hash
#> [1] "dd7602cb0907d556f5d27e31d15c8137"The hash covers the column names, the column values, and the row order. It does not depend on whether the frame is a tibble or a plain data frame, or on the order of its columns. Frames that differ only in those respects are the same population.
To verify a frame months later, pass it to
validate_frame() together with the restored design. The
check is silent when the frame matches the stored fingerprint. If the
frame has been revised since the design was written, it explains what
changed and still runs the variable checks, because a design remains
executable on any compatible frame:
validate_frame(read_design(path), bfa_eas[-(1:10), ])
#> Frame differs from the one 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.Set fingerprint = "warn" to escalate the message to a
warning, or "ignore" to skip the comparison entirely.
Execution receipts
Passing an executed tbl_sample to
write_design() saves the design together with an execution
receipt: every execute() argument that affects the result
(the seed, executed stages, and panels or reps
when used), plus the number of selected units and execution timestamp.
tools.samplyr.execution records the execution-time R and
package versions and the RNG configuration.
sample <- execute(design, bfa_eas, seed = 2026, panels = 4)
write_design(sample, path, frame = bfa_eas)
receipt <- attr(read_design(path), "execution")
receipt$seed
#> [1] 2026
unlist(receipt$stages_executed)
#> [1] 1
receipt$panels
#> [1] 4
receipt$n_selected
#> [1] 300Together with the frame fingerprint, the receipt makes the sample
exactly reproducible. replay_design() passes the recorded
arguments back to execute(), so nothing has to be
reconstructed by hand:
replayed <- replay_design(read_design(path), bfa_eas)The contract is full equality of the materialized sample when the
same frame, compatible package implementations, and any required
registered custom methods are available. replay_design()
restores the recorded RNG configuration for execution and then restores
the caller’s RNG state. The replayed tbl_sample has the
same rows in the same order, the same weights and design columns, and
the same .panel and .replicate assignments;
only the execution timestamp differs.
identical(replayed$ea_id, sample$ea_id)
#> [1] TRUE
identical(replayed$.weight, sample$.weight)
#> [1] TRUE
identical(replayed$.panel, sample$.panel)
#> [1] TRUEreplay_design() first compares the frame against the
stored fingerprint and errors by default if it differs. Use
fingerprint = "warn", "inform", or
"ignore" only when you intentionally accept that the result
will not be the recorded sample. It also warns when the recorded R or
package versions differ and checks the replayed row count against the
receipt.
Two situations fall outside the single-call contract, and
write_design() warns about both. A sample built by several
execute() calls (a stage continuation or a multi-phase
pipeline) is flagged as chained in its receipt: the receipt
records only the final call, so replay_design() refuses it.
Save and replay each phase or stage batch separately. A sample whose
rows or design columns were modified after execution is flagged as
modified: its receipt describes the original execution, not
the modified object.
The sampled rows themselves are not stored, because a design file is
a recipe, not a dataset. Save the selected units with the usual
data tools (write.csv(), arrow, …) and keep the design file
next to them as the audit trail.
Drift detection with the execution digest
The receipt also carries a frame digest: the pool sizes and selection
chances that execution resolved, with no unit identifiers.
validate_frame() uses it to answer a question a fingerprint
cannot: does a candidate frame still imply the recorded sample?
A fingerprint only says that bytes changed. The digest comparison says what the change means for the design. Rescaling a size measure changes every byte of the column but not one inclusion probability, and the message says so:
pps_sample <- sampling_design(title = "EA PPS") |>
stratify_by(region) |>
cluster_by(ea_id) |>
draw(n = 25, method = "pps_brewer", mos = households) |>
execute(bfa_eas, seed = 7)
rescaled <- bfa_eas |>
mutate(households = households * 10)
validate_frame(pps_sample, rescaled)
#> Frame structure differs from the digest recorded at execution:
#> • design-relevant columns changed (role-scoped fingerprint mismatch)
#> • resolved selection chances are unchanged in the 13 comparable pools
#> ℹ This is informational. The design remains executable, but replaying or
#> extending the recorded sample on this frame would not reproduce it.Shifting sizes for some units does change the chances the design would resolve today, and the message points at the stage:
shifted <- bfa_eas |>
mutate(households = ifelse(
province == "Soum", households + 500, households
))
validate_frame(pps_sample, shifted)
#> Frame structure differs from the digest recorded at execution:
#> • design-relevant columns changed (role-scoped fingerprint mismatch)
#> • Stage 1: selection chances differ in 1 of 13 comparable pools (largest
#> difference 0.053)
#> ℹ This is informational. The design remains executable, but replaying or
#> extending the recorded sample on this frame would not reproduce it.Structural drift is reported the same way: removing or adding units names each affected pool with its recorded and current sizes. All of this is informational. A drifted frame remains executable; it just no longer reproduces the recorded sample, so replaying or extending the sample on it would give a different result.
Control expressions and safety
draw(control = ...) accepts R expressions, but a design
file represents their meaning as plain data. Each control term has a
type and an explicit array of variables:
The three types are ascending for a bare column name,
descending for desc(), and
serpentine for serp(). This declarative
grammar can be inspected without parsing R source code.
write_design() refuses expressions outside this grammar
rather than writing a file that would not round-trip:
odd_design <- sampling_design() |>
draw(n = 50, control = c(population / households))
design_json(odd_design)
#> 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)`.The reader validates the type and variables as data and constructs the restricted expression directly; it does not parse them as R code. An unknown type is rejected, even in a file received from a third party.
payload <- jsonlite::fromJSON(design_json(design), simplifyVector = FALSE)
payload$design$stages[[1]]$draw$control[[1]]$type <- "unknown"
tampered <- jsonlite::toJSON(payload, auto_unbox = TRUE, null = "null")
read_design(tampered)
#> Error in `FUN()`:
#> ! Design file contains an invalid control term.
#> ℹ Each term needs a supported type and a non-empty variables array.What round-trips, and what does not
The round-trip contract is execution equality. A restored design draws the same sample as the original under the same seed, while the objects themselves may differ in inconsequential ways:
| Aspect | Behavior after a round-trip |
|---|---|
| Stages, strata, clusters, draws | Preserved exactly, including all per-stratum vectors and data frames |
| Whole numbers | May switch between integer and double (JSON does not distinguish) |
| Data-frame specifications | Returned as tibbles; factor columns become character |
| Control expressions | Rebuilt from declarative JSON terms into equivalent quosures |
| Builder state | Not stored; the design is revalidated on read |
Files carry a format_version so that future versions of
samplyr can read (and migrate) files written today.
read_design() reports a clear error when a file was written
by a newer version than the installed one.
Where serialization fits
-
vignette("design-semantics")explains the frame-independence that makes designs portable in the first place. -
validate_frame()(seevignette("introduction")) checks any candidate frame against a design’s requirements before execution. - For coordinated samples across waves (see
vignette("sampling-coordination")), archiving each wave’s design file together with the PRN frame gives a complete, replayable history of the sample selection.