Skip to contents

Why

I have calibrated my crop model and the posterior ensemble now has to go to somebody else: an agronomist who will price a decision on it, a colleague who will re-run it next season, a reviewer who will ask whether the numbers in my figure are the numbers the code produced. The question is: what exactly do I hand over, so that the person receiving it can read the ensemble without knowing anything about PESTO’s internals, and can check that what they are holding is the run I actually made? This vignette answers it with the object PESTO emits for the purpose, and shows what happens when a single number in the handed-over ensemble is altered.

What

A finished run wraps into a pesto_ensemble_manifest, an S7 class carrying the parameter ensemble, the simulated outputs, the assimilation context (weights and target observations), the seed, the method, the lambda schedule, the failure rate, provenance strings, and a SHA-256 hash over the data. It is a versioned data contract, not a convenience wrapper: PESTO owns it as the orchestra’s C2 contract, and consuming members read it through the four verbs below rather than through PESTO’s list internals.

as_manifest() builds one from a run. write_manifest() serialises it as a YAML entry plus sidecar files, in one of three formats whose integrity contract differs. read_manifest() reads it back. verify_manifest() recomputes the hash from what is on disk and reports whether it matches the hash the YAML records. A consumer’s obligation is to call verify_manifest() before trusting anything else, and the full producer and consumer obligations are written out as a standalone format specification in inst/manifest_contract.md.

as_manifest() also has a method for pesto_abstention(), PESTO’s typed decline, so a run that a reliability gate refused still produces a record rather than nothing at all.

Do

A run worth handing over

The run below is the synthetic crop-growth inversion from Your first inversion: from a season of measurements to a posterior: three growth parameters, twelve noisy biomass measurements, one hundred and twenty realisations, eight smoother iterations. It is reproduced here so the handover has something real to carry.

set.seed(20260902L)
vec_times <- seq(0, 120, by = 10)
vec_days <- vec_times[-1L]
crop <- crop_growth_forward_model(times = vec_times)

theta_true <- c(r = 0.065, b_max = 1450, b0 = 18)
mat_true <- matrix(theta_true, nrow = 1L,
                   dimnames = list(NULL, names(theta_true)))
vec_biomass <- as.numeric(pesto_evaluate(crop, mat_true))

obs_sd <- 45
y_obs <- vec_biomass + rnorm(length(vec_biomass), sd = obs_sd)
names(y_obs) <- paste0("t", seq_along(y_obs))

s1_real <- 120L
prior <- cbind(
  r     = runif(s1_real, 0.02, 0.12),
  b_max = runif(s1_real, 900, 2000),
  b0    = runif(s1_real, 5, 60)
)

fit <- pesto_ies_callback(
  forward_model  = crop,
  prior_ensemble = prior,
  obs            = y_obs,
  obs_sd         = obs_sd,
  noptmax        = 8L,
  seed           = 20260902L,
  verbose        = FALSE
)

Wrapping it

man <- as_manifest(fit, seed = 20260902L, apsim_version = NA_character_)
man
#> <pesto_ensemble_manifest> schema 1.1.0
#>   run_id        : ies_callback_20261016_015515_4964f3e8
#>   method        : ies_callback  (noptmax=8)
#>   ensemble      : 120 realisations x 3 parameters | 12 observations
#>   failure rate  : 1.20%
#>   pesto version : 0.11.1.9000  apsim: NA
#>   timestamp     : 2026-10-16T01:55:15+0000
#>   data hash     : sha256:86b1bf4cdfb541f322a3b2acb7dd01af92f7b7a6a0b3f31c5fab6fe2ae122658

Every slot is reachable through the standard S7 accessor, which is what a consumer uses instead of reaching into a list:

man@run_id
#> [1] "ies_callback_20261016_015515_4964f3e8"
man@schema_version
#> [1] "1.1.0"
man@data_hash
#> [1] "sha256:86b1bf4cdfb541f322a3b2acb7dd01af92f7b7a6a0b3f31c5fab6fe2ae122658"
man@failure_rate
#> [1] 0.01203704
head(man@params, 3L)
#>   real_name          r    b_max       b0
#> 1    real_1 0.07241749 1406.145 13.27327
#> 2    real_2 0.06876668 1392.795 19.16421
#> 3    real_3 0.06859347 1424.358 17.24296
The manifest slots a downstream consumer reads, and what this run put in them.
Slot What a consumer reads it for Value
schema_version which version of the contract this record obeys 1.1.0
method which algorithm produced the ensemble ies_callback
noptmax how many assimilation steps were taken 8
seed the seed that reproduces the run 20260902
failure_rate the fraction of forward evaluations that returned nothing 0.0120
params the posterior parameter ensemble 120 x 3
outputs the simulated outputs matching those parameters 120 x 12
obs_target the observations the ensemble was conditioned on 12 values
weights the observation weights, one over the observation SD 12 values
data_hash the integrity check over all of the above sha256:86b1bf4cdfb541f…

Writing, reading, verifying

write_manifest() emits the YAML entry plus three RDS sidecars, one for the parameter ensemble, one for the outputs, and one for the assimilation context. RDS rather than CSV, because IEEE 754 doubles round-trip bit-exactly through it and the SHA-256 check would otherwise trip on formatter precision loss alone.

dir_run <- tempfile("pesto_manifest_")
dir.create(dir_run)
vec_paths <- write_manifest(man, file.path(dir_run, "wagga_2026_run01.yaml"))
basename(vec_paths)
#> [1] "wagga_2026_run01.yaml"        "wagga_2026_run01_params.rds" 
#> [3] "wagga_2026_run01_outputs.rds" "wagga_2026_run01_assim.rds"
man_read <- read_manifest(file.path(dir_run, "wagga_2026_run01.yaml"))
verify_manifest(man_read)$ok
#> [1] TRUE

The YAML entry is the human-readable front door; the first lines carry the identity and provenance a reader wants before opening anything else:

cat(paste(
  readLines(file.path(dir_run, "wagga_2026_run01.yaml"))[seq_len(14L)],
  collapse = "\n"
))
#> schema_version: 1.1.0
#> run_id: ies_callback_20261016_015515_4964f3e8
#> data_hash: sha256:86b1bf4cdfb541f322a3b2acb7dd01af92f7b7a6a0b3f31c5fab6fe2ae122658
#> format: rds
#> integrity: verifiable
#> obs_schema: ~
#> artefacts:
#>   params: wagga_2026_run01_params.rds
#>   outputs: wagga_2026_run01_outputs.rds
#>   assim: wagga_2026_run01_assim.rds
#> seed: 20260902
#> fidelity: ~
#> apsim_version: ~
#> pesto_version: 0.11.1.9000

Three formats, three integrity contracts

Some handovers need a file a collaborator can open in a spreadsheet. format = "both" writes the CSV alongside the RDS: the hash stays bound to the RDS, so integrity is preserved, and the YAML records the CSV paths under a separate inspection_csv: block that a consumer can ignore.

dir_both <- tempfile("pesto_manifest_csv_")
dir.create(dir_both)
vec_paths_both <- write_manifest(
  man, file.path(dir_both, "wagga_2026_run01.yaml"), format = "both"
)
basename(vec_paths_both)
#> [1] "wagga_2026_run01.yaml"                  
#> [2] "wagga_2026_run01_params.rds"            
#> [3] "wagga_2026_run01_outputs.rds"           
#> [4] "wagga_2026_run01_assim.rds"             
#> [5] "wagga_2026_run01_params_inspection.csv" 
#> [6] "wagga_2026_run01_outputs_inspection.csv"
#> [7] "wagga_2026_run01_assim_inspection.csv"

format = "csv_unverified" is the one-way export for an analyst who will never load it back into R. It writes CSV sidecars only, records integrity: not_verifiable in the YAML, and verify_manifest() then returns NA rather than a spurious FALSE, because CSV formatter precision loss of about one unit in the last place is enough on its own to move the hash. The mode is named the way it is so the weaker contract is visible at every call site.

dir_export <- tempfile("pesto_manifest_unverified_")
dir.create(dir_export)
write_manifest(man, file.path(dir_export, "snapshot.yaml"),
               format = "csv_unverified")
man_export <- read_manifest(file.path(dir_export, "snapshot.yaml"))
verify_manifest(man_export)$ok
#> [1] NA
verify_manifest(man_export)$message
#> [1] "manifest format is 'csv_unverified' (integrity: not_verifiable); bit-exact hash verification is not possible from CSV sidecars (formatter precision loss). Re-write with format = 'rds' or 'both' for verifiable integrity."
The three sidecar formats and the integrity contract each offers.
Format Sidecars YAML integrity field verify_manifest()$ok
rds (default) RDS only verifiable TRUE
both RDS plus inspection CSV verifiable TRUE
csv_unverified CSV only not_verifiable NA

What tampering looks like

The point of the hash is the case nobody notices: a downstream tool re-saves the outputs sidecar after an accidental edit or a partial overwrite. Below, one cell of the outputs ensemble is moved by 0.001 g/m2, far below the measurement noise the run was conditioned on, and the file is written back.

path_outputs <- file.path(dir_run, "wagga_2026_run01_outputs.rds")
tab_outputs <- readRDS(path_outputs)
val_before <- tab_outputs[1L, 2L]
tab_outputs[1L, 2L] <- tab_outputs[1L, 2L] + 1e-3
saveRDS(tab_outputs, path_outputs, version = 3L)

man_tampered <- read_manifest(file.path(dir_run, "wagga_2026_run01.yaml"))
res_verify <- verify_manifest(man_tampered)
res_verify$ok
#> [1] FALSE
tab_orig <- as.matrix(man@outputs[, -1L])
tab_seen <- as.matrix(man_tampered@outputs[, -1L])
mean_shift <- abs(mean(tab_seen[, 1L], na.rm = TRUE) -
                    mean(tab_orig[, 1L], na.rm = TRUE))
signif(mean_shift, 3L)
#> [1] 8.47e-06
tab_diff <- data.table::data.table(
  cell = seq_len(length(tab_orig)),
  delta = as.numeric(abs(tab_seen - tab_orig))
)
tab_diff <- tab_diff[!is.na(delta)]
tab_diff[, altered := delta > 0]

ggplot2::ggplot(
  tab_diff, ggplot2::aes(x = cell, y = pmax(delta, 1e-18), colour = altered)
) +
  ggplot2::geom_point(size = 1.1, alpha = 0.8) +
  ggplot2::geom_hline(yintercept = obs_sd, linetype = "dashed",
                      colour = "grey40") +
  ggplot2::scale_y_log10() +
  ggplot2::scale_colour_viridis_d(
    name = "cell altered", end = 0.8, labels = c("no", "yes")
  ) +
  ggplot2::labs(
    x = "cell of the outputs ensemble (realisation by observation)",
    y = "absolute difference on re-read (g/m2, log scale)",
    title = "What the hash sees and the eye does not"
  ) +
  ggplot2::theme_minimal(base_size = 12)
Cell-by-cell absolute difference between the ensemble written to disk and the ensemble read back after one value was altered. Every cell but one is identical to the last bit; the single altered cell sits at 0.001 g/m2, far below anything an ensemble summary would move. The dashed line marks the measurement noise the run was conditioned on.

Cell-by-cell absolute difference between the ensemble written to disk and the ensemble read back after one value was altered. Every cell but one is identical to the last bit; the single altered cell sits at 0.001 g/m2, far below anything an ensemble summary would move. The dashed line marks the measurement noise the run was conditioned on.

verify_manifest() returns the stored and the recomputed hash side by side, so a consumer can fail fast and say exactly what diverged rather than reporting a vague mismatch:

substr(res_verify$stored, 1L, 22L)
#> [1] "sha256:86b1bf4cdfb541f"
substr(res_verify$recomputed, 1L, 22L)
#> [1] "sha256:ff560238ad07057"

A run that declined to happen

Not every call returns an ensemble. PESTO’s reliability gates return a typed pesto_abstention() instead of an error or a half-populated result, and the manifest has a method for it: an empty-payload record whose summary slot carries the decline. A consumer therefore sees a typed refusal in the same place it would have seen a posterior, rather than a missing file.

mat_par_off <- matrix(rnorm(30L * 12L), 30L, 12L)
mat_obs_off <- matrix(rnorm(30L * 8L), 30L, 8L)
res_off <- pesto_surrogate_ies(
  par_ensemble = mat_par_off,
  obs_ensemble = mat_obs_off,
  obs_target   = rnorm(8L),
  weights      = rep(1, 8L),
  parcov_inv   = rep(1, 12L)
)
is_pesto_abstention(res_off)
#> [1] TRUE
res_off
#> $reason
#> [1] "surrogate_off_design"
#> 
#> $detail
#> [1] "surrogate training regime is unfavourable: n_train = 30 for n_params = 12 (ratio 2.50 < the check_surrogate_regime() default floor of 5)."
#> 
#> $diagnostics
#> $diagnostics$n_train
#> [1] 30
#> 
#> $diagnostics$n_params
#> [1] 12
#> 
#> 
#> $scope
#> [1] "call"
#> 
#> $abstained
#> [1] TRUE
#> 
#> attr(,"class")
#> [1] "pesto_abstention"
man_declined <- as_manifest(res_off)
dim(man_declined@params)
#> [1] 0 1
man_declined@summary$abstained
#> [1] TRUE
man_declined@summary$reason
#> [1] "surrogate_off_design"

dir_declined <- tempfile("pesto_manifest_declined_")
dir.create(dir_declined)
write_manifest(man_declined, file.path(dir_declined, "declined.yaml"))
man_declined_read <- read_manifest(file.path(dir_declined, "declined.yaml"))
man_declined_read@summary$reason
#> [1] "surrogate_off_design"
verify_manifest(man_declined_read)$ok
#> [1] TRUE

What a conformant consumer does

A consumer reads the YAML with read_manifest(), calls verify_manifest() before touching anything, refuses on integrity: not_verifiable, on a summary that reports an abstention, or on a failure_rate above whatever it is willing to tolerate, branches on schema_version for forward compatibility, and then works with the typed slots. It threads man@data_hash and man@run_id into its own provenance so the lineage closes. The short function below is that whole obligation:

consume <- function(path, max_failure = 0.05) {
  m <- read_manifest(path)
  v <- verify_manifest(m)
  if (isTRUE(is.na(v$ok))) {
    return("declined: integrity not verifiable")
  }
  if (!isTRUE(v$ok)) {
    return("declined: hash mismatch")
  }
  if (isTRUE(m@summary$abstained)) {
    return(paste0("declined: producer abstained (", m@summary$reason, ")"))
  }
  if (m@failure_rate > max_failure) {
    return("declined: failure rate above tolerance")
  }
  paste0("accepted: ", nrow(m@params), " realisations, run ", m@run_id)
}

consume(file.path(dir_both, "wagga_2026_run01.yaml"))
#> [1] "accepted: 120 realisations, run ies_callback_20261016_015515_4964f3e8"
consume(file.path(dir_export, "snapshot.yaml"))
#> [1] "declined: integrity not verifiable"
consume(file.path(dir_declined, "declined.yaml"))
#> [1] "declined: producer abstained (surrogate_off_design)"
consume(file.path(dir_run, "wagga_2026_run01.yaml"))
#> [1] "declined: hash mismatch"

Read

The handover works, and it works in both directions. Writing the run produced 4 files: one YAML entry and three sidecars. Reading them back and recomputing the hash returned TRUE, so the ensemble a colleague opens is bit-for-bit the ensemble that came out of the smoother. The record carries 120 realisations of 3 parameters against 12 target observations, and a failure rate of 0.012, which is the fraction of forward evaluations that returned nothing during the run and is exactly the kind of thing a consumer should be allowed to refuse on.

The tamper case is the one that matters. Moving one value by 0.001 g/m2 shifts the ensemble mean of that observation column by 0.0000085 g/m2, which is nothing at all beside the 45 g/m2 of measurement noise, and the figure shows why no summary statistic would ever raise it: every other cell is identical to the last bit, and the altered one sits 4.7 orders of magnitude below the noise floor. The hash sees it regardless, and verify_manifest() returns FALSE with both hashes attached so the consumer can name what diverged. That asymmetry is the whole argument for hashing the payload rather than checking it by eye.

The three formats are three different promises, and the table above states them plainly: the default RDS mode and the both mode are verifiable, and csv_unverified is not, returning NA from verify_manifest() rather than a false negative. The refusal path closes the set. The off-design surrogate call returned a typed abstention with reason ‘surrogate_off_design’, the manifest built from it carries a zero-row parameter payload and that same reason in its summary slot, and the reason survives the write and the read. The four consume() calls at the end walk exactly those four outcomes: accepted, declined for unverifiable integrity, declined because the producer abstained, and declined because the file on disk no longer matches its hash.

Limits

This vignette shows the record and the check, not the science: the manifest guarantees that an ensemble is the one that was produced, and says nothing whatever about whether the ensemble is any good. A hash-verified posterior from a badly specified likelihood is a hash-verified wrong answer, and the calibration questions belong to Wiring your own simulator into PESTO and My posterior looks too confident: is the ensemble collapsing?. The integrity check covers the payload the hash is computed over, which is the parameter ensemble, the outputs, the weights, the targets and the seed; it does not cover the optional obs_schema descriptor, whose correspondence between column names and physical quantities is established by a second, independent record that agrees on units rather than by a self-hash. Nor does the hash make a run reproducible on its own: it records the seed, the method, the iteration count and the lambda schedule, but re-running a calibration also needs the forward model, and for a simulator-backed run that means the same simulator version on the far end. The tamper demonstration alters one cell deliberately, so it shows detection, not the frequency of accidental corruption in practice, which this vignette makes no claim about.

Your first inversion: from a season of measurements to a posterior is where the run wrapped above comes from, and is the shortest route into the smoother itself. Wiring your own simulator into PESTO covers the forward-model contract on the producing side, including the failure handling that sets the failure_rate slot read here. Can PESTO recover APSIM’s own parameters? emits a manifest at the end of a full simulator-backed calibration, which is the realistic version of this handover. When can a surrogate stand in for the simulator? is where the typed abstention used above comes from, and states the regime that triggers it.

Reproduce

Seed 20260902 sets the synthetic season, the prior draw and the off-design surrogate demonstration, and is passed to pesto_ies_callback(), which records it on the run and carries it into the manifest’s seed slot. All files are written under tempfile() and removed at the end of the vignette. Package versions follow.

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.5 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.26.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
#>  [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
#>  [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
#> [10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   
#> 
#> time zone: UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] PESTO_0.11.1.9000
#> 
#> loaded via a namespace (and not attached):
#>  [1] vctrs_0.7.3         cli_3.6.6           knitr_1.52         
#>  [4] rlang_1.3.0         xfun_0.61           otel_0.2.0         
#>  [7] S7_0.2.2            textshaping_1.0.5   jsonlite_2.0.0     
#> [10] data.table_1.18.6.1 labeling_0.4.3      glue_1.8.1         
#> [13] htmltools_0.5.9     ragg_1.5.2          sass_0.4.10        
#> [16] scales_1.4.0        rmarkdown_2.32      grid_4.6.1         
#> [19] evaluate_1.0.5      jquerylib_0.1.4     fastmap_1.2.0      
#> [22] yaml_2.3.12         lifecycle_1.0.5     compiler_4.6.1     
#> [25] RColorBrewer_1.1-3  fs_2.1.0            Rcpp_1.1.2         
#> [28] farver_2.1.2        systemfonts_1.3.2   digest_0.6.39      
#> [31] viridisLite_0.4.3   R6_2.6.1            bslib_0.12.0       
#> [34] withr_3.0.3         gtable_0.3.6        tools_4.6.1        
#> [37] pkgdown_2.2.1       ggplot2_4.0.3       cachem_1.1.0       
#> [40] desc_1.4.3