# petekStatic — design constitution (build spec)
> Repo: `Koding/Rust/petekSuite/petekStatic` · crate: `petekstatic`
> (modules preserve the historical `srs-*` layer labels). The **GEOMODEL** layer of the petek subsurface-modelling
> ecosystem. This is the design constitution (the *why*/*how*); the locked public
> contract is `API.md`; the shared conventions are the petek family house style
> (`petekSuite/dev-docs/petek-house-style.md`). Cross-library seams + lifecycle
> live in the suite planning graph (served by the `contract` MCP).
petekStatic turns **model-ready inputs** (petekIO's `ModelInputs` / `.pproj`)
into a populated **`StaticModel`** — a structural framework (horizons + faults +
zones), a modelling grid, per-cell property cubes — **and owns the volumetrics +
static-uncertainty stack over it**: GRV / in-place (OOIP/OGIP) off the model,
and Monte-Carlo regeneration over static-model realizations. petekSim (the
dynamic/engineering + product layer) consumes the model and its volumetric
results across the seam.
**Litmus test for what belongs here (re-scoped 2026-07-03, graph
`decision_layer_charters`):** it is a *static-modelling* concern — anything a
geomodeller or a static-uncertainty study needs: the populated grid, its
in-place volumes, and the MC loop over static realizations (tornado later).
**PVT/fluid correlations, dynamic flow, decline/material-balance, economics, and
the Python product facade are petekSim's.** FVF crosses this seam **as an
uncertain scalar input** (a validated value type), never as PVT code. Nothing
input-data-specific (parsing, QC, interpretation) lives here; that is petekIO's
job. We **consume** petekIO and **call** petekTools kernels; we **never** depend
on petekSim (our downstream consumer).
---
## 1. Design constitution (this library's slice of the family house style)
1. **Strictly layered, one-way internal deps; no cycles.** The current module DAG
inside the single `petekstatic` crate:
```
error # the one workspace error enum (StaticError) + Result
srs-grid # i,j,k corner-point grid: Grid/Cell/KLayer/Property
srs-wireframe # the constraining wireframe (boundary+horizons+contacts)
srs-gridder # convergent gridder: min-curvature surface + layering
srs-petro # petrophysics: log upscaling (power-law means)
srs-data # thin petekio adapter: ModelInputs -> wireframe + logs
srs-volumetrics # GRV + in-place (OOIP/OGIP) + FVF value types + range validation
srs-uncertainty # Monte Carlo: distributions, SplitMix64, P90/P50/P10 (leaf)
srs-spill # out-of-core backing mode: memory budget + k-slab spill
# onto a petekTools store (imports srs-grid + srs-volumetrics)
srs-model # TOP OF DAG: the StaticModel aggregate + builder +
# the MC-regeneration template (relocated from petekSim)
```
A module imports only from layers above it. `srs-volumetrics` and
`srs-uncertainty` **relocated here from petekSim 2026-07-03** (origin SHA
`fe6343c`, `task_relocate_refine_orchestration`) under the layer-charter
re-scope; `srs-spill` **split out of `srs-model` 2026-07-05**
(`task_petekstatic_organize`, P10 organize wave) as the out-of-core
backing-storage mode below the aggregate; `srs-model` composes grid + gridder +
petro + wireframe + volumetrics + spill into the `StaticModel` and owns the
regeneration orchestration relocated from petekSim's `RefiningModel`. `srs-model`
re-exports `srs-spill`'s surface, so the split is source-compatible.
2. **One error enum** (`StaticError`, `thiserror`) + `Result<T>` everywhere;
downstream composes it with a `#[from]` variant on its own enum (house §1).
petekSim does this: `SrsError::Static(#[from] petekstatic::error::StaticError)`.
Upstream composes IN the same way: `StaticError::Geo(#[from] petekio::GeoError)`
— so `?` chains DATA→GEOMODEL→SIM and `source()` reaches the origin.
3. **Domain objects carry their operations** — fluent, chainable, immutable; ops
return *new* objects, mutation is explicit `set_*`. `f64::NAN` = undefined.
4. **Compartmentalized.** One module/topic, one type/responsibility; split before a
file owns two jobs. Boundaries are traits where backends are plural, enums where
the set is small and closed.
5. **Compose deps, don't reinvent.** The convergent gridder / kriging / warm-start
kernels are petekTools' job; unit conversions are petekTools'; input-data work is
petekIO's. We map at the seam and convert small types rather than share code
sideways. (The FVF value types in `srs-volumetrics` are the sanctioned
duplicate-at-the-seam: petekSim's `srs-pvt` keeps its own copies for the
dynamic work; no dependency crosses.)
6. **Physical-range validation is typed, at the seam.** Every volumetrics input
passes `validate_fraction` (φ/NG/Sw ∈ [0,1]) / `validate_positive`
(area/height) / the FVF constructors — a non-physical input is a
`StaticError`, never silent garbage (`-117 MMSTB`, `inf`).
7. **PyO3-ready by construction.** Owned types on the public surface, no public
lifetimes, plain numeric types / `ndarray`. Bindings (petekSim's facade today)
only marshal.
---
## 2. The `StaticModel` — the contract (LANDED, `srs-model`)
A **`StaticModel` is a *populated* static reservoir model**: the single, complete,
self-describing artifact petekStatic produces. It is a value (owned, `Clone`,
`Send`), not a handle to a live builder. Its anatomy:
| **structural framework** | `Wireframe` (`srs-wireframe`) | areal boundary + depth horizons (`Top`/`Intermediate`/`Base`) + fluid contacts, each tagged with `Hardness` |
| **grid** | `Grid` (`srs-grid`) | corner-point geometry + `nk` `KLayer`s. The unfaulted box is the degenerate case; the same type upgrades in place to the faulted grid |
| **property cubes** | `Properties` (`srs-grid`) | name → `f64[cell_count]` per-cell scalar fields (`PORO`, `SW`, `NTG`, …); `NaN` = undefined |
| **zones** | `ZoneTable` (`srs-model`) | named stratigraphic intervals bounding k-ranges; MVP ships the single implicit whole-column zone (§4) |
| **contacts** | `Vec<Contact>` (on the framework) | OWC / GOC / GWC depths — the model's own volumetrics clip against these |
| **provenance** | `Provenance` (`srs-model`) | inputs identity, gridder settings, population mode, and the `RealizationDraw` (with `seed_index`) for an MC realization (§6) |
**Invariant:** every property cube's `values.len() == grid.cell_count()`; every
zone's k-range lies within `[0, nk)`; the grid's areal lattice matches the
framework's. Enforced at construction (the constructor is crate-internal; only
the builder/template build models) — the consumer never re-validates.
**Units — SI everywhere, one convention** (`decision_si_units_standard`,
2026-07-04). All coordinates, depths and lengths are **metres**; areas **m²**;
volumes **m³** internally, reported in **mcm** (`1e6 m³`, GRV), **MSm³** (oil)
and **bcm** (`1e9 Sm³`, gas). Vertical is **positive-down depth** (clearly-named
`*_m` accessors), the domain-natural framing the ruling permits, and it is the
**one datum inside the `Wireframe`**: petekIO delivers surfaces (and well-curve
positions) as negative-down subsea elevation, so `srs-data` **negates them at the
ingest boundary** (`surface_depths`) onto positive-down `depth_m`, matching the
contacts petekIO already delivers positive-down. This unifies horizons and
contacts on one convention (so structural role assignment reads `Top` =
shallowest correctly) and retires the coordinate-flip deferral — its blocker,
petekIO's imperial `SummaryInputs` contacts, resolved when petekIO went SI
(2026-07-04). FVF (`OilFvf`/`GasFvf`) is
dimensionless **Rm³/Sm³** — numerically identical to the legacy rb/STB & rcf/scf,
so it is a **relabel, not a conversion**. Imperial is opt-in only (petekTools
factors), never a default.
**The model OWNS its volumetric answer** (inverted 2026-07-03 by
`decision_layer_charters`, superseding the earlier "computes no volumes"
stance): `model.in_place()` clips the column against the model's contact(s) and
returns `InPlace` (GRV + HCPV + per-cell HCPV); the caller applies FVF through
the validated `OilFvf`/`GasFvf` value types (`ooip_sm3`/`ogip_sm3`, +
`oil_msm3`/`gas_bcm` reporting scales). P-curves aggregate per-realization
in-place values via `srs-uncertainty`'s `PercentileSummary`.
**Two-contact columns (gas cap + oil rim).** A framework carrying a GOC *and* a
lower contact (OWC/FWL or GWC) makes `in_place()` partition cells by centroid
depth — gas cap (`z < GOC`) / oil leg (`GOC <= z < lower`) / water — and report
per-zone GRV + HCPV (`InPlace::gas` / `::oil`, `gas_zone_ogip_sm3` /
`oil_zone_ooip_sm3`). A lone contact stays the generic single-column answer.
**The split is geometry + in-place only**: free-gas OGIP off the cap, STOIIP off
the leg. What the model still does NOT carry: PVT correlations (solution gas Rs,
gas-cap expansion, condensate), economics, dynamic behaviour — petekSim's; FVF
still enters only as a validated scalar per zone.
---
## 3. Construction path — how a `StaticModel` is built
The build pipeline, ingest → framework → grid → population → model:
```
petekIO ModelInputs (.pproj)
│ srs-data (thin adapter, no data processing)
▼
Wireframe {boundary, horizons, contacts} + PetroSample logs [(tvd,φ,Sw)]
│ warm_surface (petekTools warm kernel; the SAME path the MC template uses — R2)
▼
solved Top/Base surfaces
│ Surface::guard_below (base-above-top → CrossedSurfaces, or clamp — R1)
│ srs-gridder::layer_grid (conformable k-layering -> corner-point Grid + report)
│ = ceil(max thickness/dz), capped MAX_NK; deep/shallow layers TRUNCATE at the
│ pinch-out horizon → zero-thickness cells, counted in provenance warnings)
▼
Grid (geometry only)
│ srs-petro upscaling (φ length-weighted, Sw pore-volume-weighted)
│ population: logs-in-cell-range -> upscaled cube values; else priors
│ + optional areal trend: lateral NTG (+φ) shape (§3b)
▼
StaticModel {framework, grid, cubes, zones, contacts, provenance}
```
This pipeline is `srs_model::StaticModelBuilder` (relocated from petekSim's
`RefiningModel` 2026-07-03; petekSim's `srs-core` now keeps only a thin facade
that delegates here and applies FVF). Entry points: `from_wireframe(wf, opts)`
(the data-layer hand-off) and `flat(...)` (the model-first flat-box start);
`add_top_control` grows the control set for the live-refine loop; `build()`
converges the current state. See `API.md`.
**One declarative `BuildSpec` (the suite api-consistency contract,
`task_petekstatic_spec_mirror`).** The builder and the MC template share a single
declarative configuration value — `BuildSpec` (georef, boundary, extrapolation,
repair/clamp/collapse floors, sugar-cube, sw_gas, well ties + `TieSettings`,
inputs_ref) — held internally by both; every `with_*` setter is thin sugar
mutating it and `with_spec` installs a whole one. It serializes (a scenario is a
savable file; the whole config layer derives serde — R7 battery in
`tests/spec_conformance.rs`; `McInputs` joins once petekTools' samplers derive
serde), compares by value, and is `#[non_exhaustive]` so the structural-
uncertainty per-zone isochore leg (`decision_structural_uncertainty_isochore`)
lands additively — as do new fields on the `#[non_exhaustive]`
`RealizationDraw`/`ZoneDraw`. Well ties fold in per `TieSettings`: `Replace`
(default; datum substitution — the tie moves exactly the tied node on a dense
lattice) or `Radius { radius_m }` (bounded locality: the residual decays linearly
to zero at the radius across defined datums; beyond is bit-untouched; voids stay
the solve's to taper). One tie authority (`substitute_tie_datums`) serves both
consumers. MC run resources are the separate `McSettings { n, seed, workers,
spill_dir }` behind the single `run_mc` entry (the four historical drivers are
deprecated thin wrappers).
### 3a. Base-horizon relief (Fix S1)
`from_wireframe` wires a supplied `Base` horizon's **real relief** through the
build: its defined nodes solve a base surface the pillars follow, so gross
thickness varies spatially. `BuildOpts.gross_height_m` is the **fallback**
when no Base horizon is supplied (backward-compatible constant offset). Supplied
horizons the build cannot consume yet (Intermediate; a second or
lattice-mismatched Base) are reported non-blockingly on
`Provenance.warnings` (`BuildWarning::UnusedHorizon`), never silently dropped.
The MC template honours base relief too (`decision_template_gross_scaling`):
built from a wireframe carrying a Base horizon, it extracts the nominal per-node
gross field `g(x,y)` (base solved once in kernel space against the nominal top)
and each draw's `gross_height_m` acts as a **level** on that **shape** —
per-node gross = `g × gross_height_m / mean(g)`, so a draw at `mean(g)`
reproduces the deterministic build. Without a Base horizon the draw keeps the
constant-offset behaviour exactly (backward compatible).
**Builder/template kernel unification (R2, 2026-07-04).** Both the deterministic
build and the template's `realize` solve their surfaces through one shared
petekTools warm-kernel path (`warm_surface`: flat bootstrap at the mean control
depth, then a seeded solve). Previously `build()` used the cold `solve_surface`
(natural-dip reference kernel) while the template used the warm kernel; the two
kernels interpolate **unpinned** interior nodes differently, so on thin columns —
where gross is a small difference of two large surfaces — GRV diverged ~20× (a
fully-pinned lattice hid it, since both kernels then just return the control
values). Unifying on the warm kernel makes `realize(mean-gross draw)` reproduce
`build()` within tight tolerance on all column shapes; cold `solve_surface`
remains the accuracy reference, off the build path
(`decision_gridder_kernel_unification`).
**Base-above-top guard (R1, 2026-07-04).** After the surfaces solve, a per-node
`Surface::guard_below` asserts the base sits at/below the top (positive gross). A
crossing is a typed `StaticError::CrossedSurfaces { nodes, worst_m }` (default),
or — with `with_clamp_base_to_top(true)` — the offending columns clamp to zero
gross and the rest are untouched. Thin/crossing frameworks no longer silently
collapse GRV.
**Post-gridding order-repair (R-c, 2026-07-04).** The `CrossedSurfaces` guard is
loud but leaves the caller stuck when a real thin margin genuinely crosses:
independent gridding of Top and Base overshoots at the edges and re-introduces a
crossing a pointwise pre-repair had removed. `with_min_thickness_m(f64)` (on both
the builder and the template) opts into a remedy: `Surface::repair_min_thickness`
pulls every node where `base_z - top_z < min_thickness` **down** to exactly
`top_z + min_thickness`, **preserving the top** — the top is the better-constrained
seismic pick, so the softer base yields to it (repair direction = downward). Off
by default (the guard stays the default); when enabled, `Provenance.warnings`
gains a `BuildWarning::ThinColumnsRepaired { columns, worst_m }` recording the
repaired-node count and the worst (most-negative) original separation. It takes
precedence over `with_clamp_base_to_top` when both are set (repair, don't zero).
**Solve fidelity (2026-07-04, structure-fidelity audit S1/S2).** The stack
surface solve honours every **defined input node exactly** (hard controls,
bit-level) — the on-data contract. Between nodes, the solve is iterated to a
true **fixed point**: the kernel's per-sweep stopping rule can stall metres
short of convergence on a sparse-control lattice (the smooth modes under the
natural-dip boundary relax slowly), so `warm_surface` re-seeds the solve from
its own output until a whole re-solve moves < 1 mm (a converged re-solve exits
in ~1 sweep, so the loop is cheap once settled). Scatter→node **conditioning is
upstream of this seam**: the builder consumes `GriddedDepth` node data; if the
upstream authoring snaps scatter to nearest nodes on a coarser lattice, the
resulting on-data misfit is an authoring artifact the audit fixture quantifies
(`tests/structure_fidelity.rs` S1) — author node values by local interpolation.
**Extrapolation policy (2026-07-04, audit S3).** Every stack surface/isochore
solve is subject to an explicit [`ExtrapolationPolicy`]
(`with_extrapolation` on builder + template). Default **`DecayToData`**: within
`start_cells` (2) of the nearest datum the solve is untouched; beyond, it blends
linearly toward the **nearest datum's value** over `decay_cells` (4), so a data
void inside the model extent flattens to nearest-data instead of running the
kernel's natural-dip linear extension unbounded (or, under-converged, freezing
at the seed mean — both uncontrolled). A merged envelope's margin therefore
STAYS merged (nearest isochore datum is 0). `NaturalDip` opts back into the
legacy unbounded extension for callers who KNOW the regional dip continues.
**Repair precedence — mapped over derived (2026-07-04, `task_petekstatic_topsonly_envelope`).**
The downward repair above (base yields, top preserved) is the *mapped-vs-mapped*
rule. In a horizon stack the repair also distinguishes **mapped vs derived**
(tops-only) surfaces: a mapped horizon is measured and authoritative, so when a
*derived* surface crosses a *mapped* one the **derived surface yields** — the
`Surface::guard_above` / `repair_min_thickness_from_below` twins pull the derived
**upper** *up* to the mapped lower, leaving the mapped surface bit-unchanged. A
mapped-vs-mapped (or derived-vs-derived) crossing keeps the historical
lower-yields direction. With the build-down isochore construction (§4a) a
consistent stack never crosses, so this precedence only guards inconsistent inputs
or a `min_thickness` floor.
### 3b. Areal trend hook — external-drift-lite (Fix 2; INTERIM)
`with_areal_trend(TrendSurface)` (on both the builder and the template) applies a
gridded areal multiplier field, nearest-node resampled to the model column
lattice and **mean-normalized**, per-column to NTG (and φ if the trend flags it)
after population. Semantics (ratified logs-give-shape / draw-gives-level,
`decision_staticmodel_regen_seam`): the **trend gives lateral shape, the
prior/draw gives the level**; because multipliers are mean-normalized the
property field-mean is preserved. Undefined (`NaN`) nodes fall back to `1.0`.
Resampling is index-fraction by default; `TrendSurface::with_georef(origin,
node_dx, node_dy)` maps the field to world coordinates so population resamples by
each column's **world** `(x, y)` (from the grid cell centroids) — the trend then
lands where its data is instead of being aligned to the model lattice by luck
(R4). **This is the INTERIM trend mechanism** (coordinator design
`python-model-build-api.md`): its successor is the per-property pipeline (§3c),
where trends enter as collocated cokriging inside propagation. The hook remains
for the deterministic `with_areal_trend` path; its resampler is now the shared
`petektools::resample` kernel (the nearest-node sampler was retired 2026-07-04).
### 3c. Per-property geostatistical pipeline (P5, `task_petekstatic_property_modelling`)
Properties are modelled **one at a time** through a visible pipeline (coordinator
design `python-model-build-api.md`, owner-refined):
```
PropertyPipeline::new("PHIE")
.upscale(wells, UpscaleMethod::Arithmetic) // logs -> per-cell conditioned values (+ QC)
.propagate(Gaussian::new(variogram, seed)) // SGS fills every cell, conditioned on the upscaled cells
```
- **upscale** is a first-class inspectable step: each positioned `WellLog` snaps
to its areal column, and its samples that fall in a cell's depth range are
upscaled (`srs-petro` power means) into that cell — a per-cell conditioned field
(`NaN` where no log passes) plus an `UpscaleQc` report (upscaled-vs-log stats).
The cell's depth range is interpolated **at the well's `(x, y)`** (bilinear over
the cell corners), not the column-centroid 4-corner mean, so an off-centroid well
on a dipping zone boundary bins each sample into the zone its depth truly falls in
— without this, near-boundary samples were mis-assigned across the zone and the
per-zone upscale compressed the zone proportions toward the mid-range.
- **propagate** runs petekTools' sequential Gaussian simulation
(`geostat::sgs`) **per k-layer**, conditioned exactly on that layer's upscaled
cells, so every cell is filled with a seeded, reproducible draw that honours the
wells and reproduces the data histogram. `Gaussian::with_trend(surface, corr)`
folds a trend in as a collocated (Markov-1) secondary via cokriging, the surface
resampled to the model lattice through `petektools::resample`; `corr = 0`
recovers plain SGS bit-for-bit. The moving-neighbourhood search defaults to a
**bounded** window (16 nodes within `max(1.5·range, 4·spacing)`) — the old
whole-grid default was pathologically slow on real lattices, and beyond a range a
node's kriging weight is ~0, so the bounded field matches within simulation
tolerance; `Gaussian::with_unbounded_search` restores the old window. A simulated
layer with **no conditioning data** is a hard error naming the property (and, via
the zone-scoped callers, the zone) — a silent constant mean-fill erased spatial
structure; `Gaussian::allow_mean_fill` opts back into the fill. **Frame seam:** a
**world**-georeferenced trend is resampled at
each column's world position through the model `Georef` (the grid geometry is
local; the georef maps it to world) — resampling a world trend onto the local
lattice yields an all-`NaN` secondary that the kernel silently drops to plain SGS,
so a secondary covering **< 50%** of the model frame is a hard error, never a
silent no-op.
- Wired into `StaticModelBuilder::with_property` and `StaticModelTemplate::
with_property` / `with_property_mode`; each run's `PropertyReport` (upscale QC +
`propagated`) lands on `Provenance.property_reports`. Regular / axis-aligned
column lattices (the `layer_grid` box/conformable grids); rotated pillars are
future work.
**MC composition (`decision_mc_composition`).** Per property, the template chooses
a mode:
- **`LevelShift`** (default) propagates once (cached at the first realization) and
reuses the pattern each draw, adding only the draw's per-property level shift
(`RealizationDraw::with_property_shift`) — same spatial pattern, moved level; the
cheap ~ms-class MC path (level uncertainty dominates this asset class). The three
**fraction cubes** (`PORO`/`NTG`/`SW`) are **shift-then-clamped** to `[0,1]` per
cell (F9, 2026-07-04): a conditioned real cube legitimately holds boundary cells
(a non-net `NTG=0`, an aquifer `SW=1`), so a shift that would push them out of
range **saturates** them instead — an `SW=1` cell stays 1 under a positive shift
(physically right) and slides interior under a negative one; interior cells move
by the full shift; `NaN` (undefined) cells are untouched. This is a per-cell
*application* clamp only — H2 still validates the **drawn inputs** at the seam
(`realize`), so a garbage sampler is still a typed error. Non-fraction cubes
(e.g. permeability) are shifted without a clamp.
- **`Resimulate`** re-runs SGS with a fresh per-draw reseed (from `seed_index`) —
a new pattern each draw (heterogeneity uncertainty), a simulation per draw.
Both are bit-reproducible for identical draws. Bench (50×50×20): `LevelShift`
realize ~320 µs, `Resimulate` realize ~139 ms.
---
## 4. The zones concept
Zones are the **stratigraphic index** of the model. A **zone** is a named
vertical interval of the column, bounded by two framework horizons (or the
top/contact), spanning a contiguous range of `k`-layers — the grid addressable
by geology instead of raw cell index.
Landed shape (`srs-model`): `Zone { name, top_horizon, base_horizon, k_range }`
+ `ZoneTable` (ordered top→base; k-ranges partition `[0, nk)`), with
`ZoneTable::single(nk)` the degenerate whole-column zone and
`ZoneTable::from_stack(...)` the real multi-zone table.
### 4a. The multi-zone regional framework (`task_petekstatic_multizone`, LANDED)
The canonical framework is an **ordered horizon stack** — `N` horizons top→down
define `N − 1` named zones (intra-zone splits are simply more horizons):
`StaticModelBuilder::from_horizon_stack(HorizonStack, BuildOpts)`. A
`HorizonStack` carries the ordered `horizons` and the per-zone `zone_layers`
(`StackZone { name, color, conformity, nk, contacts }` — each zone names itself and
carries an optional display colour; the old `zone_names` parallel array is folded
into `StackZone::name`, and `color` flows to the model's `Zone` and the section
bundle's colour-by-zone `zones` list).
- **Build-down via non-negative CUMULATIVE isochores, anchored at the TOP
(2026-07-04, `task_petekstatic_topsonly_envelope` + the structure-build rider).**
The stack is resolved by **constructing downward**, not by gridding each horizon
independently: the **top** mapped horizon is gridded once; each deeper **mapped**
horizon carries a **cumulative isochore vs the TOP anchor** — thickness samples
`(z_k − z_top).max(0)` at co-located defined nodes, min-curvature gridded,
clamped `≥ 0`, tapered per the extrapolation policy, then made **monotone** down
the stack (`cum_k = max(cum_{k−1}, iso_k)`); the horizon sits at `top + cum_k`.
Ordering therefore **can never invert**; where two mapped horizons merge the
input thickness is exactly `0` and the zone collapses to **genuine zero**; and —
the **internal-swap invariance convention** — each mapped horizon depends only on
the TOP grid and its OWN grid, so replacing an internal mapped horizon leaves
every other horizon (and the total envelope GRV) **bit-unchanged**, except where
the swapped data genuinely crosses a deeper horizon (the monotone clamp then
collapses that zone toward the data — never adds rock). Fallbacks when a horizon
shares no defined node with the TOP: co-locate with the nearest shallower mapped
horizon (samples lifted by its cum), else an independent solve differenced
pointwise vs the horizon above, clamped `≥ 0`. **Well ties substitute the
measured top into the horizon's gridded datum at the tie node and re-run this
same resolution** — a tie on a fully-defined lattice moves exactly the tied node
(radius of influence 0 cells; audit S4). *This changed built geometry for
stacked models (mapped horizons now match the input scatter within gridding
tolerance rather than being solved in isolation).*
- **The engine owns scatter gridding** (`task_petekstatic_facade_engagement`): a
**Scatter** horizon (`HorizonSource::Scatter(Vec<WorldPoint>)`, world coords) is
gridded **inside** the stack build — the single scatter-gridding authority — via
`from_scatter_stack(stack, opts, StackFrame)`. The engine conditions the points
onto the model lattice with petekTools **bilinear** off-node conditioning
(`StackFrame.georef` maps world→node), leaving genuine data voids **`NaN`**; the
converged solve + `ExtrapolationPolicy` + isochore build-down then act on the
actual observations, so a data-void margin between two exactly-merged horizons
**collapses to zero** rather than inheriting an independently-extrapolated fill.
No caller pre-grids scatter onto the lattice. `Mapped` remains the **pre-gridded
escape hatch** and is documented as **bypassing** the engine's solve/conditioning
fidelity (loaded grids only).
- **Direct-solve conditioning + the factor-once seam** (`task_suite_scatter_perf`):
per-horizon conditioning uses petekTools' direct band-LU `MinCurvatureOperator`
(the cap-bound iterative SOR is the degenerate-system fallback), driven through a
crate-private `ScatterConditioner` that splits **factor** (assemble + factor the
operator for a horizon's fixed off-node sample geometry — the dominant, once-per-
surface cost) from **resolve** (back-substitute a fresh depth vector, ~O(n·bw)).
Because the sample `(x, y)` — and thus the factorization — is fixed across depth
vectors, re-seating the same geometry with new depths is a cheap resolve, not a
re-conditioning. Conditioning is **bit-identical** to the prior one-shot path.
The 11-horizon `condition_scatter` map stays rayon-parallel (each horizon factors
independently). *The per-draw resolve lever has no live consumer while the MC
template conditions each horizon once and perturbs surfaces by additive field
(not by re-conditioning perturbed scatter); a data-perturbation MC mode that uses
it is a coordinator decision.*
- **Three horizon source kinds** (`HorizonSource`): the raw **Scatter** above; a
**Mapped** depth surface already on the lattice (tied or untied — the tie is a
well-tie concern, not a source distinction); and a **TopsOnly** internal horizon
with *no* mapped surface, defined solely by well **picks**. A tops-only internal split is
**subordinate to its mapped envelope**: it is seated at the mapped horizon above
it **+ `min(pick isochore, envelope isochore)`** — its own pick-thickness field
(min-curvature gridded, constant-thickness fallback for a single pick), plainly
**clamped** (a `min`, not a redistribution) against the zone's mapped envelope
thickness. Consequences by construction: envelope `0` ⇒ split `0` ⇒ **both
sub-zones collapse** to zero exactly (no phantom slab); the split lives strictly
inside `[mapped top, mapped base]`, so a **derived surface can never displace a
mapped one**. A *trailing* tops-only horizon with no mapped horizon beneath it
has no envelope and falls back to the legacy absolute drape. *The geological
statement: an untied internal split follows the mapped horizon above it at the
thickness its well picks record, but is never allowed to breach the measured
horizons that bound its zone.* (Redundant mapped surfaces aliasing one horizon —
the same `GriddedDepth` given twice — is tolerated; it yields a zero-thickness
zone by construction.)
- **Per-interface order-repair (precedence: mapped over derived).** On a
consistent isochore-built stack no interface crosses, so the repair is a **no-op**
safety net; it still fires under a `min_thickness` floor or genuinely inconsistent
inputs. When it does, it honours **mapped-over-derived precedence**: a *mapped*
horizon is authoritative, so a crossing *derived* (tops-only) surface **yields**
(is moved), never the mapped one; a mapped-vs-mapped (or derived-vs-derived) tie
keeps the historical behaviour (the lower yields, top preserved). Per-interface
repair records land in provenance (`StackProvenance::interface_repairs`).
- **Per-zone layering + conformity.** Each zone is layered by its own
`Conformity` + `dz` (the conformity engine, made per-zone); total `nk` = sum of
the per-zone counts. The **200-layer cap applies to the TOTAL** (per-zone
breakdown retained; proportional scale-down when exceeded).
- **Per-zone contacts (a domain statement).** The real framework assigns
**separate** contact sets to different zones — distinct accumulations in one
stack — and some zones have **no** contact at all. So contacts are scoped per
zone: `in_place_by_zone()` clips each zone's hydrocarbons against *its* contacts
(two-contact gas/oil split where a zone has both), and a **contactless zone
contributes its gross bulk volume but ZERO hydrocarbon in-place** — no contact
means no *known* accumulation, so it is explicitly **not** a full hydrocarbon
column. The rollup `ZonedInPlace::total` sums the zones, so
`sum(zone volumes) == total` to FP tolerance (conservation).
- **Per-zone stats.** `StaticModel::zone_stats(property)` reports per-zone
active-cell count/mean/min/max over the full-grid cubes (cubes stay full-grid,
indexed by zone). Per-zone priors / per-zone SGS variograms are the noted
follow-up; MC draws stay global-per-property this wave (per-zone contact draws =
the documented follow-up).
- **Provenance records the stack.** `Provenance::stack: Option<StackProvenance>`
carries the ordered horizon names, the per-zone layering (`ZoneProvenance`:
name, bounding horizons, conformity, nk, k_start, truncated_cells), and the
interface repairs.
The classic Top+Base wireframe (`from_wireframe`) is the 2-horizon / single-zone
degenerate case — unchanged and backward-compatible.
### 4b. Cell-collapse threshold (Petrel-style, LANDED)
`with_collapse_below_m(f64)` (builder **and** template; default OFF): after
layering, any cell thinner than the threshold **collapses** — but
**volume-conservingly**. The layer interface is snapped so a **thicker
zone-interior neighbour absorbs the sliver's thickness** (merge into the thicker
vertical neighbour; rock is never deleted). The merge **never crosses a zone
boundary** (a zone's bounding interfaces are pinned), so each zone's volume is
individually conserved; a single-layer zone has no interior neighbour and is left
untouched. Collapse is applied per pillar, so a cell zeroes only where all four
pillars collapse it (the same max-pillar rule as truncation). Per-zone collapsed
counts land in provenance (`BuildWarning::CellsCollapsed`); a collapsed
(zero-thickness) cell is excluded from volumetrics and `NaN`-marked in the view
bundles by the existing `dz ≤ ε` rule. It bites hardest on sub-`dz` slivers under
fixed-count proportional layering in thin columns and on Follow-style truncation
partials.
---
## 5. Framework, faults, and the wireframe
The `Wireframe` is the structural skeleton the grid conforms to. Today it carries
boundary + horizons + contacts; **faults are deferred** (`question_gridder_spec` /
P5 `task_petekstatic_zones_faults`: split pillars + throw + non-neighbour
connections). The `StaticModel` framework is designed so a fault set is *additive* —
it lands as a new framework member and a grid that splits pillars, without changing
the property-cube or zones contracts. Open/closed: extend by adding, not editing.
---
## 6. Provenance — the reproducibility record (LANDED)
A `StaticModel` records **what produced it**: the input bundle identity
(`inputs_ref`), the gridder settings (`SolveOpts`, `Conformity`, `nk`), the
it never affects geometry or cubes, but it lets a consumer label a realization
and answer "which inputs gave me this P10 model?". `Hardness` per constraint
(on the wireframe) is the per-node half of this lineage.
---
## 7. The regeneration seam — RATIFIED (graph `decision_staticmodel_regen_seam`)
### 7a. Monte-Carlo regenerates the model per realization
The MC loop does **not** perturb a shared grid in place — it **regenerates a
whole `StaticModel` per realization** from that realization's sampled inputs.
petekStatic is the single owner of *how a model is built*; each realization is a
clean, independently-valid `StaticModel`. (Since the charter re-scope the MC
driver itself is also petekStatic's — `task_peteksim_mc_structured`, retargeted
— which keeps the whole loop below the product facade.)
**Warm-start makes regeneration cheap.** The reusable vs per-realization split:
| areal lattice (dims fixed) + conformity (+ `nk` for Proportional) | sampled scalar draw (area, gross height, φ, Sw, NTG, contact, optional GOC via `with_goc`); under a Follow conformity `nk` is dz-derived per realization from the drawn thickness (deterministic in the draw → still bit-reproducible) |
| control-point **topology** (which nodes are pinned) | control-point **depths** (structural shifts, typed `Option`) |
| the previous realization's **converged surface** (the warm seed) | the re-converged surface (a few sweeps from the seed vs 20k cold) |
| attached logs / population inputs | the re-layered grid + repopulated cubes |
Landed as `StaticModelTemplate::new(wf, opts)` (holds the fixed geometry +
warm-start state) with `realize(&mut self, &RealizationDraw) ->
Result<StaticModel>` the cheap call. Measured warm-start win: **~14×** (94 ms vs
1.34 s at 50×50) — the number that makes per-realization regeneration viable.
**Stack-aware MC (P8, `task_petekstatic_multizone_2`).**
`StaticModelTemplate::from_horizon_stack(stack, opts)` runs MC over the multizone
framework: the horizon surfaces + interface repair are resolved **once** (they do
not vary per draw), so `realize`/`realize_into` vary only spacing, the **per-zone
contacts**, and the **per-zone property levels** (`RealizationDraw.zones:
Vec<ZoneDraw>`). Geometry topology is draw-invariant ⇒ bit-deterministic by
construction and `realize_into`-recyclable (the stale-buffer bit-compare holds); a
contactless zone contributes GRV, zero HC.
**The ratified amendments (all landed):**
1. **`StaticModelTemplate: Send` + `StaticModel: Send`** (Sync not required),
compile-checked via `static_assertions` — consumers shard realizations
one-template-per-worker; the warm chain makes `realize` serial per template.
2. **`RealizationDraw`**: `#[non_exhaustive]` + `::new(...)` (P5 structural
fields stay additive, no re-ratification); concrete `pub seed_index: u64`;
derives `Clone + Debug`; structural perturbation as a concretely typed
`Option<StructuralPerturbation>` (empty for MVP). **`fvf` deliberately
EXCLUDED** — FVF is drawn separately and applied at the volumetrics output
surface, never rides the draw.
3. **Perf:** grid-per-realization is its own (heavier) budget — the 422µs/100k
figure is the sampler's only. Only property-cube `Vec<f64>`s conceptually vary
per draw; room is left for an additive
`realize_into(&mut self, &draw, &mut StaticModel) -> Result<()>` buffer-reuse
variant (not yet implemented).
4. **`&mut self` on `realize`** (the warm-start chain) and **`Result` per draw**
(warm-started solves can fail to converge; `StaticError` composes into
`SrsError` via `#[from]`).
### 7a-bis. Per-horizon correlated structural uncertainty (LANDED — `decision_structural_uncertainty_isochore`)
The MC template's structural draws are **correlated perturbation fields**, applied
**in the space the geometry is built in** so ordering + exact merges survive every
draw *by construction* (no repair rules):
- **TOP surface** takes a correlated **DEPTH** field per draw
(`RealizationDraw::top_structural`): an unconditional Gaussian random field
(petekTools `sgs_unconditional`, marginal `N(0, sd_m²)`, the row's variogram)
added to the converged top. The warm-start chain still advances on the
**unperturbed** surface (per-draw noise, not structural drift).
- **Every deeper horizon** perturbs via its zone's **ISOCHORE (thickness)** field
(`ZoneDraw::isochore_structural`): the fixed base thickness plus a correlated
field, **clamped `>= 0`** and **zero-masked where the base isochore is exactly 0**.
Deeper horizons are reseated `top + Σ isochores`, so a perturbed stack can never
invert or resurrect a collapsed zone.
- **Determinism + ties:** each field's seed = `draw.seed_index` salted by the
horizon index (bit-reproducible per seed; independent across horizons). The stack
structural `realize` is a **pure function of the draw** → sharded == serial and
`realize_into` recycling stay bit-exact. Perturbation is pinned to **zero at
well-tie nodes** (radius-0 locality).
- **Clamp bias (measured + bounded):** the `>= 0` clamp biases mean thickness up near
pinchouts by `E[max(0, T+P)] − T = sd·φ(T/sd) − T·Φ(−T/sd) ∈ [0, sd/√(2π)]`,
variogram-independent, → 0 as `T/sd → ∞`. Pinned by a planted-truth GRV recovery
test (`tests/structural_uncertainty.rs`).
### 7b. Kernel constraint (from the ConvergentGridder adoption — ENFORCED BY TYPE)
The template's warm-start surface lives **in petekTools kernel space**:
`solve_surface_seeded` accepts only the `KernelSurface` newtype, whose only
sources are `KernelSurface::flat` (a constant field — a kernel fixed point, the
safe bootstrap) and a prior `solve_surface_seeded` output.
**Kernel unification landed (2026-07-04, petekTools f81b6a6).** The newtype's
original justification — warm==cold holding only *within one kernel* because
petekTools lacked the natural-dip (linear-extrapolation) boundary of the cold
`solve_surface` (12.48 ft interior sag vs 0.0 on the plane reference) — is
**retired**: petekTools now carries the natural-dip boundary, so the cold reference
and the warm kernel share one fixed point. This is gated **per-node** (not
aggregate — the old min/max/mean cross-check passed while per-node drift was
12 ft): `srs-gridder/tests/adoption_readiness.rs` now asserts per-node plane
parity, and `seeded_warm_equals_cold_of_same_kernel` re-verifies warm==cold to
1e-6 on a well-determined framework (`decision_gridder_kernel_unification`).
**The newtype is KEPT** (reassessed 2026-07-04) on its second, still-live
justification: it is a **provenance / staleness guard** on the warm chain. A seed
must be a genuine same-kernel output — never an arbitrary `Surface` (a cold solve
carries caller-chosen `SolveOpts` whose fixed point can differ from the kernel's
fixed defaults; a hand-built/foreign field is stale). The barrier is zero-cost
(open/closed) and keeps the chain sound; it intentionally has no `From<Surface>`.
A subtlety surfaced while re-verifying: a **single** flat-bootstrap solve is not
itself converged on a *sparse*-control lattice (the smooth level mode relaxes
slowly under the near-Neumann boundary), so `build()` and `realize(mean draw)`
can differ there — tracked as an R2 follow-up, not a natural-dip regression.
### 7c. What the consumer reads off a `StaticModel`
The read-only accessor contract: `model.grid()` (geometry + `bulk_volume` +
`cells()`), `model.property("PORO")` / `"SW"` / `"NTG"` + `property_names()`,
`model.contacts()`, `model.zones()`, `model.provenance()` — **plus the
volumetric answer itself**: `model.in_place()` → `InPlace`, FVF applied by the
caller (`ooip_sm3(OilFvf)` / `ogip_sm3(GasFvf)`). petekSim's facade presents
these results; it re-computes nothing.
### 7d. The structured MC driver + tornado (LANDED, `mc` / `tornado`)
The uncertainty loop over the seam (`task_peteksim_mc_structured` /
`task_peteksim_tornado`). `run_structured_mc(&mut template, &McInputs, n, seed)`
samples every uncertain input from a petekTools `Sampler` (raw or `.clamped` —
petekTools' hard limiter, so a wild fraction prior can't trip §7's H2 guard),
realizes each draw, and **keeps** the per-draw `oil_sm3`/`gas_sm3`/`grv_m3`
vectors (W17 — the realizations are the product; tornado and cross-checks reuse
them). `boi`/`bgi` are sampled but applied at the volumetrics surface, never on
the draw (amendment 2). Reproducible: one seeded stream, fixed field order,
`seed_index = seed + i` per draw.
**Error policy — fail-fast with the draw index.** The first draw whose
realization or volumetrics fails stops the run and surfaces as the typed
`StaticError::McDraw { index, source }`: the §7 H2 rejection is carried to the
caller *with the offending draw identified*, deliberately over collect-and-report
(a bad draw means the input distribution strays outside the physical range — fix
it at the sampler). `aggregate_field` brackets a multi-segment field between
petekTools' `Correlation::Independent` (narrow) and `Comonotonic` (wide downside).
`tornado(&mut template, &McInputs, n, seed, lo_pct, hi_pct)` swings each input
one-at-a-time between its **realized** percentiles (others at P50), re-realizes,
and ranks the oil-in-place swings descending. Pivots are percentiles of the
realized input vectors — not the analytic distribution — so the ranks stay
consistent with the MC P-curve; the same `(n, seed)` reuses the MC's draws.
### 7e. View bundles — the viewer export seam (LANDED, `view`; graph `decision_viewer_home_product`)
The model exports typed, JSON-stable **inspection bundles** for a *separate*
viewer codebase (the `peteksim` viewer). The division is strict — **petekStatic
exports; the viewer renders, it never computes.** Everything a map /
cross-section / 3-D view needs is pre-computed here off the populated
`StaticModel` and handed over as serde-`Serialize` value types. The viewer scales
for display (vertical exaggeration, colour ramps, threshold filtering) but runs
no reservoir computation.
Three bundles, one shared frame:
- **`MapBundle` = `model.map_bundle(&MapSpec)`** — areal (plan-view): realized
structural depth surfaces (top + base, grid-georeferenced, named after the
framework horizons), property maps as a single **k-slice** and the
**zone/interval average** (the useful default), the outline ring(s) in world
coords (a `from_horizon_stack` build with no wireframe boundary derives the world
outline from its georef, or takes an explicit `with_boundary` ring — never the
degenerate unit square against a world frame), well surface markers (with tie
residuals where provenance carries them
— none in the MVP), and per-contact **subcrop masks** (the columns the contact
plane crosses — the honest, marked-mask form of a contact contour). Every layer
carries its value range for a legend.
- **`IntersectionBundle` = `model.intersection_bundle(&SectionSpec)`** — a
vertical section walked through the areal lattice (§7e below in API). Since v4
it also emits `horizon_traces` — one depth polyline per *interior* framework
horizon (zone boundaries between the structural top/base), parallel to `columns`.
- **`VolumeBundle` = `model.volume_bundle(...)`** — the corner-point cell mesh
(this is where the mesh builder lives now — moved DOWN from petekSim's
`srs-core/mesh.rs` per the layer charter; peteksim retires its copy onto this
bundle at the viewer wave).
**Contract conventions.** All areal layers share one regular `GridFrame`
(origin + spacing + `ncol=ni` / `nrow=nj`), reconstructed from the grid's
column-centroid lattice (the `xy↔ij` map — the same frame the property pipeline
uses); row-major `values[j*ncol + i]`; `f64::NAN` = undefined/outside (JSON
`null`). SI units, positive-down depth. The serialized **key structure** is the
cross-codebase seam — documented in `API.md`, version-tagged (`SCHEMA_VERSION`),
and locked by a schema-snapshot test per bundle.
**The world/local frame discipline (a standing seam rule).** The canonical
real-data configuration is a **LOCAL-origin cell lattice + a registered world
`Georef`** (the "F5" convention): a trace/spec arrives in **world** coordinates
(the view `GridFrame`), but a cell's ZCORN `corners` live in the grid's **local**
lattice. Any geometry that consumes both a spec point *and* the raw corners —
notably `intersection_bundle`'s `fence_edge_depths` areal clip — **must convert to
one frame first** (map the world point + direction into the local lattice via the
`WorldToLattice` affine, or the corners into world). Mixing frames does not error;
it silently produces garbage — a world line misses every local cell rectangle and
the section collapses to a flat centroid trace. This has now bitten **three** times
(the bundle-frame F5 zero-columns bug, the collocated-trend no-op, and the
section-edge centroid collapse), so it is a hard convention, not a caution.
- **A miss is loud, not silent.** Where a frame-consistent computation cannot
legitimately miss (e.g. a non-degenerate fence line through the cell it was
matched to), the miss path is a `debug_assert` + a visible warning, not a quiet
fallback. The single honest degenerate that keeps the centroid (`l == r`) is a
zero-areal-extent trace (a single-point / vertical bore) — explicit and distinct.
- **Test convention (mandatory): every frame-sensitive view/population test must
carry a world-georeferenced variant** — a local-origin lattice + a world georef,
spec in world coordinates, asserted against the local-frame image of the same
geometry. An axis-aligned world fence is *not* sufficient (its single bounding
slab clip is offset-invariant and hides the bug); use an **azimuthed/diagonal**
trace. See `view::section` tests
(`georef_world_fence_and_bore_edges_are_frame_invariant_and_dip`).
---
## 8. Contract history — ratified calls (was: open questions)
- **Q1 — Volumetrics home: RESOLVED, INVERTED (2026-07-03).**
`decision_layer_charters` supersedes `decision_volumetrics_home_peteksim`: the
static layer owns volumetrics AND static uncertainty (the industry split).
`srs-volumetrics` + `srs-uncertainty` relocated here; `StaticModel` owns its
volumes (§2); petekSim keeps `srs-pvt` for its dynamic work and the product
facade. FVF crosses as a validated scalar input.
- **Q2 — The regeneration API: RATIFIED + LANDED** (§7a;
`decision_staticmodel_regen_seam` with peteksim's four amendments).
- **Q3 — Draw construction: RESOLVED.** petekStatic defines the neutral
`RealizationDraw`; whoever owns the sampler fills it (today that is also
petekStatic — `srs-uncertainty` — but the type stays sampler-agnostic; no
sampler dependency in `srs-model`'s seam types).
- **Q4 — Faults / zones depth (P5) do not change this contract.** Confirmed:
the framework/zones/cube contracts absorb faults + multi-zone layering
additively (`task_petekstatic_zones_faults`); `RealizationDraw` absorbs
structural sampling via its `#[non_exhaustive]` + typed-Option design.
Open: kernel unification (`decision_gridder_kernel_unification` /
`task_petektools_natural_dip_boundary`) — until then §7b's type guard stands.
---
## 9. What exists today vs planned
**Exists (green):** `error` (`StaticError` + `#[from] GeoError`),
`srs-grid`, `srs-gridder` (`solve_surface`, `solve_surface_seeded` +
`KernelSurface`, `layer_grid`), `srs-petro`, `srs-wireframe`, `srs-data`,
`srs-volumetrics` (in-place + FVF types + range validation), `srs-uncertainty`
(distributions, SplitMix64, fallible P90/P50/P10), `srs-model` (`StaticModel`,
`StaticModelBuilder`, `StaticModelTemplate`, `RealizationDraw`, `ZoneTable`,
`Provenance`). The full pipeline runs here; petekSim's `srs-core` keeps a thin
`RefiningModel` facade over it.
**Exists (green), continued:** the per-property geostatistical pipeline (§3c,
`task_petekstatic_property_modelling`) — `PropertyPipeline` (upscale + per-layer
SGS + collocated cokriging) on both the builder and the template, with the
`LevelShift`/`Resimulate` MC modes (`decision_mc_composition`); the structured MC
driver + tornado (§7d, `task_peteksim_mc_structured` / `task_peteksim_tornado`) —
`run_structured_mc` / `McInputs` / `McResult` / `aggregate_field` / `tornado`.
**Planned:** real boundary rings
(`task_petekstatic_boundary_rings`, ready); multi-zone + faults + **per-zone**
geostatistical population (P5 `task_petekstatic_zones_faults`); `realize_into`
buffer recycling; a petekStatic-side PyO3 mirror (today Python access is via
petekSim's facade); 0.1 release (`task_petekstatic_release_0_1`).
---
## 10. Out-of-core — the backing-storage mode (`task_petekstatic_slab_streaming`)
Larger-than-memory models must build, populate, run MC, and answer volumetrics by
**spilling to disk**, not by dying (the P9 engine half; coordinator design
`petekSuite/dev-docs/designs/out-of-core-strategy.md`, rulings R2/R3/R4/R5). The
design is **budget-driven and loud**, and — critically — the in-core StaticModel
type keeps its API: **spilled is a backing-storage mode, not a new type.**
**The budget + the loud switch (R5).** The build/template API carries a declared
`MemoryBudget` (default: a documented fraction of physical RAM). `build()` compares
a live-set estimate (`live_set_bytes`: ZCORN f64 + cubes f64, ×2 for the warm/MC
state) against it. **Below → the in-core path, byte-identical** (zero behaviour
change; the golden/determinism suite is untouched and a generous-budget build is
bit-identical to the pre-budget one). **Above → the spilled path**, and the engine
**says so**: a `SpillNotice` on stderr naming the mode, budget, estimate, and store
path. Never an OOM kill; never a silent switch.
**The abstraction (the enum, cleanly).** A `StaticModel` carries an internal
`spill: Option<Arc<SpillBacking>>`. `None` is the in-core model — geometry + cubes
owned by the `Grid`, every accessor byte-identical to before. `Some(_)` is a spilled
model whose heavy per-cell arrays live in a memory-mapped petekTools `store` (the
R1 lane store); its `grid` is a unit placeholder. The volumetric surface
(`in_place`, `in_place_summary`, `bulk_volume`, the two-contact split) **dispatches
on the backing**: in-core reads the `Grid`; spilled **streams** the store one k-slab
at a time. Shared behind an `Arc` so the model stays `Clone`/`Send` (the mmap is
shared, not copied) and the store file is cleaned up when the last clone drops
(`Drop`), unless the caller detaches it (`with_spill_persist`). `grid()`/`property()`
on a spilled model return the placeholder — a spilled model is read through its
volumetric methods; the raw borrowed `&Grid` is the in-core representation only.
**The lane layout maps the grid's natural order (R1/R2).** ZCORN is already
k-slab-major (`zcorn[k·ni·nj·8 ..]`) and each cube is `cube[k·ni·nj ..]`, so a
k-window of any lane is **one contiguous zero-copy slice** — exactly the store's
chunked-along-k shape. ZCORN → a slab f32 lane; each cube → a slab f32 lane; COORD
(small, k-invariant) → a resident f64 flat lane. The spilled cell rebuild is
bit-parallel to the in-core vertical-lattice fast path except ZCORN is narrowed to
f32 (the sole parity delta).
**Precision + parity (R4, the honesty clause).** Storage lanes are **f32** at spill
scale — the out-of-core enabler and the MC bandwidth lever (halves the bytes on the
streaming summary read). So in-core↔spilled parity is **tolerance-based, not bit
parity**: measured on a 2500 m fixture, GRV/bulk are bit-exact, HCPV ≤ ~2e-9, and MC
oil ≤ ~3.1e-6 relative (larger because a near-contact cell can flip in/out under f32
centroid rounding) — asserted ≤ 1e-5, documented here. **Accumulations stay f64**
regardless; only storage lanes narrow. Bit-determinism holds **within** a mode
(sharded MC == serial at every worker count, in-core and spilled).
**MC never spills per-draw state (R3).** The structured-MC loop keeps its
one-reusable-model / realize → summary → discard shape. In the spilled mode each
draw realizes in-core into the one reusable model, is flushed to **one reused
per-shard store** (overwritten every draw — never a new file per draw, so no
per-draw disk accumulation), its summary streamed from the f32 lanes, then the
backing is detached (the in-core grid stays intact for the next `realize_into`).
`run_structured_mc_spilled` / `_parallel_spilled` prove the determinism +
stale-buffer contracts hold in the spilled mode.
**Slab-incremental build — true O(slab) build peak (v2, item 1).** The gridder
path now builds a spilled model **slab-incrementally**: a `StreamingLayering`
(srs-gridder) produces ZCORN one k-slab at a time from two interface planes (the
same f64 `boundary_depth` as the in-core path, narrowed to f32), and the builder
writes each ZCORN + constant-cube slab **straight into the store**
(`slab_mut_f32`), never materializing a whole in-core grid. The heap/anonymous
working set is **O(slab)** (a slab + two O(area) interface planes), so a model
whose in-core live set exceeds RAM can now be **built**. Bit-identical to
build-then-spill (same f64→f32 narrowing, same constant cubes). Measured
(`dev-docs/bench/scripts/rss_probe.sh`, 3.2M cells, `/usr/bin/time -l` max RSS):
in-core 90 B/cell (the whole f64 grid — unbuildable above RAM), v1 build-then-spill
135 B/cell (grid + store resident together), **v2 streaming 47 B/cell** (≈ the f32
store alone) — a 2.9× cut, and v2 sits *below* the in-core build. Eligibility: the
per-slab-safe population (constant priors / per-zone constant), collapse **off**
(the volume-conserving collapse pass is a whole-zone-column carry). Ineligible
spilled builds (logs/trend/SGS pipelines, or collapse) fall back to build-then-spill.
**v2 deferrals (honest).**
- **True O(slab) *resident* RSS** would need the petekTools `store` writer to shed
written pages (`madvise(MADV_DONTNEED)`); today the mmap'd f32 store's written
pages stay in the page cache, so total RSS is O(store_f32) = the on-disk model
itself (file-backed, sheddable) + O(slab) heap. Raised to petekTools as
`question_petektools_store_page_evict`.
- **Streaming SGS-pipeline population** (logs/trend/SGS builds spill via
build-then-spill) and **writable-store MC realize** (spilled MC realizes in-core
into the one reused model then flushes — R3: MC never spills per-draw state, so
its peak is the bounded reused model, not a scaling wall) — both need per-slab SGS
population + must preserve the `realize_into` budget/determinism contracts.
- Spilled `zone_stats` (arbitrary-property per-zone cube statistics) and spilled
view-bundle windowing. (Spilled `in_place_by_zone` **shipped** in v2 — item 3.)
---
## References (suite planning graph)
- `task_relocate_refine_orchestration` (this wave) · `task_peteksim_mc_structured`
(retargeted) · `task_peteksim_tornado` (retargeted) ·
`task_petekstatic_zones_faults` · `task_petekstatic_property_modelling` ·
`task_petekstatic_boundary_rings` · `task_petekstatic_release_0_1`
- `decision_layer_charters` (the 2026-07-03 re-scope) ·
`decision_staticmodel_regen_seam` (the ratified seam) ·
`decision_gridder_kernel_unification` · `decision_srs_core_seam_pathdeps`
- Specs: `convergent_gridder_spec`, `layer_interpolation_spec`,
`log_upscaling_spec`, `data_to_wireframe_spec`, `grv_from_grid_spec`,
`monte_carlo_volumetrics_spec`, `distribution_sampling_spec`
- Cross-library design: `petekSuite/dev-docs/designs/staticmodel-regen-seam.md`