Benchmarking
Report 15 · Benchmarks as content-addressed contracts, checked against what the stack runs today
Abstract¶
Two teams that report “a 10 % improvement” on the same ocean dataset are
often not comparing the same thing: they split the data differently, use
different baselines, score against different references, or keep iterating
on the test set. The scoping report calls these contract failures and
proposes that a benchmark be a frozen, content-addressed contract (task,
carriers, splits, references, metrics, baselines and submission rules), with
five pieces of infrastructure to enforce it. This page runs one sea surface
height (SSH) mapping benchmark through the pinned code. The contract’s hash
and the runner exist: pipekit hashes any configuration, and
pipekit-evaluate ships a Benchmark runner and the L0–L4 levels the
scoping report’s task list restates. The data and the baselines are in reach:
xrtoolz ships the gap-fillers, CRPS and spread-skill. What is missing is the
contract itself, splits that know about space and platforms, and a report that
names the contract it scored. The run also corrects the scoping report’s
split rule: the held-out set must reproduce the gap the method will face in
use, which is a withheld altimeter for mapping and a block larger than the
correlation scale only for generalization to unseen regions or periods. On the
same method, the choice moves the error from 1.68 to 4.18 cm. Every number
below is from a real run.
About this report
| Status | Partly built. Originally a scoping proposal (“committed”: a framework, with a gallery, now the benchmarks gallery); the framework is kept, the infrastructure is redistributed across the libraries that own each term |
| Reading time | about 30 minutes |
| Audience | anyone designing a benchmark, entering one, or turning a paper’s evaluation into one: paper authors, curators, OceanBench or WeatherBench adopters |
| Companions | Report 14 (pipekit-evaluate, whose runner and report this extends); Report 11 (the training splits); Report 12 (the registry); Report 13 (analyses, reanalyses and forecasts as catalog rows); Report 6 (catalogs and matchups) |
| Code checked against | pipekit 0.0.2 at ef3c17f (pipekit, pipekit-evaluate, pipekit-train, pipekit-experiment, pipekit-cycle); xrtoolz 0.0.5 at 308c7f2; geotoolz 0.10.2 with geocatalog 0.5.1 and geopatcher at 088475e. Read on 13 October 2026 |
| Running example | every output is from one run of notebooks/reports/report15_benchmarking.py, which builds the field and the altimeters from fixed seeds in memory and asserts each number the page shows |
| Sources | the scoping report’s source material: external writing on ocean ML benchmarks, OceanBench’s multi-track pattern Johnson et al., 2023, and WeatherBench 2 Rasp et al., 2024 as the second community benchmark it names |
Summary¶
Decision. A benchmark is a BenchmarkContract: frozen data in
pipekit-evaluate, hashed with pipekit’s stable_json and sha256_hex, and
recorded on every report it produces. Each term of the contract is
implemented where its data lives: splits of catalog rows in geocatalog and of
training windows in geopatcher (pipekit-train until
Report 16’s D2 moves the window dataset), baselines and probabilistic scores in
xrtoolz, and the content-addressed store for contracts and submissions in
pipekit-experiment. No new package, and no hosted scoring service.
A. The contract. A frozen
BenchmarkContractwith a task given as a level transition (L1 → L2 for mapping), carriers, splits, references, scorers, baselines and a status (pre-registered or exploratory), hashed and round-tripped through YAML (A1); the hash recorded on every report (A2).B. Splits as contract terms. Block, platform and causal splits of catalog rows in geocatalog, each refusing a configuration it cannot honour (B1, B2); the training window’s guard band in time units, refusing an empty split (B3). The contract states the gap the split reproduces.
C. Baselines with the carriers. Space-time optimal interpolation with a valid covariance in
xrtoolz.interpolate(C1); persistence with an explicit reference time and gap rule (C2); climatology fitted on the training period only (C3). The contract pins each configuration.D. Variants and tracks in the runner. Entries and scorers declare a variant, which the report records, and point forecasts are scored under CRPS as the degenerate distributions they are (D1); one run scores against several references and reports a missing one as unavailable (D2); entries are named, not keyed by their ladder cell (D3).
E. Registry and submissions. Contracts and reports stored content-addressed in pipekit-experiment (E1); a submission that pins the contract, the model and the report hashes, and verifies against them (E2).
End state. A curator writes a contract, publishes its hash, and anyone can run it: the splits refuse configurations that would leak, the baselines are the same code for every entry, the runner records each entry’s variant and scores every track, and the report names the contract and the data, and each of its rows the model it scored. Running the leaderboard, a blind test service and its queue stays with the projects that host one. Each transition of the six-step cycle is benchmarked by one such contract (Report 17).
The framework, as the scoping report put it¶
This section restates the scoping report’s framework; the rest of the page keeps it except where a box says otherwise.
Benchmark versus evaluation. An evaluation scores one model, once, with whatever data and choices are at hand. A benchmark is a shared contract that fixes everything a comparison depends on before anyone submits (Table 1).
Table 1:The scoping report’s distinction between an evaluation and a benchmark.
| Evaluation | Benchmark | |
|---|---|---|
| What it is | the act of scoring a model | a shared, pre-registered evaluation contract |
| Scope | one project, one run | across projects and teams |
| Permanence | ephemeral | versioned and immutable |
| Reference data | whatever is at hand | curated, frozen, content-addressed |
| Preprocessing, splits | each team’s choice | fixed and distributed as code |
| Metrics | each team’s choice | pre-registered before submissions open |
| Baselines | optional | mandatory, one shared implementation |
Tasks as carrier transformations. Each task maps an input carrier to an output carrier, so a model is anything that performs the map: discretization (points to a sparse grid), gap filling (to a dense grid), analysis (to a state), reanalysis (to a smoothed state trajectory), forecast (a state to a future state) and representation (a state to a latent and back). Every task comes in a deterministic and a probabilistic variant, scored by different metrics.
Three tracks. The same predictions are scored against a reanalysis, an analysis and the raw observations. A model that wins on the reanalysis and loses on observations has learned the assimilation product, not the ocean.
Five pieces of infrastructure. Leakage-aware splitters, the contract artifact, a baseline registry, pre-registration by content address, and a blind test set behind catalog access control, plus multi-track evaluation as a built-in pattern.
Ownership rules¶
The contract is data in pipekit-evaluate; every term it fixes is implemented by the library that owns the data the term constrains (Figure 1).
Figure 1:The contract lives in pipekit-evaluate; its splits, baselines and storage live with their data.
pipekit-evaluate owns the contract, the runner, the scorer tags (variant beside Report 14’s unit, lens and stage) and the report. It imports geocatalog, xrtoolz and pipekit-experiment only through optional extras, as it imports pipekit-cycle today.
geocatalog owns splits of catalog rows: blocks in space and time, a held-out platform, a causal cut. A row already carries the footprint, the time interval and, with Report 13’s profile, the producer, which is all a split needs. It never imports pipekit-evaluate.
The window dataset owns splits of training windows inside a dataset, and reads the contract’s split as plain arguments (fractions, a guard in time units). It never parses a contract. It is pipekit-train’s
XarrayWindowDatasettoday and moves togeopatcher.integrations.trainwith Report 16’s D2; B3 lands wherever it is when B3 is done.
The baselines and probabilistic scores go to xrtoolz (geotoolz for GeoTensor carriers; Section ??), the content-addressed store to pipekit-experiment (Section ??), and curation, the leaderboard and any blind service stay with benchmark projects such as OceanBench, WeatherBench and the gallery’s entries. Otherwise neighbours follow the stack rules.
The running example: mapping sea surface height from three altimeters¶
The problem. Map SSH anomalies every day from along-track altimetry, and decide how to rank mapping methods fairly. This is the task OceanBench’s first edition poses on real data Johnson et al., 2023; here the field is synthetic, so the truth is known and every score can be checked against theory.
The data. η(t, y, x) is an SSH anomaly in metres on a doubly periodic 800 km square, 32 × 32 cells of Δ = 25 km, daily from 1 January 2023 to 30 December 2025 (T = 1,095 days, so the test year 2025 has 364). It is a seasonal cycle of amplitude a = 4 cm plus a mesoscale anomaly with standard deviation σ = 8 cm and a Gaussian covariance
with r the distance between two points, Δt the time between them, L = 100 km and τ = 30 days, the mesoscale values the scoping report gives for SSH. Space is periodic; time is not: the field is drawn by FFT over a year longer than the record and the extra year is dropped, so the end of 2025 is uncorrelated with the start of 2023. Three altimeters s1, s2 and s3 each fly one diagonal ground track across the domain every third day and measure η with noise σₒ = 2 cm: 35,040 observations, 11,680 per satellite. The task is gap filling, L1 → L2: from the observations, produce η on the full grid.
What exists. The contract in stage 1 is a dictionary hashed with
pipekit’s own primitives. The mapping methods are written in the notebook
where the stack has none: an inverse-distance mapper in space and time
(xrtoolz’s idw_to_points weighs in two dimensions only) and an optimal
interpolation (proposed as C1). Lines marked user
(markers) are code the workstreams move
upstream.
Stage 1 · Write the contract and hash it (pipekit → pipekit-evaluate)¶
TL;DR. Write down everything the comparison depends on and give it a fingerprint that changes when anything in it changes.
Problem. A contract c is a nested mapping of the task, carriers, splits, references, metrics and baselines. Its address (2) is
the hash of its JSON encoding with sorted keys, so two contracts with the same content get the same h whatever the order they were written in, and any edit gives a new h.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36# Shapes: input the s2, s3 obs binned to the grid (1,095, 32, 32), NaN off-track; # output (364, 32, 32) maps of 2025 from pipekit.hashing import sha256_hex, stable_json contract = { "task": "L1->L2", "operation": "gap filling", "variant": "deterministic", "input": {"carrier": "sparse grid", "sources": ["s2", "s3"], "units": "m", "aggregate": "mean"}, # coincident s2/s3 samples in one cell "output": {"carrier": "grid", "shape": [364, 32, 32], "units": "m"}, "splits": {"kind": "leave_one_platform_out", "platform": "s1", "period": ["2025-01-01", "2025-12-30"]}, "references": { # each derived, with its settings "truth": {"from": "data.generator", "period": [...]}, "reanalysis": {"from": "truth", "op": "scipy.ndimage.gaussian_filter", "sigma_cells": [0, 2, 2], "mode": "wrap", "code": "scipy@…"}, "observations": {"from": "data.generator", "platform": "s1", ...}}, "data": {"generator": GEN}, # grid, L, τ, σ, noise, orbits, seed "metrics": [{"name": "rmse", "code": "xrtoolz.metrics.rmse@308c7f2"}], "scoring": { # how each track is matched and reduced "truth": {"carrier": "grid", "dims": ["time", "y", "x"]}, "reanalysis": {"carrier": "grid", "dims": ["time", "y", "x"]}, "observations": {"carrier": "points", "matchup": "cell_of_obs", "match_on": ["time", "y", "x"], "dims": ["obs"]}}, "baselines": [ # every setting that changes a score {"name": "linear_in_time", "method": "linear", "edge_fill_m": 0.0, "code": "xrtoolz.interpolate.fillnan_temporal@308c7f2"}, {"name": "oi_100km", "length_km": 100, "time_days": 30, "window_days": 15, "space_kernel": "gaussian_periodic_images", "images": [-3, 3], "code": "notebook source sha256:5288366f…", ...}, ...], # six in all: stage 3's } # h = SHA-256 of the key-sorted JSON h = sha256_hex(stable_json(contract)) # → "4f0a8b663178…" today # reverse key order → same h; withhold s2 → "8d701687cece…"; seed 1, another # OI kernel or another edge fill → a new h each [f.name for f in dataclasses.fields(pe.BenchmarkReport)] # → ["task", "domain", "axis", "results"]: no contract, no model today
The stage-1 contract as data, from a real run.
The fingerprint costs nothing: pipekit already uses stable_json and
sha256_hex for the registry and the dataset hashes, and the whole contract
is about 3 KB of JSON. It must identify the data and the baselines, not only
name them: labels
such as "osse" and "s1" say nothing about which arrays they stand for,
so this contract hashes the generator’s full configuration and seed, and a
contract over real data would carry the catalog’s content hash for each
input and reference. Each baseline likewise carries every setting that
changes its score, down to the kernel and the fill for cells with no
observation on one side, and the revision of the code that implements it:
a library at a pinned commit, or, for the OI that lives in the notebook until
workstream C1, the SHA-256 of its source, so that editing it changes the
hash. Each reference carries its derivation (the reanalysis stand-in is a
Gaussian filter of the truth, and its σ and boundary mode are in the
contract), and each track says how it is scored: the grids cell by cell, and
the withheld s1 observations by sampling the map at each observation’s cell
and reducing over the observations. Stage 3 shows that each of these settings
moves the result. What is missing is the place to put it. pipekit-evaluate’s
BenchmarkReport records the task name, the domain, the axis and the cell
results, so a report cannot say which contract, which model or which data it
scored, and a submission cannot be checked against anything (workstreams A
and E).
Stage 2 · One method, six splits (numpy and scipy, then pipekit-train)¶
TL;DR. Score one mapping method under six ways of holding data out, and see which of them predicts the error the method will make in use.
Problem. Let 𝒟 be the set of observations (tᵢ, yᵢ, xᵢ, ηᵢ). A split divides 𝒟 into a training set 𝒟_train and a test set 𝒟_test; the method sees 𝒟_train and predicts η̂ at each test point. The score is
against the noisy observation ηᵢ and against the true field. The method is inverse-distance weighting of the k = 10 nearest training observations in the scaled coordinates (x/L, y/L, t/τ).
1 2 3 4 5 6 7 8 9 10# Shapes: 35,040 obs; scaled coordinates (n, 3) = (x / L, y / L, t / τ) # ŷ = Σ wᵢ yᵢ / Σ wᵢ, wᵢ = 1 / dᵢ over the 10 nearest user splits = { "random 80/20": random_split(0.2), # 2.62 / 1.73 user "withhold s1": platform_split("s1"), # 3.32 / 2.70 user "withhold s2": platform_split("s2"), # 2.75 / 1.92 user "blocks 50 km × 7 d": blocks(50, 7), # 2.61 / 1.68 user "blocks 200 km × 60 d": blocks(200, 60), # 4.58 / 4.18 user "last 20 % of 2025": causal_split(0.8), # 8.65 / 8.39 user }
The same method under six splits, from a real run (RMSE in cm, against the observation and the truth).
Table 2:The six splits of stage 2: what each reproduces, and the method’s error under it.
| Split | Reproduces | RMSE vs observations (cm) | RMSE vs truth (cm) |
|---|---|---|---|
| random 80/20 | nothing: test points sit beside training points on the same track | 2.62 | 1.73 |
| withhold s1 | mapping where one altimeter flew, from the other two | 3.32 | 2.70 |
| withhold s2 | the same, for s2’s orbit | 2.75 | 1.92 |
| blocks 50 km × 7 d | gaps smaller than L and τ | 2.61 | 1.68 |
| blocks 200 km × 60 d | a region and season with no data at all | 4.58 | 4.18 |
| last 20 % of 2025 | a forecast of the last 73 days of 2025, with no data | 8.65 | 8.39 |
The splits disagree by a factor of five (Table 2), and no single one is “honest”. A mapping product is used where an altimeter did not fly while the others did, so the withheld-satellite rows predict its error in use: 2.7 cm against the truth for s1. The random split is optimistic by 36 % against that, because each test point has a training neighbour 25 km away on the same track and the same day. The 200 km × 60 day blocks the scoping report prescribes for SSH are pessimistic by 55 %, because they ask the method to fill a region and a season with no data, a task it is not used for. And which satellite is withheld matters (2.70 against 1.92 cm), because s1’s orbit leaves wider gaps between the other two. A fourth satellite or a different orbit would change every row: the split is a term of the contract, not a team’s choice.
1 2 3 4 5 6 7 8# Shapes: one cell's daily series, T = 1,095; input stencil −2 … 0 d, target +1 d ds = pt.XarrayWindowDataset(source={"input": cell, "target": cell}, stencils=stencils, store_version="v1") # today ds._split_guard, [len(ds.with_split(s)) for s in ("train", "val", "test")] # → 2, [871, 105, 108]: contiguous 80/10/10, a guard of 2 origins # 2 origins dropped each side: targets 5 d apart, ρ(5 d) = exp(−25 / 1,800) = 0.986 pt.XarrayWindowDataset(..., split_guard=60) # 60 d = 2τ today # → 60, [813, 0, 50]: the validation split is empty, silently
The training loader’s own split, from a real run.
pipekit-train’s window dataset already splits contiguously in time, which is the right shape for a forecast. Its guard band is the stencil’s reach (2 origins, dropped on each side of a boundary), so the last training target and the first validation target are five days apart and correlated at 0.986. A guard of two correlation times leaves the 105-day validation split, which has a boundary on each side, empty, and the dataset returns zero windows instead of refusing (workstream B3).
Stage 3 · The baselines a contract must pin (xrtoolz)¶
TL;DR. Score the simple methods every paper compares against, and see how much “the baseline” depends on how it was written.
Problem. The input is the 2025 maps gridded from s2 and s3, with no value where neither flew; s1 is withheld. Where s2 and s3 sample the same cell on the same day (242 cells in 2025) the grid holds their mean, and every baseline, OI included, reads this grid rather than the raw tracks, so all see the same input. A baseline fills every empty cell. Optimal interpolation (OI) is the best linear estimate under the covariance (1): with y the M non-empty cells in a window of ±15 days, C_oo their M × M covariance plus σₒ² I, C_go the N × M covariance between the N grid cells and the observations, μ the window mean, and the spatial factor of (1) summed over the domain’s periodic images so that it stays a valid covariance, the estimate and its variance are (4):
1 2 3 4 5 6 7 8 9 10 11 12 13# Shapes: G (T = 1,095, 32, 32) with NaN off-track, coincident s2/s3 samples # averaged; every baseline, OI included, reads G; scored on 2025's 364 days from xrtoolz.interpolate import fillnan_climatology, fillnan_temporal G.ffill("time") # pandas; persistence (carry fwd) 7.98 / 7.49 user fillnan_temporal(G, method="nearest") # nearest in time 5.59 / 5.72 today fillnan_temporal(G, method="linear") # linear in time 4.77 / 5.02 today fillnan_climatology(G, group="month", residual="zero") # monthly clim. 8.35 / 7.87 today # η̂ = μ + C_go C_oo⁻¹ (y − μ), one solve per day, ±15 d oi(G, length_km=50, time_days=30) # OI, L = 50 km 2.66 / 3.20 user oi(G, length_km=100, time_days=30) # OI, L = 100 km 1.81 / 2.62 user # 1 − 1.81 / RMSE(baseline): 77 % over persistence; 68 % and 62 % over # nearest and linear interpolation in time
Six baselines on the 2025 maps, from a real run (RMSE in cm against the truth and against the withheld s1).
Only carry forward is persistence: it repeats the last observation. Nearest
and linear interpolation in time also look at later observations, so they are
interpolation baselines, not persistence, and a forecast contract could not
use them. Against these three simple baselines OI improves by 77, 68 and
62 %, so “an improvement over the baseline” means nothing until the contract
pins which baseline and its gap handling (all three leave cells empty before
the first or after the last observation, which the notebook fills with zero).
OI itself depends on its assumptions: half the right correlation length costs
47 %. And the assumption can break the method. The usual Gaussian over the
shortest distance on a periodic domain is not a covariance at all: before the
noise term is added, its smallest eigenvalue on a day of 2025 is
−9.1 × 10⁻⁶ m² at L = 100 km and −1.7 × 10⁻³ m² at L = 150 km. The noise
σₒ² = 4 × 10⁻⁴ m² hides the first and not the second. The Gaussian summed
over the domain’s periodic images is a valid covariance (smallest eigenvalue
zero to rounding at both lengths), and it is what the notebook’s OI uses, so
a baseline must also fix a valid kernel for its domain. xrtoolz ships every gap-filler above except carry
forward and OI; it has fillnan_rbf and inverse-distance weighting but no
covariance-based interpolation (workstreams C1 and C2).
Stage 4 · A deterministic and an ensemble entry under CRPS (xrtoolz)¶
TL;DR. Score a map and an ensemble of maps with the same probabilistic score, and see whether the score tells them apart.
Problem. The continuous ranked probability score of a forecast distribution F against the truth y, estimated from N_e members xᵢ, is (5):
with X and X′ independent draws from F. For a point forecast the second term is zero and CRPS is the mean absolute error. The ensemble is 20 draws xᵢ = η̂ + σ̂ εᵢ, εᵢ ~ 𝒩(0, 1), from the L = 100 km OI of stage 3.
1 2 3 4 5 6 7 8# Shapes: m25 (364, 32, 32); ens (N_e = 20, 364, 32, 32) from xrtoolz.metrics import crps_ensemble, spread_skill_ratio crps_ensemble(ens, truth25, dims=dims) # → 0.96 cm today spread_skill_ratio(ens, truth25, dims=dims) # → 0.96 today crps_ensemble(m25.expand_dims(member=1), truth25, dims=dims) # → ValueError: Ensemble/sample dim 'member' has size 1; need at least 2. crps_ensemble(xr.concat([m25, m25], "member"), truth25, dims=dims) # → 1.29 cm, equal to the mean absolute error of m25 today
CRPS of an ensemble and of a deterministic map, from a real run.
A point forecast is a valid, degenerate predictive distribution, and its CRPS is its mean absolute error; WeatherBench 2 scores deterministic baselines that way beside ensembles Rasp et al., 2024. So the comparison is meaningful: the map scores 1.29 cm, the calibrated ensemble 0.96 cm, and the proper score rewards the ensemble for its spread. The defect is in the plumbing: xrtoolz refuses a one-member ensemble, so a point entry has to be entered twice to be scored. The fix (workstream D1) scores point forecasts directly and records each entry’s variant on the report, so a reader sees which rows are deterministic; refusing them would remove the baselines a probabilistic benchmark needs.
Stage 5 · Three references (xrtoolz → pipekit-evaluate)¶
TL;DR. Score the same maps against a reanalysis, the truth and the withheld observations, and see whether the ranking survives.
Problem. A reanalysis is the output of an assimilation system and inherits its smoothing. Here the stand-in is the true field smoothed with a Gaussian of 50 km (two cells). “Learned the reanalysis” is the L = 100 km OI map smoothed the same way, as a model trained against that product would be. Each entry is scored with (3) on three tracks.
1 2 3 4 5 6 7# Shapes: each entry (364, 32, 32); s1 track: the withheld 2025 observations rean = truth.copy(data=gaussian_filter(eta, sigma=(0, 2, 2), mode="wrap")) # user for name, p in entries.items(): # reanalysis · truth · s1 scores[name] = (rmse(p, rean), rmse(p, truth), rmse_at(p, s1)) # user # OI, L = 100 km 2.27 · 1.81 · 2.62 # OI, L = 50 km 2.82 · 2.66 · 3.20 # learned the reanalysis 1.24 · 2.66 · 3.27
One set of maps, three tracks, from a real run (RMSE in cm).
The entry that learned the reanalysis wins its track by a factor of 1.8 and loses the other two, coming last on the observations. The truth (which a real benchmark does not have) and the withheld observations agree on the winner, the L = 100 km OI, and the reanalysis does not. That is the scoping report’s three-track argument, reproduced: a single reanalysis track would have put first the map that comes last on the observations. Today each track is a separate loop the user writes; the runner takes one reference per cell (workstream D2).
Stage 6 · The runner pipekit-evaluate ships (pipekit-evaluate)¶
TL;DR. Run the baselines through the benchmark runner that exists, and see what it records.
Problem. pipekit-evaluate’s Benchmark takes a Task whose datasets()
returns the observations and the truth, a list of Estimators each placed at
a ladder cell (level, rung, complexity), and Scorers. It resolves each
cell’s reference with its ReferenceRule and scores the prediction.
1 2 3 4 5 6 7 8 9 10# Shapes: obs G (1,095, 32, 32); θ the true 2025 field (364, 32, 32) ests = [Baseline("linear in time", rung=2, complexity=0), # user Baseline("OI, L = 50 km", rung=2, complexity=1), Baseline("OI, L = 100 km", rung=2, complexity=2)] rep = pe.Benchmark(task=MappingTask(), estimators=ests, scorers=[RMSEScorer()]).run() # today # → L4/r2/c0 known_theta 4.77 · L4/r2/c1 known_theta 2.66 # L4/r2/c2 known_theta 1.81 pe.Benchmark(..., estimators=[*ests, Baseline("nearest in time", 2, 0)]) # → ValueError: duplicate benchmark cells (rung, complexity): [(2, 0)] today
Three baselines as estimators in the shipped runner, from a real run.
The runner works on a mapping task as written: the protocols are structural,
so a user’s task and estimators satisfy them without importing
pipekit-evaluate. Three things stop it running a contract. Its cells are
ladder coordinates, so two baselines of the same rung and complexity collide,
and the report names cells, not methods. Its level defaults to L4 although
mapping ends at L2, because nothing sets it from the task. And Task has no
notion of a split: datasets() is one dictionary, so the train and test sets
of stage 2 are a convention between the user’s task and estimators
(workstreams A and D).
The scoping report’s names, checked¶
Table 3 sets every name the scoping report uses against the pinned code.
Table 3:Every name the scoping report uses, against the pinned code.
| Scoping report | At the pinned revisions |
|---|---|
BenchmarkContract, content_hash, to_yaml, from_yaml | do not exist; pipekit.hashing.stable_json and sha256_hex hash any mapping today (stage 1) |
task Literal["discretization", …, "representation"] | pipekit-evaluate’s LEVEL_NAMES (L0 to L4) name the same carriers; representation has no level |
Metric.differentiable, stage_compatibility, variant | do not exist; Report 14’s A1 proposes unit, lens, stage and differentiable tags on Scorer |
pipekit_train.splitters (SpatioTemporalBlockSplit, LeaveOnePlatformOut, CausalTemporalSplit, LeaveOneRegionOut) | do not exist; XarrayWindowDataset and SimulationDataset split 80/10/10 in fixed tenths, the first with a guard band |
PersistenceBaseline, ClimatologyBaseline, LinearTrendBaseline, OptimalInterpolationBaseline | do not exist as baselines; xrtoolz ships fillnan_temporal, fillnan_climatology, CalculateClimatology and fillnan_rbf, and no OI |
MultiTrackEvaluation | does not exist; Report 14’s A4 proposes several references per cell |
registry.register_contract, load_contract | do not exist; ModelRegistry.store takes an Operator, and _JsonArtifact gives JSON save and load to the two artifacts |
BenchmarkSubmission.verify, EvaluationReport.was_produced_from | do not exist; BenchmarkReport records no contract or model (stage 1) |
AccessGatedGeoCatalog | does not exist; geocatalog has no access control |
pipekit-cycle.DACycle, SmootherCycle, Cycle for analysis, reanalysis, forecast | exist, as named (Report 10) |
xr_toolz.interpolate for discretization and gap filling | exists as xrtoolz.interpolate: bin_2d, points_to_grid, idw_to_grid, the fillnan_* family |
| pyrox-gp, gaussx for Gaussian-process gap filling | not pinned in references/, so not checked |
Current-state audit¶
Four of the nine symbol groups need action (Table 4); Figure 2
shows what the page’s sketches call. Read on 13 October 2026 at the
revisions in the report’s header, on main of each submodule.
Figure 2:Of the page’s 45 marked stack calls, 14 run today, 15 are proposed by this report’s workstreams, 1 by another report and 15 are code the reader writes (how to read it: conventions).
Table 4:The benchmark surface, by symbol group.
| Symbols | Defined in | Used by | Verdict |
|---|---|---|---|
stable_json, sha256_hex | pipekit hashing.py | the registry, hash_config in pipekit-train | Healthy. Deterministic; hashes the stage-1 contract as is |
Benchmark, BenchmarkReport, CellResult | pipekit-evaluate benchmark/run.py | stage 6 | Split ownership. Cells keyed by (rung, complexity); the report records no contract, model or data |
Task, Estimator, Scorer, Estimate | benchmark/protocols.py | stage 6 | Split ownership. No split, no variant, no estimator name |
LEVEL_NAMES, Cell | benchmark/cube.py | Benchmark(level="L4") | Healthy. The vocabulary the contract’s task should use; the level is never set from the task |
XarrayWindowDataset split, _split_origins, split_guard | pipekit-train xarray_window.py:113-133 | Report 11’s window dataset | Split ownership. Temporal only, fixed 80/10/10; the guard counts origins and can empty a split (stage 2) |
SimulationDataset split | pipekit-train dataset.py:220-226, :295-299 | training on simulations | Healthy. Disjoint seed ranges per split, so no leakage between draws |
fillnan_temporal, fillnan_climatology, fillnan_rbf, idw_to_points, CalculateClimatology | xrtoolz interpolate/_src/gap_fill.py, knn.py, geo/operators.py | stage 3 | Healthy. The baselines a gap-filling benchmark needs, minus OI |
crps_ensemble, spread_skill_ratio, rank_histogram, ensemble_coverage, rank_methods, rmse_skill_scores | xrtoolz metrics/_src/ | stage 4 | Healthy. Probabilistic scores and a leaderboard table; the variant is checked on array shape only |
ModelRegistry, LocalModelRegistry, _JsonArtifact | pipekit-experiment protocols.py:51, registry.py, artifacts.py | Report 12 | Split ownership. Content-addressed storage for operators only; artifacts save as JSON but are not addressed by hash |
Smaller findings:
rank_methodsalready builds a leaderboard. xrtoolz’smetrics/_src/leaderboard.pysorts a per-method score dataset into a table; a report’sto_pandas(Report 14’s A3) can feed it.rmse_skill_scoresfixes one convention. It pinsddof=0to match an upstream OSSE report, the kind of choice a contract has to record.fillnan_climatologycomputes its climatology from its input. On a test year that is legitimate (the inputs are observations of that year), but a climatology forecast must use the training years only, which needs a separate fit (workstream C3).The window dataset’s empty split is silent.
XarrayWindowDatasetkeeps a zero-length split rather than raising, so a guard set from the physics can remove a split without notice (stage 2).
Workstream A · The contract¶
Decision. A benchmark is a frozen dataclass of plain data in
pipekit_evaluate.benchmark.contract, hashed with pipekit’s primitives, and
its hash travels with every report it produces (Table 5).
Table 5:Workstream A’s moves.
| Symbol | Destination | Reason |
|---|---|---|
BenchmarkContract(name, version, task, operation, input, output, variant, splits, references, data, scorers, baselines, submission, status) | pipekit_evaluate.benchmark.contract | the scoping report’s artifact; task is a level transition ("L1->L2") and operation what it does ("gap filling"); data holds the content hashes of the inputs and references (or, for an OSSE, the generator and its seed); each baseline lists every setting that changes its score and its code revision; status is "pre-registered" or "exploratory" |
content_hash, to_yaml, from_yaml | the same module, over pipekit.hashing | stage 1’s hash, with YAML for people and JSON for the hash |
contract_hash on BenchmarkReport (and Report 14’s EvaluationReport), and model_hash on each of its results | benchmark/run.py, report.py | stage 1: a report names nothing it scored; one run scores several entries (stage 6 runs three), so the model identity belongs to the row, not the report |
Benchmark.from_contract(contract, estimators) | benchmark/run.py | sets the level, the scorers and the references from the contract (stage 6) |
| templates per task | pipekit_evaluate.benchmark.templates | the scoping report’s mitigation for long contracts |
Make the contract data. Every field is a string, a number, a list or a mapping, so the contract round-trips through YAML and hashes stably. Splits, scorers and baselines are given by
_target_and arguments, as Hydra would build them; the contract never holds an object.Hash the content, not the file.
content_hashissha256_hex(stable_json(asdict(self))), so formatting and key order never change it.Record the hash everywhere. The runner copies the contract hash onto the report; a report without one is exploratory by construction.
1 2 3 4 5 6 7 8 9 10 11 12# Shapes: as stage 1 # Today: h = sha256_hex(stable_json(contract)) # a dict today # After: c = pe.BenchmarkContract(name="ssh-mapping-synthetic", version="1.0.0", task="L1->L2", variant="deterministic", splits={"_target_": "geocatalog.splits.split_by", "column": "producer", "test": "s1"}, # (B2) status="pre-registered") # proposed (A1) c.content_hash() # proposed (A1) rep = pe.Benchmark.from_contract(c, estimators=ests).run() # proposed (A2) rep.contract_hash == c.content_hash() # proposed (A2)
Stage 1’s contract, before and after workstream A.
Workstream B · Splits as contract terms¶
Decision. A split is a function of catalog rows in geocatalog, or of
windows in geopatcher.integrations.train (pipekit-train until
Report 16’s D2 moves the window dataset), configured by the contract and refusing what it
cannot honour; the contract also says which gap the split reproduces
(Table 6).
Table 6:Workstream B’s moves.
| Symbol | Destination | Reason |
|---|---|---|
split_blocks(catalog, *, km, days, test_fraction, seed, min_scale=None, crossing="drop") | geocatalog.splits (new) | stage 2’s blocks; rows carry footprints and intervals, so a row is assigned only when it lies wholly inside one block (crossing="drop", or "group" to merge the blocks it touches); raises when a block is smaller than the declared scale or the domain cannot hold one |
split_by(catalog, column, *, test) | geocatalog.splits | stage 2’s withheld satellite, over a platform or producer column (Report 13’s producer) |
split_causal(catalog, *, train_end, val_end) | geocatalog.splits | the forecast split: every test row after every training row |
split_regions(catalog, regions) | geocatalog.splits | the scoping report’s leave-one-region-out, as footprint intersection |
fractions and guard as a pd.Timedelta | XarrayWindowDataset, _split_origins: in geopatcher.integrations.train after Report 16’s D2, in pipekit-train before it | stage 2: the guard counts origins, and a long one empties the validation split silently |
gap field on the contract’s split | BenchmarkContract.splits | stage 2’s correction: the split states the deployment gap it reproduces |
B1 · Blocks in space and time¶
A block split tiles space and time into blocks and holds out whole blocks. A row whose footprint or time interval crosses a block boundary would carry observations from one block into the other, so such rows go to neither side by default (or, as an option, are grouped with every block they touch). It refuses a block smaller than the declared correlation scale when the contract asks for generalization, and a block that does not fit the catalog’s domain.
B2 · Platform, causal and region splits¶
The platform split is the one stage 2 shows a mapping benchmark needs: hold out every row of one producer. The causal split is the forecast’s. Both are row filters over columns geocatalog already has, or that Report 13’s state profile adds.
B3 · The window guard in time¶
XarrayWindowDataset (in geopatcher.integrations.train once
Report 16’s D2 moves it there) takes its
guard as a duration, converts it to origins
with the source’s step, and raises when any split ends up empty, naming the
guard and the split’s length.
1 2 3 4 5 6 7 8 9 10 11# Shapes: 35,040 obs rows; the window dataset as stage 2 # Today: tr, te = sat != "s1", sat == "s1" # user pt.XarrayWindowDataset(..., split_guard=60) # → val has 0 windows today # After: train, test = gc.splits.split_by(obs_cat, "producer", test="s1") # proposed (B2) gc.splits.split_blocks(obs_cat, km=200, days=60, test_fraction=0.2, seed=0, min_scale=(100, 30)) # proposed (B1) from geopatcher.integrations.train import XarrayWindowDataset # proposed (R16 D2) XarrayWindowDataset(..., guard=pd.Timedelta("60D")) # → ValueError: guard 60 days leaves split 'val' (105 days) empty proposed (B3)
Stage 2’s splits, before and after workstream B.
Workstream C · Baselines with the carriers¶
Decision. Baselines are ordinary operators in the carrier library, beside the gap-fillers xrtoolz ships; the contract names them and pins every argument (Table 7).
Table 7:Workstream C’s moves.
| Symbol | Destination | Reason |
|---|---|---|
optimal_interpolation(obs, grid, *, kernel, length, time_scale, noise, window) and OptimalInterpolation | xrtoolz.interpolate | stage 3’s best baseline, absent from the stack; the kernel is valid on the grid (images summed on periodic domains, or a compactly supported kernel), checked on the prior before the noise is added by its smallest eigenvalue against a scale-aware tolerance (λ_min ≥ −10⁻¹⁰ σ²); a strict Cholesky would reject a valid kernel, since the prior is only semidefinite (colocated observations make it singular) |
persistence(field, *, lead, reference="last_available", gap="nan") | xrtoolz.geo (forecast baselines) | stage 3: persistence is carry-forward with an explicit reference time; nearest and linear interpolation in time are interpolation baselines (fillnan_temporal), not persistence, and the improvement over each differs by up to 14 points |
ClimatologyForecast(train_period, freq) | xrtoolz.geo, over CalculateClimatology | a climatology fitted on the training years only, unlike fillnan_climatology |
linear_trend(field, *, n_recent) | xrtoolz.geo | the scoping report’s trend baseline |
1 2 3 4 5 6 7 8# Shapes: G (1,095, 32, 32); output (364, 32, 32) # Today: mean, var = oi(G, length_km=100, time_days=30) # user # After: # η̂ = μ + C_go C_oo⁻¹ (y − μ); σ̂² = σ² − diag(C_go C_oo⁻¹ C_og) mean, var = xi.optimal_interpolation(G, grid, kernel="gaussian", length=100e3, time_scale="30D", noise=0.02, window="15D") # proposed (C1)
Stage 3’s OI baseline, before and after workstream C.
Workstream D · Variants and tracks in the runner¶
Decision. The runner checks an entry against the contract before scoring it, scores every declared reference, and names entries (Table 8).
Table 8:Workstream D’s moves.
| Symbol | Destination | Reason |
|---|---|---|
variant on Scorer and on the entry’s Estimate ("deterministic", "probabilistic") | benchmark/protocols.py, beside Report 14’s A1 tags | stage 4: the report should say which rows are point forecasts |
| point forecasts under CRPS | xrtoolz.metrics.crps_ensemble and the runner | a point forecast is scored as a degenerate distribution (its CRPS is its MAE) without duplicating it; stage 4’s duplication goes |
references: Mapping[str, ReferenceSource | str] | Benchmark, building on Report 14’s A4 | stage 5’s three tracks in one run; a track with no data reports “unavailable”, as CellResult.note already does for a missing oracle |
per-track scoring from the contract’s scoring (carrier, matchup rule, reduction dims) | Benchmark.from_contract | stage 5: a gridded track is scored over (time, y, x), the s1 track only after sampling the map at each observation and reducing over obs; without the rule the runner cannot reproduce the observation scores |
MultiTrackEvaluation | not added | the multi-reference runner is the same object; a second class would duplicate it |
name on Estimator; results keyed by name | benchmark/protocols.py, run.py | stage 6: two baselines in one cell collide |
datasets(split) on Task | benchmark/protocols.py | stage 6: a task has no train and test sets today |
1 2 3 4 5 6 7 8# Shapes: as stages 4 and 5 rep = pe.Benchmark.from_contract( c, estimators=[oi_100, oi_50, learned_reanalysis], # named entries references={"reanalysis": rean_cat, "observations": s1_cat, "analysis": None}, # → "unavailable" proposed (D2) ).run() # proposed (D3) pe.Benchmark.from_contract(c_probabilistic, estimators=[oi_100_map]).run() # → scored as a point forecast (CRPS = MAE), row tagged "deterministic" proposed (D1)
Stages 4 to 6, after workstream D.
Workstream E · Registry and submissions¶
Decision. pipekit-experiment stores contracts, reports and submissions by content hash, and a submission verifies by recomputing hashes, not by trust (Table 9).
Table 9:Workstream E’s moves.
| Symbol | Destination | Reason |
|---|---|---|
ArtifactStore.put(obj) -> hash, get(hash) for JSON artifacts | pipekit_experiment (beside ModelRegistry, same layout) | store takes only operators; contracts and reports are data |
BenchmarkSubmission(contract_hash, model_hash, report_hash) with verify(store) | pipekit_evaluate.benchmark.submission | one submission is one entry: checks that the report’s contract_hash matches, that a result row carries the submitted model_hash, that every row carries a model hash, and that the contract is pre-registered |
max_test_evaluations | BenchmarkContract.submission | recorded and counted per model hash; enforcing it needs a service, which stays with the project |
1 2 3 4 5 6# Shapes: three hashes of 64 hex characters store = px.ArtifactStore("registry") # proposed (E1) h = store.put(c) # = c.content_hash() proposed (E1) sub = pe.BenchmarkSubmission(contract_hash=h, model_hash=m_hash, report_hash=store.put(rep)) # proposed (E2) sub.verify(store) # → True proposed (E2)
Publishing a contract and verifying a submission, after workstream E.
Testing gates¶
Each workstream merges only when its gates (Table 10) pass on the running example.
Table 10:Gates, by workstream.
| Gate | Workstream | Passes when |
|---|---|---|
| A1 Contract hash | A | the stage-1 contract as a BenchmarkContract hashes to the same value after a YAML round trip and in any key order; changing any one field (the withheld platform, a block size, a baseline argument) changes the hash |
| A2 Report hash | A | Benchmark.from_contract(c, …).run().contract_hash == c.content_hash(), the report’s level is the contract’s (L2 for stage 6, not L4), and each of its three rows carries its entry’s model hash |
| B1 Blocks | B | on stage 2’s observations, split_blocks(km=200, days=60, seed=0) reproduces the notebook’s test set and its RMSE (4.18 cm against the truth) to 1e-4, assigning no row that crosses a block boundary; km=50 with min_scale=(100, 30) raises ValueError |
| B2 Platform and causal | B | split_by("producer", test="s1") reproduces stage 2’s 2.70 cm, and every test time of split_causal is later than every training time |
| B3 Window guard | B | XarrayWindowDataset(guard=pd.Timedelta("60D")), imported from wherever Report 16’s D2 has left it, on stage 2’s series raises ValueError naming the empty split; with guard="2D" its splits are 871, 105 and 108 windows |
| C1 OI | C | optimal_interpolation reproduces stage 3’s 1.81 and 2.62 cm to 1e-4 m; with L = 150 km on the 800 km periodic grid its prior covariance, before the noise term, has smallest eigenvalue λ_min ≥ −10⁻¹⁰ σ² (semidefinite to rounding), where the minimum-image Gaussian gives −1.7 × 10⁻³ m² |
| C2 Persistence | C | persistence reproduces stage 3’s carry-forward 7.98 cm and uses no observation after its reference time; nearest and linear interpolation in time (5.59 and 4.77 cm) are separate interpolation baselines, and each configuration hashes differently |
| C3 Climatology | C | ClimatologyForecast(train_period=("2023", "2024")) uses no 2025 value: perturbing every 2025 input changes its output by 0 |
| D1 Variant | D | on a probabilistic contract, stage 4’s map is scored without duplication at its MAE, 1.29 cm, its row is tagged deterministic, and the 20-member ensemble scores 0.96 cm CRPS |
| D2 Tracks | D | one run, driven by the contract’s scoring, reproduces stage 5’s nine scores to 1e-4 m (the s1 track through its matchup, over obs), and a track with no reference appears with the note “unavailable” |
| D3 Names | D | stage 6’s four baselines, two at the same cell, run without error and the report is keyed by their names |
| E1 Store | E | store.put(c) == c.content_hash() and store.get(h) returns a contract equal to c |
| E2 Verify | E | each of the stage-6 report’s three entries verifies with its own model hash, and a submission fails after any change to the contract, its model hash or the report, or when a row has no model hash |
Sequencing and risks¶
The contract and the splits come first, because every later check needs something to check against (Figure 3); the hosted service never comes into the framework.
Figure 3:The contract and its splits first, then the baselines and runner checks, then the store.
The contract outgrows its readers. A full contract with carriers, splits, scorers and baselines is long for a person to write. Fallback: templates per task (A), and a contract can reference a template by hash and override fields.
Splits over rows need rows. A dataset that is one Zarr store has one catalog row, so a block split cannot see inside it. Fallback: split the windows inside the dataset (B3) with the same block rule, or index the store per time step with Report 13’s per-step option.
A split that matches use needs knowledge of use. Stage 2 shows the right split depends on the gap the method will face, which is a scientific judgement. Fallback: the contract’s
gapfield is required and free text, so at least the choice is stated and reviewable.OI does not scale as written. One dense solve per day is fine for 320 to 704 observations, not for a global product. Fallback: a localized OI (a radius per grid cell) behind the same function, or the Gaussian-process tools once pyrox-gp or gaussx is pinned.
Blind tests need a service. Without one,
max_test_evaluationsis recorded but not enforced. Fallback: the contract marks its test setblind, and projects that run a service enforce it there.Two report types. Report 14’s
EvaluationReportand the ladder’sBenchmarkReportcould both grow hashes. Fallback: the hash fields go on the one report Report 14’s A3 leaves standing.
Open questions¶
Does a contract pin code or names? A baseline pinned as
xrtoolz.interpolate.optimal_interpolationwith arguments still changes if xrtoolz changes. Should a contract also pin the library versions, asTrainingArtifact.deps_lockdoes?Who sets the gap? Stage 2’s withheld satellite is right for mapping with today’s constellation. Should a contract carry several splits (a withheld satellite and a withheld region) and report both?
Pre-registered means what? The hash proves the contract did not change. It does not prove it was published before the results. Is a timestamped record outside the repository (a DOI, a tagged release) required for the
pre-registeredstatus?Representation tasks. They are not a level transition. Do they need a contract kind of their own, or are they scored only through a downstream task?
Where does a reanalysis track come from? Report 13’s state profile indexes reanalyses as catalog rows. Should a contract’s references be catalog hashes rather than URIs?
Upcoming work¶
Table 11 lists the follow-ups outside the workstreams.
Table 11:Follow-ups from checking the scoping report against the code.
| Item | Where | Why |
|---|---|---|
| Review the gallery’s eighteen draft contracts with domain experts, then pre-register them | benchmarks gallery | the drafts hash today, but their metrics, baselines and splits need review before their hashes mean anything |
| Raise on an empty split | XarrayWindowDataset (pipekit-train today, geopatcher.integrations.train after Report 16’s D2) | stage 2: a long guard empties a split silently; independent of B3’s units |
| Index xrtoolz submodules in the capability index | scripts/capabilities.py (UPSTREAM) | the index lists only top-level xrtoolz, so fillnan_*, crps_ensemble and rank_methods are not searchable (Report 14 found the same) |
| Pin pyrox-gp or gaussx | references/, UPSTREAM | the scoping report maps Gaussian-process gap filling to them; neither is checked |
| Write the representation contract | open question 4 | no level transition to name it by |
References¶
Sources¶
The scoping report, “Report 15 — Benchmarking as first-class infrastructure” (supplied by the author).
The pinned code in
references/: pipekit (hashing.py), pipekit-evaluate (benchmark/), pipekit-train (xarray_window.py,dataset.py), pipekit-experiment (registry.py,artifacts.py), xrtoolz (interpolate/,metrics/,geo/) and geocatalog.OceanBench, the SSH mapping benchmark the running example imitates Johnson et al., 2023; WeatherBench 2 Rasp et al., 2024.
Block cross-validation for spatially and temporally structured data Roberts et al., 2017, the standard reference for the leakage argument of stage 2.
The running example’s notebook, described in Section ??.
- Johnson, J. E., Febvre, Q., Gorbunova, A., Metref, S., Ballarotta, M., Le Sommer, J., & Fablet, R. (2023). OceanBench: The Sea Surface Height Edition. Advances in Neural Information Processing Systems 36, Datasets and Benchmarks Track. https://proceedings.nips.cc/paper_files/paper/2023/file/f6ccbf94fa57c2ae372ece91b537574d-Paper-Datasets_and_Benchmarks.pdf
- Rasp, S., Hoyer, S., Merose, A., Langmore, I., Battaglia, P., & others. (2024). WeatherBench 2: A Benchmark for the Next Generation of Data-Driven Global Weather Models. Journal of Advances in Modeling Earth Systems. https://arxiv.org/abs/2308.15560
- Roberts, D. R., Bahn, V., Ciuti, S., Boyce, M. S., Elith, J., Guillera-Arroita, G., Hauenstein, S., Lahoz-Monfort, J. J., Schröder, B., Thuiller, W., Warton, D. I., Wintle, B. A., Hartig, F., & Dormann, C. F. (2017). Cross-validation strategies for data with temporal, spatial, hierarchical, or phylogenetic structure. Ecography, 40(8), 913–929. 10.1111/ecog.02881