Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

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.

Keywords:benchmarkcontractpre-registrationleakageblock cross-validationbaselinesmulti-track evaluation

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.

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.

EvaluationBenchmark
What it isthe act of scoring a modela shared, pre-registered evaluation contract
Scopeone project, one runacross projects and teams
Permanenceephemeralversioned and immutable
Reference datawhatever is at handcurated, frozen, content-addressed
Preprocessing, splitseach team’s choicefixed and distributed as code
Metricseach team’s choicepre-registered before submissions open
Baselinesoptionalmandatory, 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).

Benchmark projects write contracts and read reports through pipekit-evaluate, which ships Benchmark, ReferenceRule, Scorer, Task and BenchmarkReport and would add BenchmarkContract, variant and track checks and a contract hash on reports. Below it, geocatalog holds block, platform and causal splits of rows, geopatcher the window splits and their guard (in pipekit-train until Report 16's D2 moves them), xrtoolz the baselines and CRPS, and pipekit-experiment the content-addressed store. All four use pipekit's hashing.

Figure 1:The contract lives in pipekit-evaluate; its splits, baselines and storage live with their data.

  1. 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.

  2. 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.

  3. 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 XarrayWindowDataset today and moves to geopatcher.integrations.train with 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

C(r,Δt)=σ2exp⁡(−r22L2−Δt22τ2),C(r, \Delta t) = \sigma^2 \exp\left(-\frac{r^2}{2 L^2} - \frac{\Delta t^2}{2 \tau^2}\right) ,

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

h(c)=SHA256(jsonsorted(c)),h(c) = \mathrm{SHA256}\left(\mathrm{json}_{\mathrm{sorted}}(c)\right) ,

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

RMSE=(1∣Dtest∣∑i∈Dtest(η^i−ηi)2)1/2,\mathrm{RMSE} = \left(\frac{1}{|\mathcal{D}_{\mathrm{test}}|} \sum_{i \in \mathcal{D}_{\mathrm{test}}} (\hat{\eta}_i - \eta_i)^2\right)^{1/2} ,

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.

SplitReproducesRMSE vs observations (cm)RMSE vs truth (cm)
random 80/20nothing: test points sit beside training points on the same track2.621.73
withhold s1mapping where one altimeter flew, from the other two3.322.70
withhold s2the same, for s2’s orbit2.751.92
blocks 50 km × 7 dgaps smaller than L and τ2.611.68
blocks 200 km × 60 da region and season with no data at all4.584.18
last 20 % of 2025a forecast of the last 73 days of 2025, with no data8.658.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):

η^=μ+CgoCoo−1(y−μ),σ^2=σ2−diag(CgoCoo−1Cog).\begin{aligned} \hat{\eta} &= \mu + C_{go} C_{oo}^{-1} (y - \mu) , \\ \hat{\sigma}^2 &= \sigma^2 - \mathrm{diag}\left(C_{go} C_{oo}^{-1} C_{og}\right) . \end{aligned}
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):

CRPS(F,y)=EF∣X−y∣−12EF∣X−X′∣,\mathrm{CRPS}(F, y) = \mathbb{E}_F |X - y| - \frac{1}{2} \mathbb{E}_F |X - X'| ,

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 reportAt the pinned revisions
BenchmarkContract, content_hash, to_yaml, from_yamldo 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, variantdo 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, OptimalInterpolationBaselinedo not exist as baselines; xrtoolz ships fillnan_temporal, fillnan_climatology, CalculateClimatology and fillnan_rbf, and no OI
MultiTrackEvaluationdoes not exist; Report 14’s A4 proposes several references per cell
registry.register_contract, load_contractdo not exist; ModelRegistry.store takes an Operator, and _JsonArtifact gives JSON save and load to the two artifacts
BenchmarkSubmission.verify, EvaluationReport.was_produced_fromdo not exist; BenchmarkReport records no contract or model (stage 1)
AccessGatedGeoCatalogdoes not exist; geocatalog has no access control
pipekit-cycle.DACycle, SmootherCycle, Cycle for analysis, reanalysis, forecastexist, as named (Report 10)
xr_toolz.interpolate for discretization and gap fillingexists as xrtoolz.interpolate: bin_2d, points_to_grid, idw_to_grid, the fillnan_* family
pyrox-gp, gaussx for Gaussian-process gap fillingnot 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.

A grid with one row per stage of the running example and per workstream, and one square per stack call in that row's sketches, coloured by its marker. Of 45 calls, 14 run today, 15 are proposed by this report's workstreams, 1 by another report and 15 are code the reader writes.

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.

SymbolsDefined inUsed byVerdict
stable_json, sha256_hexpipekit hashing.pythe registry, hash_config in pipekit-trainHealthy. Deterministic; hashes the stage-1 contract as is
Benchmark, BenchmarkReport, CellResultpipekit-evaluate benchmark/run.pystage 6Split ownership. Cells keyed by (rung, complexity); the report records no contract, model or data
Task, Estimator, Scorer, Estimatebenchmark/protocols.pystage 6Split ownership. No split, no variant, no estimator name
LEVEL_NAMES, Cellbenchmark/cube.pyBenchmark(level="L4")Healthy. The vocabulary the contract’s task should use; the level is never set from the task
XarrayWindowDataset split, _split_origins, split_guardpipekit-train xarray_window.py:113-133Report 11’s window datasetSplit ownership. Temporal only, fixed 80/10/10; the guard counts origins and can empty a split (stage 2)
SimulationDataset splitpipekit-train dataset.py:220-226, :295-299training on simulationsHealthy. Disjoint seed ranges per split, so no leakage between draws
fillnan_temporal, fillnan_climatology, fillnan_rbf, idw_to_points, CalculateClimatologyxrtoolz interpolate/_src/gap_fill.py, knn.py, geo/operators.pystage 3Healthy. The baselines a gap-filling benchmark needs, minus OI
crps_ensemble, spread_skill_ratio, rank_histogram, ensemble_coverage, rank_methods, rmse_skill_scoresxrtoolz metrics/_src/stage 4Healthy. Probabilistic scores and a leaderboard table; the variant is checked on array shape only
ModelRegistry, LocalModelRegistry, _JsonArtifactpipekit-experiment protocols.py:51, registry.py, artifacts.pyReport 12Split ownership. Content-addressed storage for operators only; artifacts save as JSON but are not addressed by hash

Smaller findings:

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.

SymbolDestinationReason
BenchmarkContract(name, version, task, operation, input, output, variant, splits, references, data, scorers, baselines, submission, status)pipekit_evaluate.benchmark.contractthe 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_yamlthe same module, over pipekit.hashingstage 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 resultsbenchmark/run.py, report.pystage 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.pysets the level, the scorers and the references from the contract (stage 6)
templates per taskpipekit_evaluate.benchmark.templatesthe scoping report’s mitigation for long contracts
  1. 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.

  2. Hash the content, not the file. content_hash is sha256_hex(stable_json(asdict(self))), so formatting and key order never change it.

  3. 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.

SymbolDestinationReason
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.splitsstage 2’s withheld satellite, over a platform or producer column (Report 13’s producer)
split_causal(catalog, *, train_end, val_end)geocatalog.splitsthe forecast split: every test row after every training row
split_regions(catalog, regions)geocatalog.splitsthe scoping report’s leave-one-region-out, as footprint intersection
fractions and guard as a pd.TimedeltaXarrayWindowDataset, _split_origins: in geopatcher.integrations.train after Report 16’s D2, in pipekit-train before itstage 2: the guard counts origins, and a long one empties the validation split silently
gap field on the contract’s splitBenchmarkContract.splitsstage 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.

SymbolDestinationReason
optimal_interpolation(obs, grid, *, kernel, length, time_scale, noise, window) and OptimalInterpolationxrtoolz.interpolatestage 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 CalculateClimatologya climatology fitted on the training years only, unlike fillnan_climatology
linear_trend(field, *, n_recent)xrtoolz.geothe 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.

SymbolDestinationReason
variant on Scorer and on the entry’s Estimate ("deterministic", "probabilistic")benchmark/protocols.py, beside Report 14’s A1 tagsstage 4: the report should say which rows are point forecasts
point forecasts under CRPSxrtoolz.metrics.crps_ensemble and the runnera 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 A4stage 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_contractstage 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
MultiTrackEvaluationnot addedthe multi-reference runner is the same object; a second class would duplicate it
name on Estimator; results keyed by namebenchmark/protocols.py, run.pystage 6: two baselines in one cell collide
datasets(split) on Taskbenchmark/protocols.pystage 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.

SymbolDestinationReason
ArtifactStore.put(obj) -> hash, get(hash) for JSON artifactspipekit_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.submissionone 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_evaluationsBenchmarkContract.submissionrecorded 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.

GateWorkstreamPasses when
A1 Contract hashAthe 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 hashABenchmark.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 BlocksBon 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 causalBsplit_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 guardBXarrayWindowDataset(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 OICoptimal_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 PersistenceCpersistence 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 ClimatologyCClimatologyForecast(train_period=("2023", "2024")) uses no 2025 value: perturbing every 2025 input changes its output by 0
D1 VariantDon 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 TracksDone 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 NamesDstage 6’s four baselines, two at the same cell, run without error and the report is keyed by their names
E1 StoreEstore.put(c) == c.content_hash() and store.get(h) returns a contract equal to c
E2 VerifyEeach 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.

Phase 1: A1 and A2, the contract and its hash on every report, beside B1 to B3, the block, platform and causal splits and the guard in days; then gates A1, A2, B1, B2, B3. Phase 2: C1 to C3, the OI, persistence and climatology baselines, beside D1 to D3, the variant check, tracks and named entries; then gates C1 to D3. Phase 3: E1 and E2, the contract and submission store and verification; then gates E1 and E2.

Figure 3:The contract and its splits first, then the baselines and runner checks, then the store.

Open questions

  1. Does a contract pin code or names? A baseline pinned as xrtoolz.interpolate.optimal_interpolation with arguments still changes if xrtoolz changes. Should a contract also pin the library versions, as TrainingArtifact.deps_lock does?

  2. 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?

  3. 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-registered status?

  4. 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?

  5. 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.

ItemWhereWhy
Review the gallery’s eighteen draft contracts with domain experts, then pre-register thembenchmarks gallerythe drafts hash today, but their metrics, baselines and splits need review before their hashes mean anything
Raise on an empty splitXarrayWindowDataset (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 indexscripts/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 gaussxreferences/, UPSTREAMthe scoping report maps Gaussian-process gap filling to them; neither is checked
Write the representation contractopen question 4no level transition to name it by

References

Sources

References
  1. 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
  2. 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
  3. 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