petekstatic 0.1.4

petekStatic — the GEOMODEL layer: structural framework, grid construction, property modelling, volumetrics and static uncertainty, consolidated into one crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
# petekStatic — public API

> **Status: WORKING CONTRACT.** The geomodel crates are green and the headline
> **`StaticModel`** surface (`srs-model`) has **LANDED** per the ratified seams
> (SPEC §7/§8: `decision_layer_charters`, `decision_staticmodel_regen_seam`).
> This file is the contract: changing a cross-library signature (the StaticModel
> accessors, `RealizationDraw`, the regeneration API) requires coordinator +
> consumer sign-off; library-internal signatures are petekStatic's own call. The
> file locks fully at the 0.1 release. Rust is canonical. Python exposes both
> the compact flat-model surface and the first petekStatic-owned static workflow
> facade (`Grid.from_project(...).geometry(...).horizons(...).zones(...).layers(...)`).
> The workflow facade is currently a declarative/spec layer with in-memory
> property arrays, petekTools formula evaluation, deterministic simple
> volumetrics, and lowering of `upscale(...).sgs(...)` property declarations to
> `PropertyPipelineSpec`; it is not yet the full Rust corner-point grid
> construction path.

Conventions (house style): `Result<T> = std::result::Result<T, StaticError>`;
per-cell cubes are `Vec<f64>` indexed by linear cell index; `NaN` = undefined;
depths are metres, positive-down (petekIO's negative-down elevation is flipped at
the `srs-data` ingest boundary).

---

## Python workflow facade — first vertical slice

```python
import petekstatic as pst

grid = (
    pst.Grid.from_project(project)
    .geometry(cell=(50.0, 50.0), orient=0.0, outline="ModelEdge")
    .horizons(
        ["Top reservoir", "Base reservoir"],
        tie_to_tops=True,
        gridding=pst.Gridding(collapse_thin=True),
    )
    .zones({"Reservoir": ("Top reservoir", "Base reservoir")})
    .layers({"Reservoir": pst.Layering(n=4)})
)

p = grid.properties
p.ntg = 0.80
p.por = p.ntg * 0.85
p.sw = 0.20
p["PermXY_BC"].set(100.0)
p["PorE_BC"].set(0.25)
p.calc(
    ["RQI = $lambda * sqrt(PermXY_BC / PorE_BC)"],
    params={"lambda": 0.0314},
)

case = grid.volumes(ntg="NTG", por="POR", sw="Sw", fluid="oil", fvf=1.30)
result = case.run(progress=True)
```

Exported Python names:

```python
Grid, Gridding, Layering, Spherical,
PropertyStore, PropertyHandle, PropertyPipelineSpec,
Var, WellLogSpec, DistributionSpec, CoKriging,
UpscaleRecipeBuilder, SgsRecipe, distributions, upscale,
WellLog, PropertyPipeline, VolumeCase, VolumeResult,
StaticModel, build_flat_model, __version__
```

Current behavior:
- `Grid.from_project(project)` accepts the petekIO `Project` facade or a
  project-like fixture.
- `geometry(..., outline=...)` and `horizons([...])` validate names against
  `Project.inventory()` and mapping-like project collections. Missing assets
  raise `ValueError` with available names.
- `grid.properties` stores dense in-memory property vectors. Handles are
  available as `p["NAME"]` and convenience attributes like `p.ntg`, `p.por`,
  `p.sw`. Constants can be written as scalars (`p.ntg = 0.8` or
  `p.ntg.set(0.8)`) and are broadcast to the declared cell count; pass an
  iterable when assigning explicit per-cell values. Property handles also
  compose into assignment expressions (`p.por = p.ntg * 0.85`), routed through
  the same formula evaluator as `p.calc(...)`.
- `p.calc([...], params={...})` delegates the whole formula block to
  `petektools.evaluate_formula` and writes outputs only after the block succeeds.
- `pst.Var(model, major, minor, vertical, azimuth, sill=None, nugget=None)`
  records the canonical anisotropic variogram spec. `pst.Spherical(range_m)` is
  accepted as isotropic shorthand and converts to `Var`.
- `pst.distributions.from_logs()` records that the SGS target distribution is
  estimated from the resolved log samples. It is also the default for log-channel
  sources when no distribution is supplied.
- Assigning an `pst.upscale(source).sgs(...)` recipe, for example
  `p.por = pst.upscale(logs.PHIE(logs.NetSand > 0.50)).sgs(...)`, records the
  declaration and lowers it to `PropertyPipelineSpec`. Inspect declarations with
  `p.declarations("por")`, inspect lowered specs with `p.pipelines("por")`, and
  access the object with `p.pipeline_spec("por")`.
- `grid.volumes(...).run(progress=True|callback)` computes deterministic simple
  GRV/HCPV/in-place volumes from the declared cell size, horizon thickness, layer
  count, and named `ntg`/`por`/`sw` arrays. `VolumeResult.summary()` and
  `VolumeResult.by_zone()` return dictionaries.

Property recipe example:

```python
logs = project.logs
vgm = pst.Var(
    "spherical",
    major=1500,
    minor=700,
    vertical=20,
    azimuth=35,
    sill=1.2,
    nugget=0.05,
)

p.por = pst.upscale(logs.PHIE(logs.NetSand > 0.50)).sgs(
    variogram=vgm,
    distribution=pst.distributions.from_logs(),
    seed=12,
)

lowered = p.pipelines("por")
```

Executable recipe example, when `logs.NetSand` or the project resolver returns
positioned well logs:

```python
iso = pst.Var("spherical", major=1500, minor=1500, vertical=1500, azimuth=0)
p.ntg = pst.upscale(logs.NetSand).sgs(
    variogram=iso,
    distribution=pst.distributions.from_logs(),
    seed=11,
)

pipe = p.execute_pipeline("ntg")
config = pipe.config()
smoke_model = pipe.apply_to_flat_model()
```

Execution boundary:
- If `source.to_well_logs(project)`, `source.resolve_well_logs(project)`,
  `project.resolve_log_expression(source)`, `project.resolve_well_logs(source)`,
  or `project.resolve_log_source(source_dict)` returns positioned logs, the
  lowered spec stores `WellLogSpec` inputs.
- `p.execute_pipeline("por")` returns a Rust-backed `pst.PropertyPipeline` handle
  only when positioned wells are available, no cokriging/trend is bound,
  `distribution=pst.distributions.from_logs()`, and the variogram is isotropic
  for current Rust execution (`major == minor == vertical`, `azimuth == 0`).
- `PropertyPipeline.apply_to_flat_model(...)` is the current smoke execution
  path. Applying a pipeline to an arbitrary mutable production grid is not
  exposed through Python yet.
- Lazy unresolved log expressions, cokriging/trend binding, non-`from_logs`
  distributions, and anisotropic Rust execution raise explicit
  `NotImplementedError`s. Anisotropic `Var` specs are still serialized intact and
  `PropertyPipelineSpec.to_petektools_variogram()` lowers them to the petekTools
  anisotropic variogram object when petekTools is installed.

---

## `error`

```rust
pub enum StaticError {
    InvalidInput(String),
    Grid(String),
    OutOfRange(String),
    CrossedSurfaces { nodes: usize, worst_m: f64 },   // R1: base crosses above the top (GRV-collapsing); opt out via with_clamp_base_to_top
    Geo(#[from] petekio::GeoError),   // DATA→GEOMODEL seam: `?` chains through
}
pub type Result<T> = std::result::Result<T, StaticError>;
```

Downstream composes it: `SrsError::Static(#[from] petekstatic::error::StaticError)`
(which transitively reaches `GeoError` too).

## `srs-grid` — the i,j,k corner-point grid

```rust
pub struct Grid { /* geom + properties + layers, all private */ }
impl Grid {
    pub fn new(geom: CornerPointGeom) -> Grid;
    pub fn dims(&self) -> Dims;
    pub fn cell_count(&self) -> usize;
    pub fn cell(&self, c: Ijk) -> Cell;
    pub fn cells(&self) -> impl Iterator<Item = Cell> + '_;
    pub fn bulk_volume(&self) -> f64;                 // gross rock volume, m³
    pub fn properties(&self) -> &Properties;
    pub fn properties_mut(&mut self) -> &mut Properties;
    pub fn layers(&self) -> &[KLayer];
}

pub struct Cell { pub ijk: Ijk, pub corners: [Point3; 8] }
impl Cell {
    pub fn volume(&self) -> f64;
    pub fn centroid(&self) -> Point3;
    pub fn top_depth(&self) -> f64;                   // mean of 4 top corners
    pub fn bottom_depth(&self) -> f64;                // mean of 4 bottom corners
    pub fn dz(&self) -> f64;
}

pub struct Property { pub name: String, pub values: Vec<f64> }
impl Property { pub fn constant(name: impl Into<String>, value: f64, cell_count: usize) -> Property; }

pub struct Properties { /* private */ }
impl Properties {
    pub fn new(cell_count: usize) -> Properties;
    pub fn set(&mut self, prop: Property) -> Result<()>;   // errors if len != cell_count
    pub fn get(&self, name: &str) -> Option<&Property>;
    pub fn len(&self) -> usize;
    pub fn is_empty(&self) -> bool;
    pub fn names(&self) -> impl Iterator<Item = &str>;     // unordered
}

pub struct Dims { /* ni, nj, nk */ }  pub struct Ijk { /* i, j, k */ }
pub struct CornerPointGeom { /* pillars + node z's */ }  pub struct Pillar { /* ... */ }
pub struct KLayer { /* ... */ }  pub struct Point3 { pub x: f64, pub y: f64, pub z: f64 }
pub struct Segment { /* ... */ }
pub fn build_box(spec: BoxSpec) -> Result<Grid>;   pub struct BoxSpec { /* ... */ }
pub fn hexahedron_volume(corners: &[Point3; 8]) -> f64;
```

## `srs-wireframe` — the constraining framework

```rust
pub enum Hardness { Hard, Interpolated, Assumed }
pub enum HorizonRole { Top, Base, Intermediate }
pub enum ContactKind { Owc, Goc, Gwc }
pub struct Boundary { pub ring: Vec<[f64; 2]>, pub hardness: Hardness }
pub struct GriddedDepth { pub ncol: usize, pub nrow: usize, pub depth_m: Vec<f64>, pub is_control: Vec<bool> }
pub struct Horizon { pub name: String, pub role: HorizonRole, pub surface: GriddedDepth }
pub struct Contact { pub kind: ContactKind, pub depth_m: f64, pub hardness: Hardness }
pub struct Wireframe { pub boundary: Boundary, pub horizons: Arc<Vec<Horizon>>, pub contacts: Vec<Contact> }  // horizons Arc-shared: realization-invariant, O(1) clone per MC realize (mutate via Arc::make_mut)
impl Wireframe { pub fn from_boundary(boundary: Boundary) -> Wireframe; }
```

## `srs-gridder` — the convergent gridder

```rust
pub struct Control { pub ip: usize, pub jp: usize, pub z: f64 }
pub struct SolveOpts { pub tension: f64, pub omega: f64, pub tol: f64, pub max_iter: usize }
impl Default for SolveOpts { /* tension .25, omega 1.5, tol 1e-6, max_iter 20_000 */ }
pub struct Surface { /* nx, ny, z — private */ }
impl Surface {
    pub fn constant(nx: usize, ny: usize, z: f64) -> Surface;
    pub fn offset_by(&self, dz: f64) -> Surface;      // conformable base at constant thickness
    pub fn offset_by_field(&self, dz_m: &[f64]) -> Result<Surface>;  // per-node offset (row-major jp*nx+ip) — real base relief
    pub fn nx(&self) -> usize;  pub fn ny(&self) -> usize;  pub fn z(&self, ip: usize, jp: usize) -> f64;
    // Order-repair family (SPEC §3 R1/R-c + §4a precedence). `guard_below` /
    // `repair_min_thickness` move the LOWER (base yields, top preserved); the
    // repair-precedence twins `guard_above` / `repair_min_thickness_from_below`
    // move the UPPER up (a derived surface yields to a mapped one).
    pub fn guard_below(&self, top: &Surface, clamp: bool) -> Result<Surface>;
    pub fn repair_min_thickness(&self, top: &Surface, min_thickness_m: f64) -> Result<(Surface, usize, f64)>;
    pub fn guard_above(&self, lower: &Surface, clamp: bool) -> Result<Surface>;
    pub fn repair_min_thickness_from_below(&self, lower: &Surface, min_thickness_m: f64) -> Result<(Surface, usize, f64)>;
}
pub fn solve_surface(nx: usize, ny: usize, controls: &[Control], opts: SolveOpts) -> Result<Surface>;

// Extrapolation policy beyond the data hull (SPEC §3, audit S3): explicit,
// owner-visible; default DecayToData { start_cells: 2, decay_cells: 4 }.
pub enum ExtrapolationPolicy { NaturalDip, DecayToData { start_cells: f64, decay_cells: f64 } }
impl Default for ExtrapolationPolicy { /* DecayToData { 2.0, 4.0 } */ }
impl Surface {
    pub fn taper_beyond_data(&self, controls: &[Control], policy: ExtrapolationPolicy) -> Surface;
}

// The structure-build solve entry (SPEC §3, audit S2): plane detrending (kills
// the kernel's slow affine mode) + fixed-point restarts; hard controls exact.
pub fn solve_surface_converged(nx: usize, ny: usize, controls: &[Control]) -> Result<KernelSurface>;

// The petekTools-kernel-space surface — the ONLY admissible warm-start seed.
// Sources: `flat` (constant field — a fixed point of both kernels, the safe
// bootstrap) or a prior `solve_surface_seeded` output. Deliberately NO
// `From<Surface>`: seeding the warm kernel from the cold solver's output is the
// kernel-space violation (`decision_gridder_kernel_unification`) and is now a
// compile error.
pub struct KernelSurface { /* private Surface */ }
impl KernelSurface {
    pub fn flat(nx: usize, ny: usize, z: f64) -> KernelSurface;
    pub fn surface(&self) -> &Surface;
    pub fn nx(&self) -> usize;  pub fn ny(&self) -> usize;  pub fn z(&self, ip: usize, jp: usize) -> f64;
}
// Warm-start refine (SPEC §7a): delegates the seeded SOR to petekTools'
// ConvergentGridder kernel; ~14x faster than a cold solve for a one-control
// perturbation at 50x50. No SolveOpts — petekTools owns its solver parameters.
pub fn solve_surface_seeded(seed: &KernelSurface, controls: &[Control]) -> Result<KernelSurface>;

// Layering conformity. Proportional honours `nk`; the Follow styles derive `nk`
// from geometry (ceil(max column thickness / dz_m), capped at MAX_NK) and drape
// each layer surface parallel to the top (resp. base) at a constant dz_m, with
// the deep (resp. shallow) layers TRUNCATING against the pinch-out horizon.
// A truncated cell collapses to zero thickness (zero volume — excluded from
// volumetrics, NaN-marked in the view bundles); there is no active-mask array.
pub const MAX_NK: usize = 200;
pub enum Conformity { Proportional, FollowTop { dz_m: f64 }, FollowBase { dz_m: f64 } }
// The layered grid + its report. `grid.dims().nk == nk`. `nk` is dz-derived for
// a Follow style, the passed `nk` for Proportional. `truncated_cells` counts the
// zero-thickness collapses; `nk_capped` = the derived count hit MAX_NK.
pub struct LayeredGrid { pub grid: Grid, pub nk: usize, pub truncated_cells: usize, pub nk_capped: bool }
// `nk` honoured for Proportional, ignored (dz-derived) for the Follow styles.
pub fn layer_grid(top: &Surface, base: &Surface, dx: f64, dy: f64, nk: usize, conf: Conformity) -> Result<LayeredGrid>;
```

## `srs-petro` — log upscaling

```rust
pub struct WeightedSample { pub weight: f64, pub value: f64 }
pub fn arithmetic_mean(s: &[WeightedSample]) -> f64;
pub fn geometric_mean(s: &[WeightedSample]) -> f64;
pub fn harmonic_mean(s: &[WeightedSample]) -> f64;
pub fn power_law_mean(s: &[WeightedSample], p: f64) -> f64;
pub struct NetSample { /* ... */ }  pub struct SwSample { pub length: f64, pub porosity: f64, pub water_saturation: f64 }
pub fn upscale_porosity(samples: &[WeightedSample]) -> Result<f64>;   // length-weighted
pub fn upscale_sw(samples: &[SwSample]) -> Result<f64>;               // pore-volume-weighted
pub fn net_to_gross(samples: &[NetSample]) -> f64;
pub fn perm_bounds(/* ... */) -> (f64, f64);                          // Cardwell-Parsons bracket
```

## `srs-data` — the thin petekIO adapter

```rust
pub mod adapter;    // hardness_of, InputScalar, ModelScalars::from_summary
pub mod logs;       // petro_samples(&[WellCurveInput]) -> Vec<(f64,f64,f64)>  (tvd, φ, Sw)
pub mod wireframe;  // assemble_wireframe(&ModelInputs) -> Result<Wireframe>
pub mod petekio;    // re-export of the real petekio crate (the upstream seam)
```

## `srs-volumetrics` — GRV, in-place, FVF (relocated from petekSim 2026-07-03)

```rust
pub const PORO: &str;  pub const NTG: &str;  pub const SW: &str;   // canonical cube names

pub struct ConstantPriors { pub porosity: f64, pub net_to_gross: f64, pub water_saturation: f64 }
pub fn populate_constant(grid: &mut Grid, priors: ConstantPriors) -> Result<()>;

pub struct ZoneVolumes { pub grv_m3: f64, pub hcpv_m3: f64, pub cells: usize }  // one HC zone
pub struct InPlace {
    pub grv_m3: f64, pub hcpv_m3: f64,             // whole HC column (gas+oil)
    pub cells_in_column: usize, pub per_cell_hcpv: Vec<f64>,
    pub gas: Option<ZoneVolumes>,                    // Some only for a two-contact column
    pub oil: Option<ZoneVolumes>,
}
impl InPlace {                                        // volumes m³ internal; SI reporting scales
    pub fn grv_mcm(&self) -> f64;                     // GRV in mcm (1e6 m³)
    pub fn ooip_sm3(&self, boi: OilFvf) -> f64;       // whole column, Sm³
    pub fn oil_msm3(&self, boi: OilFvf) -> f64;       // whole column, MSm³ (oil report scale)
    pub fn ogip_sm3(&self, bgi: GasFvf) -> f64;       // whole column, Sm³
    pub fn gas_bcm(&self, bgi: GasFvf) -> f64;        // whole column, bcm (1e9 Sm³, gas report scale)
    pub fn gas_zone_ogip_sm3(&self, bgi: GasFvf) -> f64;   // gas cap only, Sm³ (0 if none)
    pub fn oil_zone_ooip_sm3(&self, boi: OilFvf) -> f64;   // oil leg only, Sm³ (0 if none)
}
pub fn compute_in_place(grid: &Grid, contact_depth_m: f64) -> Result<InPlace>;   // single contact
pub fn compute_in_place_summary(grid: &Grid, contact_depth_m: f64) -> Result<InPlace>;  // V7: same aggregates, no per-cell HCPV cube
// Two-contact (gas cap + oil rim): partitions cells gas (z<GOC) / oil
// (GOC<=z<OWC) / water. Geometry + per-zone in-place ONLY — no PVT (solution
// gas / gas-cap expansion stay in petekSim). Err if GOC deeper than OWC.
pub fn compute_in_place_two_contact(grid: &Grid, goc_m: f64, owc_m: f64, sw_gas: Option<f64>) -> Result<InPlace>;  // sw_gas: gas-cap connate-water override (R3)
pub fn compute_in_place_two_contact_summary(grid: &Grid, goc_m: f64, owc_m: f64, sw_gas: Option<f64>) -> Result<InPlace>;  // V7 summary

// FVF value types — FVF crosses the layer seam as a validated scalar INPUT
// (no PVT code); petekSim's srs-pvt keeps its own copies for the dynamic work.
// Boi/Bgi are dimensionless Rm³/Sm³ (== legacy rb/STB, rcf/scf numerically — a relabel).
pub struct OilFvf;  impl OilFvf { pub fn new(rm3_per_sm3: f64) -> Result<OilFvf>; pub fn value(self) -> f64; }   // finite, >= 1
pub struct GasFvf;  impl GasFvf { pub fn new(rm3_per_sm3: f64) -> Result<GasFvf>; pub fn value(self) -> f64; }  // finite, in (0,1)

// Physical-range predicates (H2): typed errors, never silent garbage.
pub fn validate_fraction(what: &str, x: f64) -> Result<()>;   // finite, in [0,1]
pub fn validate_positive(what: &str, x: f64) -> Result<()>;   // finite, > 0
```

## `srs-uncertainty` — the Monte Carlo toolkit (relocated from petekSim 2026-07-03)

```rust
pub enum Distribution {
    Constant(f64),
    Uniform { min: f64, max: f64 },
    Triangular { min: f64, mode: f64, max: f64 },
    Normal { mean: f64, sd: f64 },
    Lognormal { mu: f64, sigma: f64 },
}
impl Distribution {
    pub fn uniform(min: f64, max: f64) -> Result<Distribution>;          // validated ctors
    pub fn triangular(min: f64, mode: f64, max: f64) -> Result<Distribution>;
    pub fn normal(mean: f64, sd: f64) -> Result<Distribution>;
    pub fn lognormal(mu: f64, sigma: f64) -> Result<Distribution>;
    pub fn quantile(self, u: f64) -> f64;                                // inverse CDF
    pub fn sample(self, rng: &mut SplitMix64) -> f64;
}

pub struct SplitMix64;  impl SplitMix64 { pub fn new(seed: u64) -> SplitMix64; pub fn next_u64(&mut self) -> u64; pub fn next_f64(&mut self) -> f64; }
pub fn inverse_normal_cdf(p: f64) -> f64;                                // Acklam probit

pub struct Realizations { pub values: Vec<f64> }
impl Realizations { pub fn summary(&self) -> Result<PercentileSummary>; }   // Err on empty (H1)
pub fn run<F: FnMut(&mut SplitMix64) -> f64>(n: usize, seed: u64, trial: F) -> Realizations;

pub struct PercentileSummary { pub p90: f64, pub p50: f64, pub p10: f64, pub mean: f64 }
impl PercentileSummary {
    pub fn from_realizations(values: &[f64]) -> Result<PercentileSummary>;  // Err on empty (H1)
    pub fn swanson_mean(&self) -> f64;   // delegates the P90/P50/P10 digest + type-7 percentile to petektools::sampling::reservoir_summary
}
```

## `srs-model` — the `StaticModel` + the regeneration seam (LANDED 2026-07-03)

The ratified headline contract (`decision_staticmodel_regen_seam` + amendments;
`decision_layer_charters`). Re-exports the volumetrics/P-curve output surface:
`ConstantPriors`, `InPlace`, `ZoneVolumes`, `OilFvf`, `GasFvf`,
`PercentileSummary`.

### The aggregate

```rust
/// A populated static reservoir model. Owned, Clone, Send (compile-checked);
/// construction guarantees the invariants (SPEC §2) — consumers never re-validate.
pub struct StaticModel { /* framework, grid, zones, provenance — all private */ }
impl StaticModel {
    // --- read-only accessors (SPEC §7c) ---
    pub fn grid(&self) -> &Grid;
    pub fn framework(&self) -> &Wireframe;
    pub fn contacts(&self) -> &[Contact];
    pub fn property(&self, name: &str) -> Option<&Property>;
    pub fn property_names(&self) -> Vec<&str>;
    pub fn zones(&self) -> &ZoneTable;
    pub fn provenance(&self) -> &Provenance;
    pub fn georef(&self) -> Option<Georef>;              // registered WORLD frame (None = local degenerate: synthetic square/box)
    pub fn bulk_volume(&self) -> f64;                    // pure geometry (whole grid)
    // --- the volumetrics output surface (the model owns volumes) ---
    // Clipped vs the model's contact(s). A GOC + lower (OWC/FWL/GWC) contact
    // auto-returns the gas-cap + oil-rim split (InPlace::gas / ::oil); a lone
    // contact stays a generic column.
    pub fn in_place(&self) -> Result<InPlace>;
    pub fn in_place_summary(&self) -> Result<InPlace>;  // V7: same aggregates, per_cell_hcpv empty (MC hot path)
    // --- multi-zone surface (SPEC §4a; from_horizon_stack) ---
    pub fn in_place_by_zone(&self) -> Result<ZonedInPlace>;   // per-zone in-place vs EACH zone's contacts + rollup total (sum == total)
    pub fn zone_stats(&self, property: &str) -> Result<Vec<ZoneStat>>;  // per-zone count/mean/min/max over active cells
}
pub struct ZoneInPlace { pub zone: String, pub in_place: InPlace }
pub struct ZonedInPlace { pub zones: Vec<ZoneInPlace>, pub total: InPlace }  // total = summary rollup (per_cell_hcpv empty)
pub struct ZoneStat { pub zone: String, pub count: usize, pub mean: f64, pub min: f64, pub max: f64 }  // NaN aggregates when count==0
```

### Zones (SPEC §4)

```rust
pub struct Zone { pub name: String, pub color: Option<String>, pub top_horizon: String, pub base_horizon: String, pub k_range: std::ops::Range<usize> }  // color: viewer hint from StackZone.color
pub struct ZoneTable { /* ordered top→base; k-ranges partition [0, nk) */ }
impl ZoneTable {
    pub fn single(nk: usize) -> ZoneTable;                                          // whole-column degenerate zone
    pub fn from_stack(horizon_names: &[String], zone_names: &[String], zone_colors: &[Option<String>], per_zone_nk: &[usize]) -> ZoneTable;  // real multi-zone
    pub fn zones(&self) -> &[Zone];
    pub fn get(&self, name: &str) -> Option<&Zone>;
}
```

### Multi-zone horizon stack (SPEC §4a; `task_petekstatic_multizone`)

```rust
pub struct Pick { pub ip: usize, pub jp: usize, pub depth_m: f64 }                  // a well top on the node lattice
pub struct WorldPoint { pub x: f64, pub y: f64, pub depth_m: f64 }                  // a raw scatter obs in WORLD coords (positive-down depth)
pub enum HorizonSource {
    Scatter(Vec<WorldPoint>),                    // RAW world-coord scatter — the engine grids it (bilinear-conditioned, voids left NaN); only via from_scatter_stack
    Mapped(GriddedDepth),                        // a mapped surface ALREADY on the lattice — bypasses the engine's solve/conditioning fidelity (loaded grids only; raw points belong in Scatter)
    TopsOnly(Vec<Pick>),                         // no surface — draped conformally from the nearest mapped horizon above at pick-controlled thickness
}
pub struct StackHorizon { pub name: String, pub source: HorizonSource }
pub struct StackZone { pub name: String, pub color: Option<String>, pub conformity: Conformity, pub nk: usize, pub contacts: Vec<Contact> }  // name+optional colour (folds the old zone_names) + per-zone layering + its OWN contacts (empty = contactless)
impl StackZone {
    pub fn new(name: impl Into<String>, conformity: Conformity, nk: usize, contacts: Vec<Contact>) -> StackZone;  // colour = None
    pub fn with_color(self, color: impl Into<String>) -> StackZone;                  // viewer hint -> section bundle zones[{name,color}]
}
pub struct HorizonStack {                        // N horizons top→down -> N-1 zones
    pub horizons: Vec<StackHorizon>,             // first (top) must be Mapped (or Scatter, via from_scatter_stack)
    pub zone_layers: Vec<StackZone>,             // N-1 (each carries its own name/colour/layering/contacts)
}
// The areal lattice + world frame a Scatter stack conditions onto (from_scatter_stack).
pub struct StackFrame { pub ni: usize, pub nj: usize, pub georef: Georef }          // ni = √area_m2 / georef.spacing_x
// P8 per-horizon well tie: world (x,y) marker on control node (ip,jp) + measured tops per horizon.
pub struct WellTie { pub id: String, pub x: f64, pub y: f64, pub ip: usize, pub jp: usize, pub tops: Vec<(String, f64)> }
impl WellTie {
    pub fn new(id: impl Into<String>, x: f64, y: f64, ip: usize, jp: usize) -> WellTie;
    pub fn with_top(self, horizon: impl Into<String>, depth_m: f64) -> Self;   // measured formation top (positive-down)
}
// Builder entry: StaticModelBuilder::from_horizon_stack(HorizonStack, BuildOpts) — see below.

// srs-gridder: the stacked layering primitive.
pub struct ZoneLayerSpec { pub conformity: Conformity, pub requested_nk: usize }
pub struct StackedZone { pub nk: usize, pub k_start: usize, pub conformity: Conformity, pub truncated_cells: usize, pub collapsed_cells: usize }
pub struct StackedLayeredGrid { pub grid: Grid, pub nk: usize, pub zones: Vec<StackedZone>, pub truncated_cells: usize, pub collapsed_cells: usize, pub nk_capped: bool }
pub fn layer_grid_stack(surfaces: &[&Surface], dx: f64, dy: f64, zone_specs: &[ZoneLayerSpec], collapse_below_m: Option<f64>) -> Result<StackedLayeredGrid>;
// Buffer-recycling core of layer_grid_stack (the realize_into hot path): fills the caller's
// COORD + ZCORN buffers + reusable scratch in place (all cleared + fully overwritten).
pub struct LayerScratch { /* private raw/snap columns; ::new() */ }
pub struct StackLayering { pub dims: Dims, pub nk: usize, pub zones: Vec<StackedZone>, pub truncated_cells: usize, pub collapsed_cells: usize, pub nk_capped: bool }
pub fn layer_grid_stack_into(surfaces: &[&Surface], dx: f64, dy: f64, zone_specs: &[ZoneLayerSpec], collapse_below_m: Option<f64>, scratch: &mut LayerScratch, coord: &mut Vec<Pillar>, zcorn: &mut Vec<f64>) -> Result<StackLayering>;
// srs-grid recycling primitives: Grid::{take_geometry_buffers, install_geometry}, Properties::take_values, CornerPointGeom::{take_buffers, install}.
```

### Provenance (SPEC §6)

```rust
pub enum PopulationMode { Priors, Logs }
pub enum BuildWarning {                          // non-blocking build advisory
    UnusedHorizon { name: String, role: HorizonRole, reason: String },
    ThinColumnsRepaired { columns: usize, worst_m: f64 }, // R-c: with_min_thickness_m pulled thin/crossing base columns to the floor
    LayersTruncated { cells: usize },            // Follow conformity collapsed `cells` deep/shallow layers to zero thickness (informational; volume-invariant)
    CellsCollapsed { cells: usize },             // with_collapse_below_m: sub-threshold cells merged into a thicker zone-interior neighbour (volume-conserving)
    LayerCountCapped { nk: usize },              // dz-derived nk hit MAX_NK — thickest columns' deepest part not layered (coarsen dz or accept)
}
pub struct Provenance {
    pub inputs_ref: String,
    pub solve_opts: SolveOpts,
    pub conformity: Conformity,
    pub nk: usize,                              // EFFECTIVE nk (dz-derived under a Follow conformity; total across zones on the stack path)
    pub population: PopulationMode,
    pub realization: Option<RealizationDraw>,   // Some(_) for an MC realization
    pub warnings: Vec<BuildWarning>,            // e.g. supplied horizons unused by the build
    pub property_reports: Vec<PropertyReport>,  // P5: one per attached PropertyPipeline (upscale QC + propagated)
    pub stack: Option<StackProvenance>,         // the multi-zone stack record (from_horizon_stack); None on the Top+Base path
    pub well_ties: Vec<WellTieRecord>,          // P8: per-horizon tie residuals (with_well_ties); empty otherwise
}
// Multi-zone build record (SPEC §4a):
pub struct ZoneProvenance { pub name: String, pub top_horizon: String, pub base_horizon: String, pub conformity: Conformity, pub nk: usize, pub k_start: usize, pub truncated_cells: usize }
pub struct InterfaceRepair { pub interface: usize, pub columns: usize, pub worst_m: f64 }
// P8 per-horizon well ties (residual = measured_depth_m − model_depth_m at the well node, pre-tie):
pub struct HorizonTieResidual { pub horizon: String, pub measured_depth_m: f64, pub model_depth_m: f64, pub residual_m: f64 }
pub struct WellTieRecord { pub id: String, pub x: f64, pub y: f64, pub ip: usize, pub jp: usize, pub residuals: Vec<HorizonTieResidual> }
pub struct StackProvenance { pub horizons: Vec<String>, pub zones: Vec<ZoneProvenance>, pub interface_repairs: Vec<InterfaceRepair> }
```

### Per-property geostatistical pipeline (SPEC §3c; P5)

```rust
// A positioned well log for one property: world (x, y) + (tvd_m, value) samples.
pub struct WellLog { pub x: f64, pub y: f64, pub samples: Vec<(f64, f64)> }
impl WellLog { pub fn new(x: f64, y: f64, samples: Vec<(f64, f64)>) -> WellLog; }

pub enum UpscaleMethod { Arithmetic, Harmonic, Geometric }   // in-cell log average

// Monte-Carlo behaviour across realizations (decision_mc_composition).
// LevelShift adds the drawn shift to the cached pattern per cell; the fraction
// cubes PORO/NTG/SW are shift-then-clamped to [0,1] so boundary cells (NTG=0,
// SW=1) saturate instead of escaping range and tripping the per-cell H2 check
// (F9). H2 still validates the drawn INPUTS at the seam.
pub enum McMode { LevelShift /* default */, Resimulate }

// The SGS propagation spec (`ps.gaussian(...)`): data-space variogram + seed +
// optional moving-neighbourhood search + optional collocated trend cokriging.
pub struct Gaussian { /* private */ }
impl Gaussian {
    pub fn new(variogram: petektools::Variogram, seed: u64) -> Gaussian;
    // DEFAULT search is BOUNDED: 16 nodes within max(1.5*variogram.range, 4*node_spacing).
    // (Old whole-grid default -> >15 min/cube on real lattices; bounded matches it within
    // simulation tolerance since beyond-range kriging weight is ~0.)
    pub fn with_search(self, max_neighbours: usize, radius_m: f64) -> Self;
    pub fn with_unbounded_search(self) -> Self;   // opt back into the legacy whole-grid window
    pub fn allow_mean_fill(self) -> Self;         // opt into filling a data-less simulated layer with the
                                                  // conditioned mean; DEFAULT is a hard InvalidInput naming
                                                  // the property (+ zone, via the caller) — no silent constant fill
    pub fn with_trend(self, trend: TrendSurface, corr: f64) -> Self;   // collocated (Markov-1); corr=0 == plain SGS
    // A world-georeferenced trend is resampled at each column's WORLD position via the
    // MODEL georef (build with with_georef); on a local model it stays local/index-space.
    // A secondary covering < 50% of the model frame (a georef mismatch) is a hard
    // InvalidInput, NOT a silent per-node fallback to plain SGS.
}

// Model a property ONE AT A TIME: upscale logs -> per-cell conditioned values,
// then SGS-propagate (per k-layer) conditioned on those cells.
pub struct PropertyPipeline { /* private */ }
impl PropertyPipeline {
    pub fn new(name: impl Into<String>) -> PropertyPipeline;
    pub fn upscale(self, wells: Vec<WellLog>, method: UpscaleMethod) -> Self;
    pub fn propagate(self, gaussian: Gaussian) -> Self;
    pub fn name(&self) -> &str;
    // The visible upscale step: per-cell conditioned field (NaN where no log) + QC.
    // Each sample is binned against the cell interval interpolated AT THE WELL's (x,y)
    // (bilinear), not the column-centroid mean — so an off-centroid well on a dipping
    // zone boundary assigns its samples to the zone their depth truly falls in.
    pub fn upscale_cells(&self, grid: &Grid) -> Result<(Vec<f64>, UpscaleQc)>;
    // Full run against a grid (upscale + propagate), setting the cube.
    pub fn apply(&self, grid: &mut Grid) -> Result<PropertyReport>;
    // P8: run restricted to one zone's k-range, MERGING into the cube already on the
    // grid (other zones' slices untouched). apply_in_zone(0..nk) == apply().
    pub fn apply_in_zone(&self, grid: &mut Grid, k_range: Range<usize>) -> Result<PropertyReport>;
}

pub struct UpscaleQc {                              // upscaled-vs-log QC (Clone, Debug, PartialEq)
    pub property: String,
    pub conditioned_cells: usize, pub log_samples: usize,
    pub log_mean: f64, pub upscaled_mean: f64, pub upscaled_min: f64, pub upscaled_max: f64,
}
pub struct PropertyReport { pub property: String, pub upscale: UpscaleQc, pub propagated: bool }
```

> Regular / axis-aligned column lattices (the `layer_grid` box/conformable grids);
> rotated pillars are future work. Supersedes the interim `TrendSurface` multiplier
> hook below (which now only serves the deterministic `with_areal_trend` path and,
> via the shared `petektools::resample` kernel, the collocated-secondary resample).

### Areal trend (external-drift-lite; INTERIM)

> **INTERIM mechanism** (coordinator design `python-model-build-api.md`): this
> multiplier hook is the stop-gap trend conditioning until collocated cokriging
> inside per-property propagation lands (P5
> `task_petekstatic_property_modelling`), which supersedes it. Expect the
> surface to be re-ratified then.

```rust
// A gridded areal multiplier field: nearest-node resampled to the model column
// lattice, mean-normalized (field-mean preserved), applied per-column to NTG
// (and PORO if flagged) after population — lateral SHAPE only. NaN node -> 1.0.
pub struct TrendSurface { /* ncol, nrow, values, apply_porosity, georef — private */ }
impl TrendSurface {
    pub fn new(ncol: usize, nrow: usize, values: Vec<f64>) -> Result<TrendSurface>;  // finite >= 0; NaN ok
    pub fn with_porosity(self) -> Self;              // also modulate PORO (NTG always)
    pub fn with_georef(self, origin_x: f64, origin_y: f64, node_dx: f64, node_dy: f64) -> Self;  // R4: resample by world (x,y), not index fraction
    pub fn is_georeferenced(&self) -> bool;
    pub fn applies_to_porosity(&self) -> bool;
}
```

### Construction — deterministic builder + the regeneration template

```rust
pub type PetroSample = (f64, f64, f64);   // (tvd_m, φ, Sw) — population input

pub struct BuildOpts {
    pub area_m2: f64, pub gross_height_m: f64,
    pub nk: usize, pub conformity: Conformity, pub solve_opts: SolveOpts, // nk honoured by Proportional only; Follow styles derive it from conformity dz_m
    pub priors: ConstantPriors,
}

// THE ONE declarative build configuration (task_petekstatic_spec_mirror), consumed
// by BOTH StaticModelBuilder and StaticModelTemplate: every with_* setter on either
// is thin sugar mutating an internal BuildSpec; with_spec installs a whole one.
// #[non_exhaustive] + #[serde(default)] (additive forward-compat — the per-zone
// isochore structural leg lands as a new optional field). Serde round-trips as a
// scenario file; compares by value. The WHOLE config layer derives serde
// (HorizonStack family incl. Scatter/WorldPoint, WellTie, Pick, BuildOpts, Georef,
// RealizationDraw, StructuralPerturbation, ZoneDraw, BuildSpec, TieSettings,
// McSettings + the srs-wireframe/-gridder/-volumetrics value types they carry).
// McInputs is EXCLUDED until petekTools' Sampler/Clamped derive serde (queued).
#[non_exhaustive]
pub struct BuildSpec {
    pub inputs_ref: Option<String>,              // None = the entry default ("flat-box"/"wireframe"/"horizon-stack")
    pub georef: Option<Georef>, pub boundary: Option<Vec<[f64; 2]>>,
    pub extrapolation: ExtrapolationPolicy,
    pub clamp_base_to_top: bool, pub min_thickness_m: Option<f64>, pub collapse_below_m: Option<f64>,
    pub sugar_cube: bool, pub sw_gas: Option<f64>,
    pub well_ties: Vec<WellTie>, pub ties: TieSettings,
}
impl BuildSpec {   // Default = every knob at the historical default; with_* sugar mirrors the consumers'
    pub fn new() -> BuildSpec;
    pub fn with_inputs_ref(self, r: impl Into<String>) -> Self;  pub fn with_georef(self, ox: f64, oy: f64, sx: f64, sy: f64) -> Self;
    pub fn with_boundary(self, ring: Vec<[f64; 2]>) -> Self;     pub fn with_extrapolation(self, p: ExtrapolationPolicy) -> Self;
    pub fn with_clamp_base_to_top(self, c: bool) -> Self;        pub fn with_min_thickness_m(self, m: f64) -> Self;
    pub fn with_collapse_below_m(self, m: f64) -> Self;          pub fn with_sugar_cube(self, s: bool) -> Self;
    pub fn with_sw_gas(self, s: f64) -> Self;                    pub fn with_well_ties(self, t: Vec<WellTie>) -> Self;
    pub fn with_tie_settings(self, t: TieSettings) -> Self;
}

// How well ties fold into the mapped datum grids (the settings mirror over the
// datum-substitution tie machinery). Default = Replace (today's behaviour).
pub struct TieSettings { pub method: TieMethod }
impl TieSettings { pub fn replace() -> Self;  pub fn radius(radius_m: f64) -> Self; }
pub enum TieMethod {
    Replace,                       // measured top REPLACES the map datum at the tie node; radius 0 on a dense lattice
    Radius { radius_m: f64 },      // bounded locality: residual decays linearly 1→0 over radius_m across DEFINED datums; beyond = bit-untouched; voids stay the solve's to taper; finite>0 or typed error
}

/// Deterministic single-shot build — the relocated RefiningModel pipeline.
pub struct StaticModelBuilder { /* ... */ }
impl StaticModelBuilder {
    pub fn flat(ni: usize, nj: usize, top_depth_m: f64, contact_depth_m: f64, opts: BuildOpts) -> Result<StaticModelBuilder>;
    pub fn from_wireframe(wf: &Wireframe, opts: BuildOpts) -> Result<StaticModelBuilder>;  // wires a Base horizon's real relief; else gross_height_m offset
    pub fn from_horizon_stack(stack: HorizonStack, opts: BuildOpts) -> Result<StaticModelBuilder>;  // SPEC §4a: N horizons -> N-1 zones (per-zone conformity + contacts); opts.nk/conformity/gross_height_m unused here
    pub fn from_scatter_stack(stack: HorizonStack, opts: BuildOpts, frame: StackFrame) -> Result<StaticModelBuilder>;  // SPEC §4a: raw-scatter horizons — the engine conditions them onto `frame` (bilinear, voids NaN) then resolves as from_horizon_stack; registers frame.georef. THE single scatter-gridding authority
    pub fn condition_scatter_stack(stack: HorizonStack, frame: &StackFrame) -> Result<HorizonStack>;  // dedup seam (task_suite_scatter_perf): condition raw scatter ONCE -> the conditioned all-Mapped handle; feed to BOTH from_horizon_stack(+with_georef) and StaticModelTemplate::from_horizon_stack(+with_georef) to build a model + its MC template without re-solving the cold bilinear conditioning. Bit-identical to conditioning inside from_scatter_stack
    pub fn with_collapse_below_m(self, collapse_below_m: f64) -> Self;        // Petrel-style cell-collapse: sub-threshold cells merge volume-conservingly into a thicker zone-interior neighbour; warns CellsCollapsed
    pub fn with_logs(self, samples: Vec<PetroSample>) -> Self;
    pub fn with_areal_trend(self, trend: TrendSurface) -> Self;               // INTERIM lateral NTG (+φ) shape
    pub fn with_property(self, pipeline: PropertyPipeline) -> Self;           // P5: per-property upscale + SGS, overwriting the cube (-> Provenance.property_reports)
    pub fn with_zone_property(self, zone: impl Into<String>, pipeline: PropertyPipeline) -> Self;  // P8: per-zone upscale+SGS restricted to the zone's k-range (own variogram/trend/logs); unknown zone = build-time InvalidInput
    pub fn with_zone_priors(self, zone: impl Into<String>, priors: ConstantPriors) -> Self;        // P8: override PORO/NTG/SW across one zone's k-range (per-zone level); unknown zone = build-time InvalidInput
    pub fn with_well_ties(self, ties: Vec<WellTie>) -> Self;                  // P8: per-horizon ties per the spec's TieSettings (Replace default / Radius bounded locality) + re-run the stack resolution; pre-tie residuals -> Provenance.well_ties + map wells[].ties (from_horizon_stack)
    pub fn with_tie_settings(self, ties: TieSettings) -> Self;                 // how the ties fold in; read (with the ties) at build() time — order vs with_well_ties irrelevant here
    pub fn with_clamp_base_to_top(self, clamp: bool) -> Self;                 // R1: clamp a crossed base to zero gross instead of erroring (default: error)
    pub fn with_min_thickness_m(self, min_thickness_m: f64) -> Self;          // R-c: post-gridding repair — pull thin/crossing base to top+min (top preserved); warns ThinColumnsRepaired; precedence over clamp
    pub fn with_extrapolation(self, policy: ExtrapolationPolicy) -> Self;      // stack path: beyond-data behaviour; default DecayToData (never silent unbounded natural-dip)
    pub fn with_sw_gas(self, sw_gas: f64) -> Self;                            // R3: gas-cap connate water for a two-contact in_place split
    pub fn with_inputs_ref(self, inputs_ref: impl Into<String>) -> Self;
    pub fn with_georef(self, origin_x: f64, origin_y: f64, spacing_x: f64, spacing_y: f64) -> Self;  // register the model's WORLD frame (column (0,0) centroid + spacing) so view bundles emit ONE world frame; non-positive spacing ignored; grid geometry untouched
    pub fn with_boundary(self, ring: Vec<[f64; 2]>) -> Self;                  // from_horizon_stack: world outline ring for the map bundle; omitted -> a world-extent rectangle from the georef (never the unit square vs a world frame)
    pub fn with_sugar_cube(self, sugar_cube: bool) -> Self;                   // section rendering: false (default) = dip-following trapezoids; true = flat boxes -> IntersectionBundle.sugar_cube + flattened edge arrays
    pub fn with_spec(self, spec: BuildSpec) -> Self;                          // install the WHOLE declarative config in one call (the with_* above are the same sugar); bit-identical results, pinned by tests/spec_conformance.rs
    pub fn add_top_control(&mut self, ip: usize, jp: usize, depth_m: f64);   // live refine
    pub fn control_count(&self) -> usize;
    pub fn build(&self) -> Result<StaticModel>;          // warm kernel space (unified with the template, R2); base-above-top → CrossedSurfaces
}

/// The MC-regeneration template (SPEC §7a): built once, holds the reusable
/// warm-start state IN petekTools KERNEL SPACE. Send; one per worker.
pub struct StaticModelTemplate { /* ... */ }
impl StaticModelTemplate {
    // A Base horizon in `wf` is extracted as the nominal gross field g(x,y)
    // (decision_template_gross_scaling): per draw, gross = g * gross_height_m
    // / mean(g) — draw sets the LEVEL, g the SHAPE; a draw at mean(g)
    // reproduces the deterministic build on ALL column shapes (R2: builder +
    // template share one warm kernel path). No Base = constant offset (as before).
    pub fn new(wf: &Wireframe, opts: BuildOpts) -> Result<StaticModelTemplate>;
    // P8 stack-aware MC (task_petekstatic_multizone_2): resolves the multi-horizon
    // framework ONCE (surfaces are draw-invariant); realize varies per draw only the
    // areal footprint (spacing), the per-zone contacts, and the per-zone property
    // levels (RealizationDraw.zones) — bit-deterministic, realize_into-recyclable.
    pub fn from_horizon_stack(stack: HorizonStack, opts: BuildOpts) -> Result<StaticModelTemplate>;
    pub fn from_scatter_stack(stack: HorizonStack, opts: BuildOpts, frame: StackFrame) -> Result<StaticModelTemplate>;  // raw-scatter MC template — conditions + resolves scatter byte-identically to StaticModelBuilder::from_scatter_stack
    pub fn with_logs(self, samples: Vec<PetroSample>) -> Self;
    pub fn with_areal_trend(self, trend: TrendSurface) -> Self;   // INTERIM per-realization lateral shape
    // P5 property pipelines per realization (decision_mc_composition):
    pub fn with_property(self, pipeline: PropertyPipeline) -> Self;               // default McMode::LevelShift
    pub fn with_property_mode(self, pipeline: PropertyPipeline, mode: McMode) -> Self;
    // Zone-scoped pipelines (stack templates): realize honours the with_zone_property
    // upscale+SGS cube over the per-zone priors (level shift on top) — a zero-spread
    // zoned MC reproduces in_place_by_zone on every piped zone. Unknown zone = realize InvalidInput.
    pub fn with_zone_property(self, zone: impl Into<String>, pipeline: PropertyPipeline) -> Self;             // default McMode::LevelShift
    pub fn with_zone_property_mode(self, zone: impl Into<String>, pipeline: PropertyPipeline, mode: McMode) -> Self;
    // Well ties (stack templates): draw-invariant, applied ONCE at construction
    // (re-solve + repair); every draw inherits tied geometry at zero per-draw cost.
    // Err if not stack-aware / bad tie node / unknown horizon.
    pub fn with_well_ties(self, ties: Vec<WellTie>) -> Result<StaticModelTemplate>;
    pub fn with_tie_settings(self, ties: TieSettings) -> Self;    // set BEFORE with_well_ties (ties apply at that call); same tie authority as the builder — node-for-node identical
    pub fn with_sugar_cube(self, sugar_cube: bool) -> Self;       // section rendering (see StaticModelBuilder::with_sugar_cube)
    pub fn with_clamp_base_to_top(self, clamp: bool) -> Self;     // R1: clamp a crossed base per realization instead of erroring
    pub fn with_min_thickness_m(self, min_thickness_m: f64) -> Self;  // R-c: per-realization post-gridding repair to top+min (top preserved); warns ThinColumnsRepaired
    pub fn with_extrapolation(self, policy: ExtrapolationPolicy) -> Result<StaticModelTemplate>;  // stack templates: re-resolve surfaces under the policy (default DecayToData)
    pub fn with_collapse_below_m(self, collapse_below_m: f64) -> Self;  // per-realization cell-collapse (volume-conserving); warns CellsCollapsed
    pub fn with_inputs_ref(self, inputs_ref: impl Into<String>) -> Self;
    pub fn with_georef(self, origin_x: f64, origin_y: f64, spacing_x: f64, spacing_y: f64) -> Self;  // stamp the WORLD frame onto every realized model (see StaticModelBuilder::with_georef)
    pub fn with_boundary(self, ring: Vec<[f64; 2]>) -> Self;  // stack-aware: world outline ring stamped onto each realized map bundle (see StaticModelBuilder::with_boundary)
    pub fn with_spec(self, spec: BuildSpec) -> Result<StaticModelTemplate>;   // install the WHOLE declarative config (the SAME BuildSpec the builder consumes); re-applies extrapolation + ties through the setters above — bit-identical, pinned
    /// The CHEAP per-realization call: warm-started solve, re-layer, re-populate.
    /// `&mut self` advances the warm chain; Result per draw.
    pub fn realize(&mut self, draw: &RealizationDraw) -> Result<StaticModel>;  // = new-empty-then-realize_into (allocating convenience)
    /// The buffer-recycling hot path (ratified amendment 3): realize into a REUSED
    /// model, overwriting its ZCORN + cube allocations in place (~100 MB/draw at 1M
    /// cells). Bit-identical to `realize` on the same draw + chain. Prefer on the MC loop.
    pub fn realize_into(&mut self, draw: &RealizationDraw, model: &mut StaticModel) -> Result<()>;
    /// A fresh empty model to drive a `realize_into` loop against — one per MC worker.
    pub fn reusable_model(&self) -> StaticModel;
}

/// The per-realization sampled input set — petekStatic's neutral type, filled by
/// the sampler. #[non_exhaustive]: construct via ::new + with_*. fvf EXCLUDED
/// (applied at the volumetrics output surface, never rides the draw).
#[non_exhaustive]
pub struct RealizationDraw {                              // Clone + Debug
    pub area_m2: f64, pub gross_height_m: f64, pub contact_depth_m: f64,  // contact = OWC/FWL (lower) when a GOC is set; gross = level multiplier over the template's base-relief shape when a Base horizon exists
    pub goc_depth_m: Option<f64>,                       // Some(_) => two-contact (gas cap + oil rim)
    pub porosity: f64, pub net_to_gross: f64, pub water_saturation: f64,
    pub seed_index: u64,
    pub structural: Option<StructuralPerturbation>,     // MVP explicit control-node depth shifts (2-surface path)
    pub top_structural: Option<PerturbationField>,      // correlated TOP-surface DEPTH field this draw (both paths); decision_structural_uncertainty_isochore
    pub sw_gas: Option<f64>,                             // R3: gas-cap connate-water override (with a GOC)
    pub property_shifts: Vec<(String, f64)>,            // P5: per-property additive level shift for McMode::LevelShift
    pub zones: Vec<ZoneDraw>,                           // P8: per-zone contacts + property levels (stack-aware from_horizon_stack MC); empty on the 2-surface path
}
// P8 per-zone draw for a stack-aware template. A ZoneDraw's contacts REPLACE the
// template's static contacts for that zone (neither GOC nor OWC => explicitly
// contactless: GRV, zero HC). porosity/ntg/sw override the base priors in the zone.
#[non_exhaustive]                                        // additive forward-compat (per-zone isochore leg)
pub struct ZoneDraw {                                    // Clone + Debug + PartialEq + serde
    pub zone: usize,                                     // zone index (top->down)
    pub goc_depth_m: Option<f64>, pub owc_depth_m: Option<f64>,
    pub porosity: Option<f64>, pub net_to_gross: Option<f64>, pub water_saturation: Option<f64>,
    pub isochore_structural: Option<PerturbationField>,  // correlated THICKNESS field for this zone (moves its DEEPER horizon); clamped >=0, zero-masked at exact merges; decision_structural_uncertainty_isochore
}
impl ZoneDraw {
    pub fn new(zone: usize) -> ZoneDraw;                 // no contacts / overrides
    pub fn with_owc(self, owc_depth_m: f64) -> Self;  pub fn with_goc(self, goc_depth_m: f64) -> Self;
    pub fn with_priors(self, porosity: f64, net_to_gross: f64, water_saturation: f64) -> Self;
    pub fn with_isochore_structural(self, field: PerturbationField) -> Self;   // attach this zone's isochore perturbation field
}
impl RealizationDraw {
    pub fn new(area_m2: f64, gross_height_m: f64, contact_depth_m: f64,
               porosity: f64, net_to_gross: f64, water_saturation: f64,
               seed_index: u64) -> RealizationDraw;   // goc None, structural None
    pub fn with_zone_draw(self, zone: ZoneDraw) -> Self;   // P8: attach/replace a per-zone draw (stack-aware MC)
    pub fn with_structural(self, p: StructuralPerturbation) -> Self;
    pub fn with_top_structural(self, field: PerturbationField) -> Self;   // attach the correlated TOP-surface depth perturbation field
    pub fn with_property_shift(self, property: impl Into<String>, delta: f64) -> Self;  // P5 level shift
    pub fn property_shift(&self, property: &str) -> f64;                                // 0.0 if none
    pub fn with_goc(self, goc_depth_m: f64) -> Self;    // gas cap above; must be shallower than the OWC
    pub fn with_sw_gas(self, sw_gas: f64) -> Self;       // R3: gas-cap connate water (effective only with a GOC)
    // named scalar setters (V8/R6 ergonomics) — fluent field-by-field overrides; ::new stays for compat
    pub fn with_area(self, area_m2: f64) -> Self;
    pub fn with_gross(self, gross_height_m: f64) -> Self;
    pub fn with_contact(self, contact_depth_m: f64) -> Self;
    pub fn with_porosity(self, porosity: f64) -> Self;
    pub fn with_ntg(self, net_to_gross: f64) -> Self;
    pub fn with_sw(self, water_saturation: f64) -> Self;
}
pub struct StructuralPerturbation {                       // Clone + Debug + Default
    pub control_shifts: Vec<(usize, usize, f64)>,         // (ip, jp, dz_m)
}

// A correlated structural perturbation FIELD for one horizon/isochore of a draw
// (decision_structural_uncertainty_isochore): an unconditional Gaussian random field
// (petektools sgs_unconditional) with marginal N(0, sd_m^2) and the variogram's
// spatial continuity, generated on the areal node lattice at realize() time and
// added to a TOP DEPTH surface (RealizationDraw.top_structural) or a zone THICKNESS
// (ZoneDraw.isochore_structural). The mean is variogram-INDEPENDENT (every node's
// marginal is N(0,sd^2) regardless of correlation) — only the field's shape/range
// depends on the variogram. Seed = draw.seed_index salted by the horizon index
// (bit-reproducible per seed, independent across horizons); SGS neighbourhood derived
// from the variogram range. sd_m <= 0 = a no-op (zero field). Perturbation is pinned
// to zero at well-tie nodes. Copy + Clone + Debug + PartialEq + serde.
pub struct PerturbationField { pub sd_m: f64, pub variogram: Variogram }
impl PerturbationField { pub fn new(sd_m: f64, variogram: Variogram) -> PerturbationField; }
```

```rust
// Compile-checked (ratified amendment 1):
static_assertions::assert_impl_all!(StaticModelTemplate: Send);
static_assertions::assert_impl_all!(StaticModel: Send);
```

### Structured Monte-Carlo driver (`task_peteksim_mc_structured`, LANDED)

The driver over the template: samples every uncertain input from a petekTools
`Sampler`, realizes each draw, and KEEPS the per-draw output vectors (W17). `boi`
/ `bgi` are sampled like any input but applied at the **volumetrics surface**,
never on the draw (ratified). Re-exports `Sampler` / `Correlation` /
`ReservoirSummary` from petekTools so a caller needs no direct petekTools import.

```rust
pub enum Input { Plain(Sampler), Clamped(Clamped) }   // Copy + Debug; From<Sampler>/From<Clamped>
impl Input {
    pub fn plain(sampler: Sampler) -> Input;
    pub fn clamped(sampler: Sampler, lo: f64, hi: f64) -> Result<Input>;   // petekTools .clamped (hard limiter)
}

pub struct McInputs { /* one Input per quantity; Clone + Debug */
    pub area_m2, gross_height_m, contact_depth_m, porosity, net_to_gross,
        water_saturation, boi: Input,                 // the load-bearing set
    pub goc_depth_m, sw_gas, bgi: Option<Input>,      // two-contact / gas-FVF
    pub property_shifts: Vec<(String, Input)>,        // per McMode::LevelShift property
}
impl McInputs {
    pub fn new(area_m2, gross_height_m, contact_depth_m, porosity,
               net_to_gross, water_saturation, boi: Input) -> McInputs;
    pub fn with_goc(self, Input) -> Self;  pub fn with_sw_gas(self, Input) -> Self;
    pub fn with_bgi(self, Input) -> Self;
    pub fn with_property_shift(self, property: impl Into<String>, shift: Input) -> Self;
    pub fn realize(&self, n: usize, seed: u64) -> Result<RealizedInputs>;   // Err on n == 0
}

pub struct RealizedInputs { /* per-field Vec<f64> of length n; Clone + Debug */ }  // retained for tornado reuse

pub struct McResult { /* Clone + Debug */
    pub oil_sm3: Vec<f64>,     // primary metric — two-contact oil leg, else whole column
    pub gas_sm3: Vec<f64>,     // gas cap of a two-contact run (needs bgi); else 0
    pub grv_m3: Vec<f64>,      // hydrocarbon-column GRV per draw
}
impl McResult {
    pub fn len(&self) -> usize;  pub fn is_empty(&self) -> bool;
    pub fn realized_inputs(&self) -> &RealizedInputs;
    pub fn summary(&self) -> Result<ReservoirSummary>;       // oil P90/P50/P10 (petekTools reservoir_summary)
    pub fn gas_summary(&self) -> Result<ReservoirSummary>;
    pub fn grv_summary(&self) -> Result<ReservoirSummary>;
}

// THE one structured-MC entry (task_petekstatic_spec_mirror) — the run-resources
// settings value consolidating the four historical fns (now DEPRECATED thin
// wrappers, pinned bit-identical by test). workers=1 serial (default); >1 = the
// rayon-sharded driver (sharded == serial contract). spill_dir None = in-core
// (default); Some(dir) = the spilled mode's per-shard reused f32 store under dir
// (pass std::env::temp_dir() for the old spilled(None)). Serde + PartialEq;
// #[non_exhaustive] (construct via ::new + with_*).
#[non_exhaustive]
pub struct McSettings { pub n: usize, pub seed: u64, pub workers: usize, pub spill_dir: Option<PathBuf> }
impl McSettings {
    pub fn new(n: usize, seed: u64) -> McSettings;             // serial, in-core
    pub fn with_workers(self, workers: usize) -> Self;          // clamp [1, n]; default_mc_workers() recommended
    pub fn with_spill_dir(self, dir: impl Into<PathBuf>) -> Self;  // selects the spilled mode
}

/// Seeded, reproducible: same (inputs, settings) -> bit-identical vectors.
/// FAIL-FAST error policy: the first failing draw surfaces as
/// StaticError::McDraw { index, source } (H2 typed error + the draw index).
pub fn run_mc(tmpl: &mut StaticModelTemplate, inputs: &McInputs,
              settings: &McSettings) -> Result<McResult>;

#[deprecated] pub fn run_structured_mc(..., n, seed) -> Result<McResult>;                      // = run_mc(McSettings::new(n, seed))
#[deprecated] pub fn run_structured_mc_parallel(..., n, seed, workers) -> Result<McResult>;    // = ….with_workers(workers)
#[deprecated] pub fn run_structured_mc_spilled(..., n, seed, spill_dir) -> Result<McResult>;   // = ….with_spill_dir(dir | temp_dir())
#[deprecated] pub fn run_structured_mc_parallel_spilled(...) -> Result<McResult>;              // = both

/// Field aggregation over segments' oil vectors, delegating to petekTools
/// `aggregate`: Independent (narrow) vs Comonotonic (wide downside) bracket.
pub fn aggregate_field(segments: &[&McResult], corr: Correlation) -> Vec<f64>;
```

### Tornado sensitivity (`task_peteksim_tornado`, LANDED)

One-at-a-time swings of **oil in-place Sm³**: each input pivoted at the
**realized** lo/hi percentiles (others at P50), re-realized through the template,
pre-sorted by swing descending. Pass the same `(n, seed)` as the MC run to reuse
its exact draws.

```rust
pub struct TornadoBar { pub input: String, pub lo_val, hi_val, out_lo, out_hi, swing: f64 }  // Clone + PartialEq

pub fn tornado(tmpl: &mut StaticModelTemplate, inputs: &McInputs,
               n: usize, seed: u64, lo_pct: f64, hi_pct: f64) -> Result<Vec<TornadoBar>>;
```

---

### Out-of-core — memory budget + spilled `StaticModel` (`task_petekstatic_slab_streaming`; SPEC §10)

The engine chooses its **backing-storage mode** against a declared budget (out-of-core
rulings R2/R3/R4/R5). Below budget → today's in-core path, **byte-identical**. Above
budget → geometry (ZCORN) + cubes spill to a petekTools `store` (f32 lanes, R4; COORD
resident) and the volumetric surface reads through **windowed mmap views**, streaming
one k-slab at a time (f64 accumulation). Loud switch, never OOM, temp cleanup on drop.

> **Crate origin (P10 organize wave):** these types live in the `srs-spill` crate,
> split out of `srs-model` 2026-07-05 (`task_petekstatic_organize`). `srs-model`
> re-exports the whole surface, so every path below resolves unchanged as
> `srs_model::{MemoryBudget, decide_mode, spill_grid, SpillBacking, …}`.

```rust
// The declared budget (default: DEFAULT_BUDGET_FRACTION of physical RAM).
pub const DEFAULT_BUDGET_FRACTION: f64;              // 0.5
pub struct MemoryBudget { /* private */ }            // Copy + Eq
impl MemoryBudget {
    pub fn bytes(limit_bytes: u64) -> Self;
    pub fn fraction_of_physical(fraction: f64) -> Self;   // clamped [0,1]
    pub fn unlimited() -> Self;                            // never spill (force in-core)
    pub fn limit_bytes(&self) -> u64;  pub fn is_unlimited(&self) -> bool;
}
impl Default for MemoryBudget { /* fraction_of_physical(DEFAULT_BUDGET_FRACTION) */ }
pub fn physical_ram_bytes() -> Option<u64>;          // cached; /proc/meminfo | sysctl hw.memsize
pub fn live_set_bytes(dims: Dims, n_cubes: usize) -> u64;   // ZCORN f64 + cubes f64, ×2 (warm)
pub enum BuildMode { InCore, Spilled }               // Copy + Eq
pub fn decide_mode(dims: Dims, n_cubes: usize, budget: MemoryBudget) -> (BuildMode, u64);
pub struct SpillNotice { pub cells: usize, pub budget_bytes: u64,     // the loud advisory (R5)
    pub estimate_bytes: u64, pub store_path: PathBuf }
impl SpillNotice { pub fn warn(&self); }             // → stderr on every spilled build

// Builder / template opt-in (both StaticModelBuilder and — same setters — the flow):
impl StaticModelBuilder {
    pub fn with_memory_budget(self, budget: MemoryBudget) -> Self;   // default MemoryBudget::default()
    pub fn with_spill_dir(self, dir: impl Into<PathBuf>) -> Self;    // default std::env::temp_dir()
    pub fn with_spill_persist(self, persist: bool) -> Self;          // keep the store past drop
}

// StaticModel: spilled is a backing-storage MODE, not a new type. in_place /
// in_place_summary / bulk_volume / in_place_by_zone route to the store when spilled;
// grid() / property() return a placeholder (read a spilled model through its
// volumetric methods). in_place_by_zone streams per zone (zones are contiguous
// k-bands) — v2, item 3. zone_stats stays in-core-only (typed error spilled).
impl StaticModel {
    pub fn is_spilled(&self) -> bool;
    pub fn spill_store_path(&self) -> Option<&Path>;
    pub fn dims(&self) -> Dims;                       // real dims, in-core or spilled
}

// The ONE GRV/HCPV loop (srs-volumetrics, v2 item 2): a SlabSource abstracts the
// two backings (contiguous f64 Grid vs mmap f32 slab); `compute_clipped` is the
// generic streaming core (three monomorphic loops over a Clip). GridSource is the
// in-core backing; SpillBacking::source() is the spilled one. grv.rs's public
// compute_* fns are thin wrappers over this (signatures unchanged).
pub trait SlabSource { fn dims(&self) -> Dims; fn require_cubes(&self) -> Result<()>;
    type Slab<'a>: CellSlab; fn slab(&self, k: usize) -> Result<Self::Slab<'_>>; }
pub trait CellSlab { fn centroid_z(&self, l: usize) -> f64; fn cell_volume(&self, l: usize) -> f64;
    fn poro(&self, l: usize) -> f64; fn ntg(&self, l: usize) -> f64; fn sw(&self, l: usize) -> f64; }
pub enum Clip { Bulk, Single(f64), Two { goc: f64, owc: f64, sw_gas: Option<f64> } }
pub fn compute_clipped<S: SlabSource>(src: &S, clip: Clip, k_range: Range<usize>, per_cell: bool) -> Result<InPlace>;
pub struct GridSource<'a> { /* &Grid + cube slices */ }  impl GridSource { pub fn new(grid: &Grid) -> Self; }

// Spill a built grid to a store (mmap-backed, f32 lanes); Drop removes the file
// unless detached. Used internally by the builder above budget. spill_streaming is
// the ONE store-writing path — the slab-incremental build (v2 item 1) drives it
// with an on-demand ZCORN + constant-cube producer (never a whole in-core grid);
// spill_grid_to rides it too (its producer reads a built grid).
pub fn spill_grid(grid: &Grid, dir: &Path, cleanup: bool) -> Result<SpillBacking>;
pub fn spill_grid_to(grid: &Grid, path: &Path, cleanup: bool) -> Result<SpillBacking>;
pub fn spill_streaming(path: &Path, dims: Dims, coord: &[Pillar], cube_names: &[String], cleanup: bool,
    fill_zcorn: impl FnMut(usize, &mut [f32]) -> Result<()>,
    fill_cube: impl FnMut(&str, usize, &mut [f32]) -> Result<()>) -> Result<SpillBacking>;
pub struct SpillBacking { /* private: Store + resident COORD + dims + cube names */ }
impl SpillBacking {
    pub fn dims(&self) -> Dims;  pub fn store_path(&self) -> &Path;  pub fn detach(&mut self);
    pub fn cube_names(&self) -> &[String];  pub fn bulk_volume(&self) -> Result<f64>;
    pub fn source(&self) -> SpillSource<'_>;          // the spilled SlabSource (→ compute_clipped)
}

// Spilled structured MC (R3/R4) — run_mc with McSettings::with_spill_dir(dir):
// each draw realizes in-core into the ONE reusable model, is flushed to a REUSED
// per-shard store (overwritten per draw — never a new file per draw), and its
// summary streamed from the f32 lanes → discard. Bit-deterministic within the
// spilled mode; sharded == serial at every worker count. The historical
// run_structured_mc_spilled / _parallel_spilled entries are DEPRECATED thin
// wrappers over run_mc (spill_dir None maps to std::env::temp_dir()).
```

> **Parity is tolerance-based** (R4 honesty clause): f32 storage lanes change volumes
> at a small relative bound (measured on a 2500 m fixture: GRV/bulk bit-exact, HCPV
>~2e-9, MC oil ≤ ~3.1e-6 relative — contact-boundary sensitive; asserted ≤ 1e-5).
> **v2 (item 1):** the spilled build is now **slab-incremental** — ZCORN + constant
> cubes stream k-slab-by-k-slab into the store (never a whole in-core grid), so the
> build's heap working set is **O(slab)** and a >RAM model builds. Measured peak RSS
> (3.2M cells): in-core 90 B/cell, build-then-spill 135, **streaming 47** (≈ the f32
> store alone). Eligible: constant/per-zone-constant population, collapse off;
> otherwise build-then-spill. Deferred: streaming SGS-population + writable-store MC
> realize; true O(slab) *resident* RSS awaits a page-evicting store writer
> (`question_petektools_store_page_evict`).

---

### View bundles — the viewer export seam (`view`; SPEC §7e)

Typed inspection bundles for a **separate** viewer codebase
(`decision_viewer_home_product`): petekStatic exports, the viewer renders — it
never computes. The serialized **structure is the contract** — version-tagged and
locked by a schema-snapshot test. `f64::NAN` = undefined (JSON `null`); SI units,
positive-down depth. Bundles **stream** straight to an `io::Write` (no intermediate
`serde_json::Value` tree — the streaming path that removes the legacy ~12.7×-payload
RSS spike): map/section via `write_json`; the volume bundle via its binary-block
envelope writers (below).

```rust
pub const SCHEMA_VERSION: u32 = 5;   // v3: volume exterior-shell + binary blocks
                                     // v4: section HorizonTrace polylines + MapBundle per-well `ties`
                                     // v5: section colour-by-zone — IntersectionBundle.zones + SectionColumn.zone_ids
                                     //     (all additive #[serde(default)] fields; an older decoder ignoring unknown keys still reads v5)
                                     // map/section: plain JSON, carry the family version
impl MapBundle          { pub fn write_json<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()>; }
impl IntersectionBundle { pub fn write_json<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()>; }

// The model's registered WORLD georeference (StaticModelBuilder::with_georef):
// world (x,y) of column (0,0)'s centroid + world column spacing. Some -> the
// view frames are WORLD; None -> the local degenerate frame (synthetic square/box).
pub struct Georef { pub origin_x, origin_y, spacing_x, spacing_y: f64 }
impl Georef { pub fn new(origin_x, origin_y, spacing_x, spacing_y: f64) -> Option<Georef>; }  // None if spacing not finite-positive

// Shared areal georeference: world (x,y) of node (i,j) = (origin + i*spacing, ...).
// origin/spacing come from the model's Georef when registered (WORLD frame — the
// raster overlays the world outline/wells and a world fence/bore section traces
// through it); otherwise from the grid's local column-centroid lattice.
pub struct GridFrame { pub origin_x, origin_y, spacing_x, spacing_y: f64,
                       pub ncol: usize, pub nrow: usize }   // ncol=ni, nrow=nj
pub struct ValueRange { pub min: f64, pub max: f64 }        // over finite entries (legend)
// A named georeferenced field on the shared frame, row-major values[j*ncol + i].
pub struct ScalarLayer { pub name: String, pub units: String,
                         pub values: Vec<f64>, pub range: ValueRange }
```

**`MapBundle` — `model.map_bundle(&MapSpec) -> Result<MapBundle>`** (areal / plan-view)

```rust
pub struct MapSpec { /* private */ }
impl MapSpec {
    pub fn new() -> Self;                        // structural surfaces + outline + contacts only
    pub fn property(self, name: impl Into<String>) -> Self;  // + zone-average map(s) for this cube
    pub fn k_slice(self, k: usize) -> Self;      // also emit a single-k-slice map per property
}

pub struct WellTieResidual { pub horizon: String, pub residual_m: f64 }                              // v4: one per horizon tied
pub struct WellMarker { pub id: String, pub x, y: f64, pub tie_residual_m: Option<f64>,              // tie_residual_m = mean of ties (None if no ties)
                        pub ties: Vec<WellTieResidual> }                                             // v4: per-horizon residuals (from Provenance.well_ties; empty if none)
pub struct ContactMask { pub kind: String, pub depth_m: f64, pub crossing: Vec<bool> }  // row-major j*ncol+i

pub struct MapBundle {
    pub schema_version: u32,
    pub inputs_ref: String,               // provenance identity
    pub frame: GridFrame,                 // shared by every areal layer below
    pub outline: Vec<Vec<[f64; 2]>>,      // boundary ring(s), world [x,y]
    pub horizons: Vec<ScalarLayer>,       // realized top + base depth surfaces (units "m")
    pub zone_averages: Vec<ScalarLayer>,  // name "{property}::{zone}"  (the useful default)
    pub k_slices: Vec<ScalarLayer>,       // name "{property}::k{n}"    (empty unless k_slice set)
    pub wells: Vec<WellMarker>,           // per-horizon tie residuals (from with_well_ties); empty if no ties
    pub contacts: Vec<ContactMask>,       // per-contact subcrop mask
}
```

Errors (`StaticError::InvalidInput`): column lattice `< 2x2` or not
axis-aligned/regular; a requested property absent; `k_slice` out of range.

**`IntersectionBundle` — `model.intersection_bundle(&SectionSpec, property: Option<&str>) -> Result<..>`** (vertical section)

The trace is marched through the areal lattice at a sub-cell step; each distinct
column crossed becomes an ordered `SectionColumn`. Raw metres — the viewer applies
vertical exaggeration. The structural top/base traces are the first/last *active*
`layer_tops` / `layer_bases`. Under a Follow conformity the active-layer count
varies per column; the arrays stay `nk`-sized and an inactive
(truncated/zero-thickness) layer is `NaN` in `layer_tops`, `layer_bases`, AND
`values` (the viewer NaN-guards).

**Interior-horizon traces (SCHEMA_VERSION 4).** `horizon_traces` carries one
polyline per *interior* framework horizon (every zone-bounding horizon strictly
between the structural top and base — `N − 2` of them for an `N`-horizon stack,
top→down); the structural top/base are NOT repeated. Each `depths` array runs
**parallel to `columns`**: `depths[c]` is that horizon's depth at `columns[c]`,
taken as the zone-top interface (the top-depth of the first cell of the zone the
horizon bounds above). A single-zone (2-surface) model emits an empty
`horizon_traces`, so the block is backward-compatible.

```rust
pub enum SectionSpec {
    Polyline(Vec<[f64; 2]>),                    // world [x,y] trace
    AlongBore { trajectory: Vec<[f64; 3]> },    // world [x,y,z] — xy traces, z overlays
}

pub struct SectionColumn {
    pub distance_m: f64, pub i: usize, pub j: usize, pub x: f64, pub y: f64,
    pub layer_tops: Vec<f64>,   // per-layer cell top depth (len nk), top->base — column CENTROID (hover/back-compat)
    pub layer_bases: Vec<f64>,  // per-layer cell base depth (len nk) — column centroid
    // v4-additive: cell top/base at the column's LEFT/RIGHT fence edges (bilinear from
    // the ZCORN corners at the section's entry/exit x,y), NaN-gapped like layer_tops.
    // With these the viewer draws a dip-following TRAPEZOID; under sugar_cube they equal
    // the centroid trace. FROZEN field names (a concurrent viewer consumes them).
    // Fence direction: for a Polyline it is the trace segment; for an AlongBore it is
    // the trace's AREAL TANGENT through the column (neighbouring column centres, not the
    // raw MD-station micro-segment — which degenerates to the centroid on a vertical /
    // densely-sampled bore). A truly vertical bore (a single areal point, one column)
    // has no tangent, so its edges stay the centroid (l == r) — the one honest flat case.
    pub layer_tops_l: Vec<f64>, pub layer_tops_r: Vec<f64>,
    pub layer_bases_l: Vec<f64>, pub layer_bases_r: Vec<f64>,
    pub values: Vec<f64>,       // per-layer property value (empty if property=None)
    // v5-additive (#[serde(default)]): per-layer zone id — index into IntersectionBundle.zones,
    // NaN-gapped in lockstep with the geometry/value arrays (an inactive layer carries the
    // sentinel SectionColumn::NO_ZONE = u16::MAX). FROZEN field name (the viewer decodes it).
    pub zone_ids: Vec<u16>,
    pub path_z: Option<f64>,    // bore path depth at this station (AlongBore only)
}
impl SectionColumn { pub const NO_ZONE: u16 = u16::MAX; }  // zone_ids gap sentinel (inactive layer)
pub struct SectionContact { pub kind: String, pub depth_m: f64 }
pub struct HorizonTrace { pub name: String, pub depths: Vec<f64> }  // v4; depths parallel to columns
pub struct SectionZone { pub name: String, pub color: Option<String> }  // v5; a zone in the id table (color from StackZone.color)

pub struct IntersectionBundle {
    pub schema_version: u32,
    pub inputs_ref: String,
    pub sugar_cube: bool,               // v4-additive: true = flat-box cells (edge arrays flattened to centroid); false (default) = dip-following trapezoids
    pub property: Option<String>,       // which property `values` carry
    pub top_name: String, pub base_name: String,   // structural-surface labels
    pub columns: Vec<SectionColumn>,    // in trace order (by distance_m)
    pub horizon_traces: Vec<HorizonTrace>,  // v4: interior-horizon polylines, top->down (empty for a single zone)
    pub zones: Vec<SectionZone>,        // v5-additive: the zone table zone_ids indexes into ([{name,color}], top->base). FROZEN
    pub contacts: Vec<SectionContact>,  // GOC / OWC / GWC depths
}
```

**Section colour-by-zone (SCHEMA_VERSION 5, `task_suite_section_zone_color`).** The
bundle's `zones` list (`[{name, color}]`, the model's stratigraphic zones top→base)
plus each column's per-layer `zone_ids` are the payload half of the viewer's
colour-by-zone rendering. `zone_ids[k]` indexes `zones`; it is `NaN`-gapped in
lockstep with the geometry/value arrays — an inactive/truncated layer (where those
are `NaN`) carries `SectionColumn::NO_ZONE` (`u16::MAX`), which the viewer skips
exactly as it skips a `NaN` depth. `color` comes from `StackZone::color` (`None` on
the single-implicit-zone paths). Both fields are `#[serde(default)]` (additive). The
field names `zone_ids` and `zones: [{name, color}]` are **frozen** — a concurrent
viewer decodes exactly them.

Errors (`StaticError::InvalidInput`): lattice `< 2x2` or not axis-aligned/regular;
trace with `< 2` vertices; a named property absent.

**`VolumeBundle` — `model.volume_bundle(property: &str) -> Result<VolumeBundle>`** (3-D exterior shell, SCHEMA_VERSION 4)

The corner-point cell **exterior shell** coloured by `property`: only faces
bordering an inactive/absent neighbour or the grid boundary are emitted (a cell is
active iff its property value is finite and its bulk volume is positive). For a
low-relief grid this is O(surface) not O(volume) — a 1M-cell (200×200×25) box drops
from ~12M triangles to ~200k (1.67%), and the serialized payload from ~557 B/cell
decimal-text JSON to **~6.65 B/cell** (self-contained) / **~5 B/cell** (sidecar).
Shared vertices are deduplicated by f32 position; the per-cell arrays are compacted
to the shell cells only, with a `tri_cell` index per triangle recovering cell
identity for the viewer's threshold filter / picking. The mesh builder **lives
here** — moved DOWN from petekSim's `srs-core/mesh.rs` (the DAG flows downward).

```rust
pub struct VolumeBundle {
    pub schema_version: u32,       // 4
    pub inputs_ref: String,
    pub property: String,
    pub cell_count: usize,         // total grid cells (cell_values.len() = shell count)
    pub positions: Vec<f32>,       // deduped shell verts, 3 (x,y,z) each — grid-LOCAL coords
    pub indices: Vec<u32>,         // 3 per triangle, into positions
    pub tri_cell: Vec<u32>,        // compact cell index per triangle (into cell_values/zone_ids)
    pub cell_values: Vec<f32>,     // per shell cell (compact) — the filter/legend array
    pub zone_ids: Vec<u16>,        // per shell cell (compact) — index into zone_names
    pub zone_names: Vec<String>,
    pub value_range: ValueRange,   // over the shell cell values
}
impl VolumeBundle {
    // Self-contained: metadata envelope + base64-wrapped binary blocks, ONE file (save_view).
    pub fn write_self_contained<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()>;
    // Served: JSON envelope (offset,length manifest) + raw model.bin blocks, no base64 tax.
    pub fn write_sidecar<W1: std::io::Write, W2: std::io::Write>(&self, json: &mut W1, bin: &mut W2) -> std::io::Result<()>;
}

// Threshold regeneration (decision (b)): client slider is shell-only; the server
// re-cuts the shell at a cutoff (exposing revealed interior) via:
model.volume_bundle_thresholded(property: &str, cutoff: f64, keep_above: bool) -> Result<VolumeBundle>
```

Errors (`StaticError::InvalidInput`): `property` absent (both methods).

**Binary-block payload spec (SCHEMA_VERSION 4 — the viewer decode contract).** The
big arrays are emitted as raw **little-endian**, tightly packed (no padding within
or between blocks) bytes; the JSON envelope stays human-readable
(names/units/ranges/zones/provenance). dtypes: `f32` (4 B), `u32` (4 B), `u16`
(2 B); a NaN f32 is canonical `0x7FC00000`. Block order = row-major (C-order)
flatten of `shape`.

```jsonc
{ "schema_version": 4, "kind": "volume", "inputs_ref": ..., "property": ...,
  "cell_count": N, "shell_cell_count": C, "vertex_count": V, "triangle_count": T,
  "zone_names": [...], "value_range": {"min":..,"max":..},
  "encoding": "base64" | "sidecar",
  "blocks": {
    "positions":   { "dtype":"f32", "shape":[V,3], <payload> },
    "indices":     { "dtype":"u32", "shape":[T,3], <payload> },
    "tri_cell":    { "dtype":"u32", "shape":[T],   <payload> },
    "cell_values": { "dtype":"f32", "shape":[C],   <payload> },
    "zone_ids":    { "dtype":"u16", "shape":[C],   <payload> } } }
// <payload> = "data":"<base64 of the LE bytes>"   when encoding=="base64"
//           = "offset":<byte>, "length":<byte>    when encoding=="sidecar"
//             (blocks concatenated in the above order in the companion model.bin)
```

---

## Python surface

The `petekstatic` wheel exposes the Rust-backed `StaticModel` / `build_flat_model`
surface and the first notebook-facing workflow facade:

```python
petekstatic.__all__ == [
    "CoKriging", "DistributionSpec", "Grid", "Gridding", "Layering",
    "PropertyHandle", "PropertyPipeline", "PropertyPipelineSpec",
    "PropertyStore", "SgsRecipe", "Spherical", "StaticModel",
    "UpscaleRecipeBuilder", "Var", "VolumeCase", "VolumeResult",
    "WellLog", "WellLogSpec",
    "__version__", "build_flat_model",
    "distributions", "upscale",
]
```

`build_flat_model` returns a single-zone `StaticModel` suitable for smoke tests,
volume reads, and bundle reads. `Grid.from_project(...)` owns the canonical
static declaration shape in Python: geometry, horizons, zones, layers, scalar
and formula property assignment, property recipe lowering/execution handles, and
deterministic simple volumes. Full production corner-point model building, contact scenarios,
bundles, and MC remain backed by the Rust `StaticModelBuilder` path and may
still be surfaced through petekSim's downstream product facade while the facade
lowering is completed.

---

## Change control

The cross-library seams here (`StaticModel` accessors, `RealizationDraw`, the
regeneration API, `solve_surface_seeded`/`KernelSurface`) are ratified contracts:
changing a signature requires (1) an edit to this file, (2) coordinator
(petekSuite) + consumer (petekSim) sign-off. Library-internal signatures are
petekStatic's own call. The whole file locks at the 0.1 release
(`task_petekstatic_release_0_1`).