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.

The six-step cycle

Report 17 · The data-driven modeling cycle is the benchmark ladder, run on one retrieval

Abstract

The scoping report organises the road from a forward model to an amortized predictor as the six-step cycle (overview), makes step 2 (the classical inversion) the permanent oracle that validates steps 3 to 5, and proposes a CycleStageBenchmark, a model-role enum and a lineage record. This page runs all six steps on one row of the cycle matrix, the L1 → L2 retrieval of a methane column from five-band transmittances, on the pinned code. The cycle is already there: pipekit-evaluate ships it as its benchmark ladder, with six rungs and a reference rule that scores the emulator against the simulator and both fast inversions against the oracle. The run shows why the rule matters. An emulator within 3.2 % of the simulator moves the posterior by up to 1.9 standard deviations and barely changes its coverage of the truth; an amortized predictor that passes against the truth is 53 % too wide against the oracle where the column is small. What is missing is small: a cell that names a transition, an oracle strength, a way to score the emulator on parameters, and lineage the registry cannot overwrite. Every number below is from a real run.

Keywords:six-step cycleoracleemulatoramortized inferencebenchmark ladderlineage

Summary

Decision. The six-step cycle is pipekit-evaluate’s benchmark ladder: rungs 1 to 6 are the steps, and its reference rule is the cycle’s oracle rule. No new class or package. The ladder gains a cell that names a transition, an oracle strength and rung-3 scoring on the parameters; pipekit-experiment records every artifact’s parents inside its content hash and answers what depends on an artifact; xrtoolz scores a posterior against the oracle with weights; and the docs name the cycle once, consistently.

End state. A team writes one contract per cell of its cycle row, runs the ladder, and reads one report: each rung against the reference it claims to reproduce, each row tagged with the oracle’s strength. When it improves a step, the registry lists the artifacts that must be rerun, and no others.

The framework, as the scoping report put it

This section summarises the scoping report’s framework; the rest of the page keeps it except where a box says otherwise.

The steps, the oracle and the level × step matrix are defined once in the overview (the six steps, how the axes relate); the scoping report says it follows plumax’s framing of them (plumax is not pinned here). The running example is one cell of each column in the L1 → L2 retrieval row.

Physics-first and data-first. In a physics-first cycle step 1 is a known forward model and step 2 a classical inversion of it, an oracle in the strong sense. In a data-first cycle step 1 is a labelled dataset and step 2 a classical supervised baseline, an oracle in the weak sense: matching it does not make a method right if the labels are wrong.

Ownership rules

The cycle is orchestrated by pipekit-evaluate’s ladder; each step runs in the library that owns its kind of computation (Figure 1).

Domain projects run the steps and read cycle reports through pipekit-evaluate, which ships RUNG_NAMES, DEFAULT_STAGED, Cell, Benchmark and Estimator and would add transition cells, an oracle strength and rung-3 scoring on parameters. Below it, pipekit-cycle runs steps 1, 2 and 4, pipekit-train trains steps 3 and 5, xrtoolz scores posteriors against the oracle, and pipekit-experiment keeps parents inside the hash. Domain libraries supply physics and classical oracles through adapters.

Figure 1:The ladder in pipekit-evaluate is the cycle; the steps run where their computation lives.

  1. pipekit-evaluate owns the cycle’s vocabulary (rungs, levels, transitions, oracle strength), the reference rule and the runner. It imports pipekit-cycle only through an optional extra today (cycle), and would import pipekit-experiment and xrtoolz the same way.

  2. pipekit-cycle owns steps 1, 2 and 4 as operators: the forward model (CallableForward, NeuralForward), the iterative inversion (Recurrence) and the assimilation cycles (DACycle). It never imports pipekit-evaluate.

  3. pipekit-train owns steps 3 and 5: training data drawn from the step-1 simulator (SimulationDataset) and the training loop. It never parses a contract or a cell.

xrtoolz holds the distance from a posterior to the oracle’s (workstream C), pipekit-experiment the lineage inside the hash (workstream B), and the domain libraries (plumax, somax, radtrax for step 1; filterax, vardax, pyrox for step 2) the physics and the classical oracles, reached through adapters/pipekit.py. Otherwise neighbours follow the stack rules.

The running example: one retrieval, six steps

The problem. Retrieve a methane column enhancement from a multiband sensor, and take the retrieval through all six steps of the cycle: the cell of the matrix the scoping report works through in its section 4.1. The sensor is Reports 10 and 11’s, so their results carry over.

The data. A sensor measures transmittances in B = 5 bands. With α the column enhancement, k ∈ ℝ⁵ the band absorption coefficients, k = (0.002, 0.004, 0.006, 0.008, 0.010), and ε the noise, the generative model is (1):

yb=fb(α)+εb,fb(α)=e−kbα,εb∼N(0,σ2),α∼U(0,300),y_b = f_b(\alpha) + \varepsilon_b , \qquad f_b(\alpha) = e^{-k_b \alpha} , \qquad \varepsilon_b \sim \mathcal{N}(0, \sigma^2) , \qquad \alpha \sim \mathcal{U}(0, 300) ,

with σ = 10⁻³. The test set is n = 500 scenes drawn from the prior with seeds of their own, disjoint from every training set below. This is the L1 → L2 row: structured observations (transmittances) in, a retrieved geophysical variable (α) out.

What exists. Every step runs on the pinned code except where a line is marked user (markers): the ladder’s task, estimates and scorers are user code, as in Report 14, and the polynomial emulator and the Gaussian head are small least-squares fits, so that the whole cycle runs in under a minute and every number is checkable. Report 11 trains an amortized retrieval for the same sensor with Equinox through pipekit-train (its stage 6); the cycle does not depend on which kind of model fills a step.

Stage 1 · The simple model, as a ladder task (pipekit-cycle → pipekit-evaluate)

TL;DR. Write the forward model as a pipekit-cycle operator and the retrieval as a ladder task, and read the rule the ladder already applies to each step.

Problem. A ladder Task bundles a prior, the step-1 simulator, an observation operator and the data each rung is scored against: the known α for the oracle, the simulator’s outputs f(α) for the emulator, and the observations y that every estimator receives.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Shapes: B = 5 bands; n = 500 test scenes
import pipekit_cycle as pc
from pipekit_evaluate.benchmark import (DEFAULT_STAGED, LEVEL_NAMES,
                                        RUNG_NAMES, Cell, Task)

# f_b(α) = exp(−k_b α)
forward = pc.CallableForward(lambda a, dt: np.exp(-K * a),
                             dt=1.0)                # (,) → (B,)    today
task = RetrievalTask()      # prior 𝒰(0, 300), simulator, obs     user
isinstance(task, Task)      # → True                              today
task.datasets()             # θ (n,), f(θ) (n, B), y (n, B)        user

RUNG_NAMES        # {1: simple_model, 2: model_based_inference,
                  #  3: emulator, 4: emulator_based_inference,
                  #  5: amortized_predictor, 6: improve}      today
DEFAULT_STAGED    # {1: none, 2: known_theta, 3: simulator_output,
                  #  4: oracle_posterior, 5: oracle_posterior,
                  #  6: best_validated_below}                 today
LEVEL_NAMES["L2"]           # → "gap_filled_obs"              today
Cell("L2", rung=4).label    # → "L2/r4/c0": not "L1 → L2"     today

The step-1 model and the task, and the rule the ladder applies to each rung, from a real run.

The six steps are the ladder’s six rungs, and its default rule is the cycle’s: the oracle (rung 2) against the known truth, the emulator (rung 3) against the simulator it replaces, and both fast inversions (rungs 4 and 5) against the oracle’s posterior. Two things do not fit the cycle matrix. A cell names one level, so the L1 → L2 retrieval, the L2 → L3 gap filling and the L3 → L4 forecast cannot be told apart once two rows share an output level. And the ladder names L2 “gap_filled_obs” while describing it, in the same design note, as “per-pixel geophysical retrievals (gaps allowed)”, which is what the overview’s levels call L2 (workstream A1).

Stage 2 · The oracle, and a cheaper inversion below it (pipekit-cycle)

TL;DR. Compute the exact posterior for every scene, check it against the truth, and compare it with a Gauss–Newton fit one step down the complexity axis.

Problem. With the flat prior of (1), the posterior is

p(α∣y)∝1[0≤α≤300]∏b=15N(yb; fb(α), σ2).p(\alpha \mid y) \propto \mathbb{1}[0 \le \alpha \le 300] \prod_{b=1}^{5} \mathcal{N}\big(y_b ;\, f_b(\alpha),\, \sigma^2\big) .

The oracle evaluates it on G = 30,001 nodes over [0, 300], a step of 0.01 against a smallest posterior standard deviation of 0.068, so it is exact to quadrature. A scale to compare it with is the local Fisher information: σ / ‖J(α)‖, with J_b = −k_b exp(−k_b α) the derivative of f_b, is the Cramér–Rao bound for an unbiased estimator at α. It is not a bound on the oracle, a posterior mean that uses the prior and is biased near the boundary α = 0. The Laplace step below the oracle fits α̂ by Gauss–Newton and reports the width σ / ‖J(α̂)‖.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Shapes: n = 500 scenes; G = 30,001 nodes; B = 5
# log p(α_g | y) = −½ Σ_b (y_b − f_b(α_g))² / σ² + c
# w_g ∝ q_g p(α_g | y), q trapezoidal (½ at α = 0 and 300), Σ_g w_g = 1
oracle = grid_posterior(y, f)                 # (n, B) → (n, G)   user
# α ← α + Jᵀ(y − f(α)) / JᵀJ  until |Δα| < 10⁻⁸
gn = pc.Recurrence(
    GaussNewtonStep(y),                                         # user
    condition_op=Lambda(lambda s: s.residual_norm < 1e-8),
    max_iters=50)                             # (n,) → (n,)       today
a_hat, _ = gn(a0, pc.IterationState())                        # today
# σ_L = σ / ‖J(α̂)‖
J = K * f(a_hat)                              # (n, B): |∂f_b/∂α|
laplace = GaussEstimate(a_hat,
                        SIG / np.linalg.norm(J, axis=1))  # (n,)  user
# RMSE vs α: oracle 0.236, Laplace 0.237; RMS of σ / ‖J(α)‖ 0.247
# 95 % coverage vs α: oracle 0.962, Laplace 0.960 (s.e. 0.010)
# Laplace mean within 0.26 oracle widths of the oracle's, every scene

The oracle by quadrature and the Laplace step as a Recurrence, scored against the truth, from a real run.

The oracle covers the truth at the nominal rate, and its error is of the size the local Fisher information sets (a little below it, 0.236 against 0.247, because the prior and the boundary help). On this problem step 2 is an oracle in the strong sense: the forward model is known and the posterior is computed, not approximated. The Laplace step agrees with it to a quarter of a standard deviation everywhere, which is the complexity axis doing its job: a richer inversion must first reproduce the simpler one where both are valid.

Stage 3 · Two emulators of the forward model (pipekit-train → numpy)

TL;DR. Fit two polynomial emulators to simulations of the forward model and score them against the simulator, the reference rung 3 calls for.

Problem. An emulator f̂ of degree d writes each band as a polynomial in α / 300 with coefficients C ∈ ℝ^{(d+1)×5}, fitted on the training split of a SimulationDataset of 2,000 seeded draws, (3):

C⋆=arg⁡min⁡C∑i=11600∥V(αi) C−f(αi)∥2,V(α)=[(α/300)d,…,α/300,1].C^\star = \arg\min_C \sum_{i=1}^{1600} \big\lVert V(\alpha_i)\, C - f(\alpha_i) \big\rVert^2 , \qquad V(\alpha) = \big[(\alpha/300)^d, \ldots, \alpha/300, 1\big] .
1
2
3
4
5
6
7
8
9
10
11
# Shapes: 1,600 training draws; B = 5; C (d + 1, B)
sim = pt.SimulationDataset(forward, PriorDraw(), n_samples=2000,
                           seed=0)                            # today
len(sim)                       # → 1600: the train split      today
# C = argmin ‖V C − F‖²
C4 = fit_emulator(4)           # (1600, 5) → (5, B)           user
C6 = fit_emulator(6)           # (1600, 7) → (7, B)           user
# max_b,α |f̂_b(α) − f_b(α)| / σ over 3,001 values of α
# degree 4: 2.2 σ (3.2 % of the transmittance at worst)
# degree 6: 0.03 σ
# on the 500 test scenes only: degree 4, 2.01 σ (stage 6's figure)

Training data from the step-1 simulator, two emulators, and their error against the simulator, from a real run.

Judged as a forward model, the degree-4 emulator looks good: its worst error is 3.2 % of the transmittance. Judged in the unit the problem fixes, the noise σ, it is 2.2 σ off, and the next stage shows what that costs. This is the scoping report’s claim about benchmarks made concrete: the tolerance in a step-3 contract cannot be written until one knows the noise of the step-4 problem, so writing it is the act of understanding it.

Stage 4 · The oracle’s inversion, rerun with each emulator (pipekit-cycle → xrtoolz)

TL;DR. Rerun the oracle’s quadrature with each emulator in place of the simulator and score the result against the oracle’s posterior.

Problem. Step 4 replaces f with f̂ in (2). With F̂ and F the distribution functions of the step-4 and oracle posteriors, the 1-Wasserstein distance (4)

W1=∫0300∣F^(α)−F(α)∣ dαW_1 = \int_0^{300} \big\lvert \hat{F}(\alpha) - F(\alpha) \big\rvert \, d\alpha

is zero only when the two posteriors agree; reported in units of the oracle’s standard deviation s, it reads as “how many widths apart”.

1
2
3
4
5
6
7
8
9
10
11
12
13
# Shapes: n = 500 scenes; G = 30,001 nodes; 2,000 draws per scene
fast = grid_posterior(y, f_hat_4)             # (n, B) → (n, G)   user
# (m̂ − m) / s, the mean shift in oracle widths
# degree 4: RMS 0.44, max 1.94; width ratio 1.00; W₁ / s 0.37
# degree 6: max 0.02
# 95 % coverage vs α: degree 4 0.948, degree 6 0.962 (the oracle's)
# W₁ = ∫ |F̂ − F| dα, from 2,000 samples of each, in oracle widths
a = xr.DataArray(fast.sample(2000, key=1), dims=("scene", "member"))
b = xr.DataArray(oracle.sample(2000, key=2), dims=("scene", "member"))
w1 = xrtoolz.metrics.wasserstein_1(a, b)      # (n, 2000) → (n,)  today
(w1 / oracle.sd()).mean()                      # (n,) → () : 0.37
# the oracle against itself, over 20 key pairs: 0.040 ± 0.0004
# degree 6 on the same pairs: 0.041, inside that null; exact W₁ / s 0.004

Emulator-based inference against the oracle, exactly and through xrtoolz, from a real run.

A 3 % emulator moves the posterior mean by up to 1.9 widths, while the width of the posterior does not change at all and the coverage of the truth drops only from 96.2 % to 94.8 %. On the same 500 scenes the paired difference (11 scenes lost, 4 gained) is −1.4 ± 0.8 points, z = −1.8: a check against the truth at this sample size would not reject it at 95 %, and a check on the uncertainty alone would pass outright. The degree-6 emulator, at 0.03 σ, reproduces the oracle to 0.02 widths. Scoring step 4 against the oracle, as the ladder’s rule does, is what makes this visible; scored against the truth alone, the degree-4 inversion looks like a good retrieval. xrtoolz’s wasserstein_1 reproduces the exact W₁ but takes samples only, so an exact posterior on nodes must be sampled first. Two samples of one posterior are never 0 apart, so the sample estimate is biased upward. Over 20 independent pairs of 2,000 draws, the oracle against itself scores 0.040 widths (standard deviation 0.0004, largest 0.041). The degree-6 emulator scores 0.041 on the same pairs, inside that spread, so the sample estimate cannot tell it from the oracle; the exact W₁ is 0.004 (workstream C1).

Stage 5 · An amortized predictor, against the truth and the oracle (numpy → xrtoolz)

TL;DR. Fit one Gaussian predictor for every spectrum, and see that scoring it against the truth and scoring it against the oracle give different verdicts.

Problem. The predictor is q(α | y) = 𝒩(μ(y), s(y)²), with μ and log s² linear in features φ(y) ∈ ℝ⁸ built from the per-band inversions z_b = −log y_b / k_b. Both are fitted once, by least squares on 20,000 simulated pairs (α, y) drawn from (1), as (5):

βμ=arg⁡min⁡β∑i(αi−φ(yi)⊤β)2,βs=arg⁡min⁡β∑i(log⁡ri2−φ(yi)⊤β)2,\beta_\mu = \arg\min_\beta \sum_i \big(\alpha_i - \varphi(y_i)^\top \beta\big)^2 , \qquad \beta_s = \arg\min_\beta \sum_i \big(\log r_i^2 - \varphi(y_i)^\top \beta\big)^2 ,

with r_i = α_i − φ(y_i)ᵀβ_μ, and log s(y)² = φ(y)ᵀβ_s + 1.27, since 𝔼[log χ²₁] = −1.27. A predictor calibrated against the truth has 𝔼[z²] = 1 for z = (α − μ) / s.

1
2
3
4
5
6
7
8
9
# Shapes: 20,000 training pairs; φ(y) (n, 8); n = 500 test scenes
# φ(y) = [1, z₁ … z₅, z̄², z̄³],  z_b = −log y_b / (300 k_b)
# μ = φᵀβ_μ,  log s² = φᵀβ_s + 1.27
q = amortized(y)                      # (n, B) → μ (n,), s (n,)   user
# against α: RMSE 0.255; 𝔼[z²] 0.90 (s.e. 0.063); 95 % coverage 0.954
# against the oracle: width ratio 1.16; 1.53 for α < 50 (83 scenes)
# mean shift for α < 50: 1.04 widths RMS, 3.6 at worst
# W₁ / s 0.38, over the real line: the Gaussian's tails outside [0, 300]
#   are added in closed form, s (z Φ(z) + φ(z)) on each side

The amortized Gaussian head, scored two ways, from a real run.

Against the truth, the predictor passes: 𝔼[z²] = 0.90, within 1.6 standard errors of 1, and 95.4 % coverage. Against the oracle, it is a different estimator. Where the column is small and the posterior narrow, its mean is noisier than the oracle’s (1.04 widths off) and it widens its intervals by 53 % to stay honest about that. The truth only checks that the predictor knows how wrong it is on average over the prior; the oracle checks that it reproduces the posterior scene by scene, which is the question an amortized predictor must answer. The ladder scores rung 5 against the oracle only, so today the two verdicts need two runs (Report 14’s A4, gate C2 here).

Stage 6 · The ladder and the lineage (pipekit-evaluate → pipekit-experiment)

TL;DR. Run every rung through the shipped ladder, store every artifact in the registry with its parents, improve the emulator, and ask what must be rerun.

Problem. (a) The ladder. Five estimators fill five cells at level L2: Laplace (rung 2, complexity 0), the quadrature oracle (rung 2, complexity 1), the degree-4 emulator (rung 3), its inversion (rung 4) and the amortized head (rung 5). (b) The lineage. Step 6 replaces the degree-4 emulator with the degree-6 one. An artifact is stale when it was built from the old emulator, directly or through another artifact; the set of stale artifacts is the set of descendants of the old emulator in the graph of parents.

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
# Shapes: n = 500; spectra (n, B) in σ; posteriors in oracle widths
report = pe.Benchmark(task=task, scorers=[Score()], level="L2",
    estimators=[
        Rung(2, 0, laplace),                          # y ↦ Laplace estimate
        Rung(2, 1, lambda y: grid_posterior(y, f)),           # the oracle
        EmulatorRung(3, 0, f_hat_4),
        Rung(4, 0, lambda y: grid_posterior(y, f_hat_4)),     # step 4
        Rung(5, 0, amortized)]).run()   # Rung, EmulatorRung: user  today
# L2/r2/c0  known_theta       max |α̂ − α| 1.12, RMSE 0.237
# L2/r2/c1  known_theta       max |α̂ − α| 1.12, RMSE 0.236
# L2/r3/c0  simulator_output  max |f̂ − f| 2.01 σ, RMSE 0.277 σ
# L2/r4/c0  oracle_posterior  max shift 1.94, width ratio 1.00
# L2/r5/c0  oracle_posterior  max shift 3.63, width ratio 1.16
EmulatorRung.fit(task)       # reads θ: rung 3 needs θ, not y  user

reg = px.LocalModelRegistry(root)                             # today
h0 = reg.store(Artifact("prior", {"low": 0, "high": 300}),
               tags={"rung": 1, "role": "prior"})             # today
h1 = reg.store(Artifact("simulator", {...}),
               tags={"rung": 1})                              # today
h2 = reg.store(Artifact("grid_posterior", {"nodes": 30001, "low": 0,
               "high": 300, "rule": "trapezoid"}, parents=[h0, h1]),
               tags={"rung": 2, "role": "oracle"})            # today
# another prior support → another oracle hash (asserted); another
# calibration constant → another head hash (asserted)
h3 = reg.store(Artifact("poly_emulator", {...}, parents=[h1]),
               tags={"rung": 3, "oracle": h1})                # today
# … rungs 4 and 5 likewise (parents [h0, h3] and [h0, h1]; rung 5's
# config holds β_μ, β_s and the calibration constant −1.27); then step 6
# → new hashes for rungs 3 and 4; rung 5's unchanged (parents h0, h1)
dependents(h3)       # → [old rung 4]: a scan of configs      user
reg.store(emulator_6, tags={"oracle": "x"})
# → the same hash; its oracle tag is now "x"                  today
reg.list(tags={"parents": [h1]})
# → []: parents are config, not tags                          today

The cycle through the shipped runner and registry, from a real run.

One Benchmark.run() call scores rungs 2 to 5 and resolves each rung’s reference as stage 1 read it. Step 1 is the task built before the call, and step 6 happens outside it: the notebook stores the new emulator and calls the runner again. After the improvement, rerunning it changes the rung-3 and rung-4 rows (to 0.03 σ and a 0.02-width shift) and leaves rung 5’s unchanged, because rung 5 was trained on the simulator. Three things needed glue. Rung 3 is a forward emulator, a map from α to y, but the runner calls every estimator on the observations, so the emulator reads α from the task inside fit (workstream A3). The registry can carry lineage two ways, and neither is enough: tags are outside the hash, so storing the same emulator again with another oracle tag silently replaces it, and a tag filter matches whole values, so it cannot ask which artifacts list a given parent. Parents in the configuration are hashed, which is right, and that is why a new emulator gave rung 4 a new hash; but nothing indexes them, so “what depends on this?” is a scan of every stored configuration (workstream B). And a configuration that holds a mapping cannot be loaded back: from_state refuses non-primitive values (pipekit/_base/operator.py:251).

The scoping report’s names, checked

Table 1 sets every name the scoping report uses against the pinned code.

Table 1:Every name the scoping report uses, against the pinned code.

Scoping reportAt the pinned revisions
the six steps; step 2 as oracleRUNG_NAMES and DEFAULT_STAGED in pipekit_evaluate.benchmark (stage 1)
CycleStageBenchmark(target_step, oracle_step, l_transition, oracle_artifact_hash, oracle_strength)does not exist; Cell(level, rung, complexity) and ReferenceRule cover the step and its reference; transition, oracle hash and strength are new (A1, A2)
CycleBenchmark.run_full_cycledoes not exist; Benchmark(...).run() scores rungs 2 to 5 in one call, while step 1 (the task) and step 6 (store an improvement, rerun) stay outside it (stage 6)
OracleStrengthdoes not exist; ReferenceSource says where a reference comes from, not how far to trust it
ModelRole, ArtifactLineagedo not exist; LocalModelRegistry.store(..., tags=) stores free tags outside the hash (stage 6)
pc.DACycle(forward_model, obs_op, analysis_step, n_steps)exists, with obs_source and save_history as well (da.py:84)
pc.NeuralForward(model_op, dt)exists (forward.py:136)
pc.Recurrence for iterative retrievalexists (cycle.py:265); stage 2 runs Gauss–Newton with it
pt.SimulationDataset(forward_model, prior, n_samples), pt.CachedDataset(source, cache_dir)exist (dataset.py:228, :393)
pt.TrainingLoop(..., n_epochs=, backend="lightning")TrainingLoop counts max_steps, not epochs; the Lightning backend raises NotImplementedError (Report 11)
pt.MSE, pt.NLLexist (loss.py)
pa.ModelOpno such alias; the Equinox adapter’s EquinoxModelOp or pipekit-jax’s JaxModelOp
px.S3ModelRegistryexists (registry.py)
pe.MultiTrackEvaluation, EvaluationReport.diffdo not exist; Report 15 drops the first, Report 14 proposes the report (A3)
geotoolz.matched_filter.CH4MatchedFilterMatchedFilter, MatchedFilterPixel, MatchedFilterSNR exist; no CH₄-specific class
BAEMR retrievalnot identified; the gallery found the same
plumax.PlumeForward, ColumnObs, filterx…EnKFAnalysis, pyrox_nn…ConditionalNormalizingFlow, RTMX.RTMnot pinned in references/, so not checked; the scoping report’s filterx is filterax
“the 23 benchmarks” of the gallery18 entries; 23 contracts once entry 5.1 is split into its six (gallery)

Current-state audit

Four of the eight symbol groups need action (Table 2); Figure 2 shows what the page’s sketches call. Read on 14 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 40 calls, 21 run today, 6 are proposed by this report's workstreams and 13 are code the reader writes.

Figure 2:Of the page’s 40 marked stack calls, 21 run today, 6 are proposed by this report’s workstreams and 13 are code the reader writes (how to read it: conventions).

Table 2:The cycle’s surface, by symbol group.

SymbolsDefined inUsed byVerdict
RUNG_NAMES, LEVEL_NAMES, Cellpipekit-evaluate benchmark/cube.pyBenchmark, Reports 14 and 15Split ownership. A cell names one level, not a transition; L2 is named gap_filled_obs but described as retrievals (cube.py:28)
DEFAULT_STAGED, DEFAULT_COMPLEXITY, ReferenceRule, ReferenceSourcebenchmark/reference.pyBenchmarkHealthy. The cycle’s oracle rule, as data a domain can override; no notion of how strong the oracle is
Benchmark, CellResultbenchmark/run.pystage 6Split ownership. Every estimator is called on the observations (run.py:108), so rung 3 needs glue; one reference per cell (Report 14)
Estimator, Estimate, Task, Oraclebenchmark/protocols.pystage 6Healthy. Enough for every rung once rung 3 is called on θ; Oracle is still not consumed (Report 14)
CallableForward, NeuralForward, Recurrence, DACyclepipekit-cycle forward.py, cycle.py, da.pystages 1, 2 and 6; Reports 10 and 11Healthy. Steps 1, 2 and 4 as operators; an emulator drops in through NeuralForward
SimulationDataset, TrainingLooppipekit-train dataset.py, loop.pystage 3; Report 11Healthy for steps 3 and 5; the dataset hash ignores the forward model (Report 11’s A3), so it cannot name step 1 as a parent
LocalModelRegistry.store, list, tagpipekit-experiment registry.py:113, :180stage 6; Report 12Split ownership. Tags are outside the hash and merged on re-store (:159); list matches whole tag values (:195); no reverse lookup
wasserstein_1, Wasserstein1xrtoolz metrics/_src/distributional.py:144stage 4Healthy. Samples only: no weights, so a posterior on nodes is resampled, and the sample estimate is biased upward by about 0.04 widths at 2,000 draws

Smaller findings:

Workstream A · The cycle cell

Decision. A cell of the cycle matrix is a ladder cell with a transition, and a contract names its oracle and how far to trust it; nothing else about the ladder changes (Table 3).

Table 3:Workstream A’s moves.

SymbolDestinationReason
Cell(level, rung, complexity, source=None) with label "L1→L2/r4/c0"benchmark/cube.pystage 1: one level cannot tell retrieval, gap filling and forecasting apart
LEVEL_NAMES["L2"] = "retrieved_obs"benchmark/cube.pythe ladder’s own description of L2, and the overview’s
OracleStrength (physics_derived, classical_method, human_labels, heuristic)benchmark/reference.py, beside ReferenceSourcethe scoping report’s strong and weak oracles; the reference rule says where, not how far to trust
oracle: {artifact, strength} on BenchmarkContract; strength on each CellResult scored against itReport 15’s contract; benchmark/run.pya report row says what its reference was worth; a heuristic or human_labels oracle warns and still scores
rung 3 called on task.datasets()[truth_key]Benchmark.runstage 6: a forward emulator maps θ to y; the fit workaround goes

A1 · Transition cells

A cell gains an optional source level, so a contract’s task (Report 15’s "L1->L2") and its cell use one vocabulary; without one, a cell is read as today. The ladder’s L2 is renamed to match its description; Report 15 and the gallery use “L1 → L2” for gap filling, which on the renamed levels is L2 → L3, and that relabelling is an open question below, not a silent change.

A2 · Oracle strength

A contract names its oracle by artifact hash and strength; the runner copies the strength onto every row it scores against that oracle and warns, without refusing, when the strength is human_labels or heuristic. The scoping report leans the same way (its open question 1).

A3 · The emulator rung

Benchmark passes the truth, not the observations, to a rung-3 estimator, and scores it against the simulator’s outputs as today.

1
2
3
4
5
6
7
8
9
10
# Shapes: θ (n,); f(θ) (n, B)
# Today:
EmulatorRung.fit(task)          # stores θ itself              user
pe.Benchmark(task, estimators, scorers, level="L2").run()     # today
# → "L2/r3/c0", ref simulator_output; no strength on any row
# After:
pe.Benchmark.from_contract(c, estimators=[...]).run()
# → "L1→L2/r3/c0": f̂(θ) against f(θ), 2.01 σ  proposed (A1, A3)
# c.oracle = {"artifact": h2, "strength": "physics_derived"}
# → rows r4 and r5 carry "physics_derived"        proposed (A2)

The emulator rung and an oracle with a strength, before and after workstream A.

Workstream B · Lineage in the hash

Decision. An artifact’s parents are part of what the registry hashes, and the registry answers which artifacts descend from a given one (Table 4).

Table 4:Workstream B’s moves.

SymbolDestinationReason
store(op, *, parents=(), ...) writing lineage.json into the hashed payloadpipekit_experiment.registrystage 6: a tag can be replaced without changing the hash
ArtifactLineage(parents, oracle, training_artifact)pipekit_experiment.artifacts, beside TrainingArtifactthe scoping report’s record, minus the benchmark results, which point the other way (a report names the model, Report 15’s E2)
dependents(hash), descendants(hash)ModelRegistry protocol and both registriesstage 6: “what must be rerun?” is a scan today
a rung tag ({"rung": 3}) by conventionthe ladder’s docsthe role the scoping report’s ModelRole would carry; list(tags=) filters it today

B1 · Parents in the payload

A stored artifact’s hash covers its operator state, its weights and its lineage record. Two artifacts with the same weights and different parents are different artifacts; nothing can rewrite a parent after the fact. TrainingArtifact.dataset_hash becomes one parent among others, and Report 11’s A3 (the simulator in the dataset hash) is what makes that parent trustworthy.

B2 · Dependents

The registry keeps a reverse index from parent to child, written at store time, so descendants(h) is the stale set when h is replaced.

1
2
3
4
5
6
7
8
9
10
11
# Shapes: hashes of 64 hex characters
# Today:
reg.store(emu_6, tags={"oracle": h1})
reg.store(emu_6, tags={"oracle": "x"})
# → the same hash; the oracle tag is now "x"                  today
dependents(h3)              # scan every operator.json         user
# After:
h3 = reg.store(emu_4, parents=[h1])                 # proposed (B1)
reg.store(emu_4, parents=[h_other]) != h3           # proposed (B1)
reg.descendants(h3)         # → [h4]; rung 5 has parent h1
                            #                       proposed (B2)

Asking what an improvement makes stale, before and after workstream B.

Workstream C · Scoring against the oracle

Decision. The distance from a posterior to the oracle’s is an xrtoolz metric that takes weights, and the amortized rung is scored against the oracle and the truth in one run (Table 5).

Table 5:Workstream C’s moves.

SymbolDestinationReason
wasserstein_1(a, b, *, weights_a=None, weights_b=None, ...)xrtoolz.metricsstage 4: an exact posterior on nodes is resampled today, and the estimate’s upward bias (the oracle against itself scores 0.040 ± 0.0004 widths at 2,000 draws) hides the degree-6 emulator’s exact 0.004
two references for rung 5 (oracle_posterior and known_theta)Report 14’s A4 in benchmark/run.pystage 5: the truth says “calibrated”, the oracle says “53 % too wide where α is small”; both belong on the row
1
2
3
4
5
# Shapes: nodes (G,); weights (n, G)
# W₁ = ∫ |F̂ − F| dα
xrtoolz.metrics.wasserstein_1(
    nodes, nodes, weights_a=fast.w,
    weights_b=oracle.w)            # (n, G) → (n,); 0.37 s  proposed (C1)

Distance to the oracle with weights, after workstream C.

Workstream D · The cycle in the docs

Decision. The overview states the shipped oracle rule, and the reports that already ran steps of the cycle say which. Both land with this page.

  1. Correct the overview’s rule (D1). The overview said every cell “uses the column before it as its oracle”; the shipped rule scores the emulator against the simulator and both fast inversions against step 2. This change corrects the sentence, and replaces the overview’s planned ModelRole, ArtifactLineage and CycleStageBenchmark with workstreams A and B.

  2. Link the reports to the steps (D2). Report 11’s stage 2 is step 3 of a forecast row (its stage 3 runs that emulator forward, a forecast, not an inversion) and its stages 6 and 7 are steps 5 and 2 of this retrieval; Report 14’s stage 4 fills rungs 2 and 5; Report 15’s contract is what each cell is benchmarked by. This change adds one sentence to each that points here; the scoping report’s 30 to 80 lines per report would repeat this page.

Testing gates

Each workstream merges only when its gates (Table 6) pass on the running example.

Table 6:Gates, by workstream.

GateWorkstreamPasses when
A1 Transition cellACell("L2", 4, source="L1").label == "L1→L2/r4/c0", a cell without a source keeps today’s label, and LEVEL_NAMES["L2"] == "retrieved_obs"
A2 Oracle strengthAstage 6’s rows for rungs 4 and 5 carry physics_derived; with a heuristic oracle the same run emits one UserWarning and reproduces the same scores
A3 Emulator rungAstage 6’s rung-3 estimator, with no fit workaround, scores 2.01 σ (degree 4) and 0.03 σ (degree 6) against the simulator
B1 Lineage hashBstoring the stage-6 emulator with another parent gives another hash, and with the same parent and other tags the same hash
B2 DependentsBafter stage 6’s improvement, descendants(h3) is exactly the old rung-4 artifact, and the rung-5 artifact appears in no descendant set of an emulator
C1 Weighted W₁Cwith weights, wasserstein_1 reproduces stage 4’s exact W₁ / s (0.37 for degree 4, 0.004 for degree 6) to 10⁻³, and is 0 for a posterior against itself
C2 Two referencesCone run reports rung 5’s width ratio against the oracle (1.16) and its 𝔼[z²] against the truth (0.90) on the same row
D1 DocsDthe overview’s oracle sentence matches DEFAULT_STAGED, and myst build --html reports no warnings
D2 LinksDReports 11, 14 and 15 each link to this page (its running example, stage 6 and workstream A), and every link resolves in myst build

Sequencing and risks

The docs and the vocabulary first, because they cost nothing and every later change uses them; then lineage and scoring; then a real cycle (Figure 3).

Phase 1: D1 and D2, the corrected oracle rule and the links, beside A1 and A2, transition cells and oracle strength; then gates D1, D2, A1, A2. Phase 2: B1 and B2, parents in the hash and the dependents query, beside A3, C1 and C2, rung 3 on parameters, weighted W1 and two references; then gates B1, B2, A3, C1, C2. Phase 3: a real methane L1 to L2 cycle with pinned radiative-transfer and plume code; then every gate rerun on real data.

Figure 3:Name the cycle, then record its lineage and score it, then run it on real data.

Open questions

  1. Which levels do gap filling and retrieval connect? The ladder (and Report 15 and the gallery, which follow it) call gap filling L1 → L2; the overview’s levels and the scoping report’s matrix call retrieval L1 → L2 and gap filling L2 → L3. Recommended: the overview’s levels, with Report 15’s and the gallery’s tasks relabelled in one change. Alternative: keep the ladder’s levels and rename the overview’s.

  2. Does an oracle’s strength propagate? The scoping report scores its direct predictor against step 4 (its section 6.3), and a domain may override the default rule that way to save oracle runs; that reference carries step 4’s emulator error. Should such a row record the weaker of the two strengths?

  3. Entering mid-cycle. A team handed a trained emulator without its simulator can run steps 4 and 5 with the emulator as reference. Is that heuristic strength with no step-1 parent, or a strength of its own?

  4. Cross-domain cycles. Methane retrieval feeding ocean carbon assimilation chains two rows. Is that the gallery’s cascade contract (entry 5.1), with one cycle per row?

  5. When does pipekit-jax matter? Step 4 with a differentiable emulator enables gradient-based inversions that stage 4’s quadrature does not need. Should the first real cycle use one, to settle pipekit-jax’s timing?

Upcoming work

Table 7 lists the follow-ups outside the workstreams.

Table 7:Follow-ups from checking the scoping report against the code.

ItemWhereWhy
Map each gallery entry to its cells (transition, steps present, oracle strength)benchmarks gallerythe scoping report’s appendix; needs open question 1 settled first
Pin plumax, radtrax, filterax and vardaxreferences/, UPSTREAMthe real methane cycle and the scoping report’s step-1 and step-2 names cannot be checked otherwise
Flat configurations for stored artifactspipekit Operator.from_state or the registry’s docsstage 6: a nested configuration stores but does not reload
The plume pilot as the first real cyclepipekit-evaluate docs/design/plume_pilot.mdthe ladder’s design note picks it because every rung exists

References

Sources

References
  1. Johnson, J. E. (2026). Report 16 — The Data-Driven Modeling Cycle and Benchmark-Gated L0–L4 Refinement. Unpublished scoping report.