physmap 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. physmap/__init__.py +61 -0
  2. physmap/_paths.py +69 -0
  3. physmap/applicability/__init__.py +0 -0
  4. physmap/applicability/fixtures.py +83 -0
  5. physmap/applicability/screen.py +99 -0
  6. physmap/baselines/__init__.py +0 -0
  7. physmap/benchmarks/__init__.py +0 -0
  8. physmap/benchmarks/benchmark_report.py +405 -0
  9. physmap/benchmarks/benchmark_v0_4.py +424 -0
  10. physmap/benchmarks/compare.py +149 -0
  11. physmap/benchmarks/registry.py +217 -0
  12. physmap/benchmarks/report.py +224 -0
  13. physmap/cli.py +301 -0
  14. physmap/closures/__init__.py +48 -0
  15. physmap/closures/data/__init__.py +7 -0
  16. physmap/closures/data/closure_index.json +2997 -0
  17. physmap/closures/formulas.py +213 -0
  18. physmap/closures/geometry_classes.py +109 -0
  19. physmap/closures/index.py +393 -0
  20. physmap/closures/registry.py +313 -0
  21. physmap/compat/__init__.py +0 -0
  22. physmap/core/__init__.py +0 -0
  23. physmap/core/mechanism.py +69 -0
  24. physmap/core/signals.py +50 -0
  25. physmap/corpus/__init__.py +12 -0
  26. physmap/corpus/calibration.py +543 -0
  27. physmap/corpus/data/__init__.py +12 -0
  28. physmap/corpus/data/corpus_seed.jsonl +15 -0
  29. physmap/corpus/data/evidence_claims_seed.jsonl +21 -0
  30. physmap/corpus/data/evidence_sources_seed.jsonl +8 -0
  31. physmap/corpus/data/premium_coverage.json +60 -0
  32. physmap/corpus/evidence.py +871 -0
  33. physmap/explain/__init__.py +0 -0
  34. physmap/explain/benchmark.py +101 -0
  35. physmap/explain/causal.py +82 -0
  36. physmap/guardrail/__init__.py +38 -0
  37. physmap/guardrail/aggregator_observability.py +187 -0
  38. physmap/guardrail/classify.py +147 -0
  39. physmap/guardrail/configs.py +120 -0
  40. physmap/guardrail/corpus_regimes.py +208 -0
  41. physmap/guardrail/detector_conformal.py +129 -0
  42. physmap/guardrail/detector_density.py +74 -0
  43. physmap/guardrail/enums.py +69 -0
  44. physmap/guardrail/graph.py +73 -0
  45. physmap/guardrail/guardrail.py +606 -0
  46. physmap/guardrail/io.py +201 -0
  47. physmap/guardrail/regime_observability.py +519 -0
  48. physmap/guardrail/render.py +159 -0
  49. physmap/guardrail/weighting_heuristic.py +216 -0
  50. physmap/infra/__init__.py +23 -0
  51. physmap/infra/blindspot_oracle.py +356 -0
  52. physmap/infra/corpus_runtime.py +275 -0
  53. physmap/integrations/__init__.py +0 -0
  54. physmap/materiality/__init__.py +0 -0
  55. physmap/materiality/estimator.py +239 -0
  56. physmap/materiality/independence.py +92 -0
  57. physmap/materiality/surrogate_fit.py +293 -0
  58. physmap/observability/__init__.py +0 -0
  59. physmap/pipeline/__init__.py +58 -0
  60. physmap/pipeline/aggregators.py +199 -0
  61. physmap/pipeline/assessment_v06.py +509 -0
  62. physmap/pipeline/core.py +442 -0
  63. physmap/pipeline/defeasible_aggregator.py +324 -0
  64. physmap/pipeline/detectors.py +309 -0
  65. physmap/pipeline/observability.py +430 -0
  66. physmap/pipeline/surrogate.py +251 -0
  67. physmap/pipeline/validity_signal.py +273 -0
  68. physmap/pipeline/vehicle_spec.py +287 -0
  69. physmap/release.py +81 -0
  70. physmap/stress_tests/__init__.py +9 -0
  71. physmap/stress_tests/lewis_reuse.py +517 -0
  72. physmap/substrate/__init__.py +28 -0
  73. physmap/substrate/corpus_real.py +206 -0
  74. physmap/substrate/engine.py +209 -0
  75. physmap/substrate/forrest.py +249 -0
  76. physmap/substrate/loaders.py +2176 -0
  77. physmap/substrate/naca_tn1451.py +379 -0
  78. physmap/substrate/naca_wpd_loader.py +187 -0
  79. physmap/substrate/stage1_ingest.py +187 -0
  80. physmap/substrate/vehicle_config.py +407 -0
  81. physmap-0.2.0.dist-info/METADATA +270 -0
  82. physmap-0.2.0.dist-info/RECORD +88 -0
  83. physmap-0.2.0.dist-info/WHEEL +5 -0
  84. physmap-0.2.0.dist-info/entry_points.txt +2 -0
  85. physmap-0.2.0.dist-info/licenses/LICENSE +21 -0
  86. physmap-0.2.0.dist-info/licenses/LICENSE-CORPUS +469 -0
  87. physmap-0.2.0.dist-info/licenses/NOTICE +77 -0
  88. physmap-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,187 @@
1
+ """PhysMAP Stage 1 (Part B) — row ingestion + the independence guard.
2
+
3
+ The data-ready contract for real-CFD validation. A Row is the single unit the harness
4
+ reasons over; when the dependency wall (CFD oracle + PhysicsNeMo) clears, a `cfd_rows()`
5
+ loader produces Rows from the data and NOTHING downstream changes — the Step 0 algebraic
6
+ oracle is wired in here as a STAND-IN fixture (`step0_fixture_rows`) so the whole harness
7
+ runs end-to-end now.
8
+
9
+ INDEPENDENCE GUARD (the guard that matters most): the entire value of Stage 1 is that the
10
+ truth label is INDEPENDENT of the closure-based materiality the causal method uses. If a
11
+ row's `cfd_truth` were produced by the same closure the causal method checks, the
12
+ experiment is circular (Step 0 in CFD clothing) and any lift is fake. `load_rows`
13
+ HARD-FAILS (raises) on `truth_source == "same-closure"`. The fixture uses its own marked
14
+ value `"fixture-algebraic"` (exempt → STAND-IN status, never EMPIRICAL).
15
+
16
+ Torch-free.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+ import numpy as np
24
+
25
+ # truth_source controlled vocabulary
26
+ TRUTH_INDEPENDENT = {"experimental", "independent-hifi-model"} # OK for an EMPIRICAL verdict
27
+ TRUTH_FIXTURE = "fixture-algebraic" # the Step 0 stand-in (→ STAND-IN)
28
+ TRUTH_CIRCULAR = "same-closure" # hard-fail: circular, refused at load
29
+ TRUTH_PENDING = "pending" # coarse run; independent truth not yet stood up
30
+ TRUTH_SOURCES = TRUTH_INDEPENDENT | {TRUTH_FIXTURE, TRUTH_CIRCULAR, TRUTH_PENDING}
31
+
32
+
33
+ @dataclass
34
+ class Mechanism:
35
+ """One closure/mechanism at an operating point. materiality + in_cal are DERIVED
36
+ (contribution fraction; operating_value within [calib_lo, calib_hi])."""
37
+ name: str
38
+ closure_id: str
39
+ operating_value: float
40
+ calib_lo: float
41
+ calib_hi: float
42
+ contribution: float # closure-side contribution magnitude to the QoI
43
+
44
+ def in_calibration(self) -> bool:
45
+ return self.calib_lo <= self.operating_value <= self.calib_hi
46
+
47
+
48
+ @dataclass
49
+ class Row:
50
+ """One operating point: the surrogate prediction, the (independent) truth, the
51
+ guardrail signals, and the per-mechanism closure structure."""
52
+ operating_point: tuple
53
+ surrogate_prediction: float
54
+ cfd_truth: float
55
+ truth_source: str
56
+ guardrail_signals: dict # {"ood": float, "residual": float, "variance": float}
57
+ mechanisms: list # list[Mechanism]
58
+ cfd_uncertainty: float = 0.0 # CFD's own numerical-error band (0 for the fixture)
59
+ meta: dict = field(default_factory=dict)
60
+
61
+ def materialities(self) -> np.ndarray:
62
+ c = np.array([m.contribution for m in self.mechanisms], float)
63
+ total = c.sum()
64
+ return c / total if total > 0 else np.zeros_like(c)
65
+
66
+
67
+ def _validate(row: Row) -> None:
68
+ if row.truth_source not in TRUTH_SOURCES:
69
+ raise ValueError(f"Row {row.operating_point}: unknown truth_source {row.truth_source!r} "
70
+ f"(allowed: {sorted(TRUTH_SOURCES)})")
71
+ if not row.mechanisms:
72
+ raise ValueError(f"Row {row.operating_point}: no mechanisms")
73
+ for k in ("ood", "residual", "variance"):
74
+ if k not in row.guardrail_signals:
75
+ raise ValueError(f"Row {row.operating_point}: guardrail_signals missing {k!r}")
76
+
77
+
78
+ def load_rows(rows: list) -> list:
79
+ """Validate + enforce the INDEPENDENCE GUARD. Hard-fails (raises) on any row whose
80
+ truth comes from the same closure the causal method checks — circular, refused at the
81
+ door so it can never reach a lift verdict."""
82
+ circular = [r for r in rows if r.truth_source == TRUTH_CIRCULAR]
83
+ if circular:
84
+ ids = [r.operating_point for r in circular][:5]
85
+ raise ValueError(
86
+ f"INDEPENDENCE GUARD: {len(circular)} row(s) have truth_source='same-closure' "
87
+ f"(e.g. {ids}) — the truth is produced by the closure the causal method checks, so "
88
+ f"causal-vs-truth is circular (Step 0 in CFD clothing). Refused at ingestion. The "
89
+ f"truth must be experimental or an independent hi-fi model.")
90
+ for r in rows:
91
+ _validate(r)
92
+ return rows
93
+
94
+
95
+ def truth_is_independent(rows: list) -> bool:
96
+ """True iff every row's truth is from an independent source (real-data EMPIRICAL gate)."""
97
+ return all(r.truth_source in TRUTH_INDEPENDENT for r in rows)
98
+
99
+
100
+ def is_fixture(rows: list) -> bool:
101
+ """True iff all rows are the algebraic stand-in (→ STAND-IN status)."""
102
+ return all(r.truth_source == TRUTH_FIXTURE for r in rows)
103
+
104
+
105
+ # ── Step 0 algebraic oracle as the STAND-IN fixture ───────────────────────────
106
+
107
+ def _maha(P, mu, inv):
108
+ d = np.asarray(P, float) - mu
109
+ return np.sqrt(np.einsum("ij,jk,ik->i", d, inv, d))
110
+
111
+
112
+ def step0_fixture(seed: int = 0) -> tuple:
113
+ """Build the Step 0 grid as Stage-1 Rows + the TRAINING-distribution reference signals
114
+ (for the harness's 95th-pct self-calibration — the production-correct calibration set,
115
+ NOT the query rows). Returns (rows, reference). truth_source='fixture-algebraic'
116
+ (exempt from the independence guard, → STAND-IN); cfd_uncertainty=0.
117
+
118
+ Calibrating on the FULL-ENVELOPE training reference reproduces Step 0's deployed behavior:
119
+ the surrogate is confident across the trained envelope, so the extrapolation region's
120
+ signals are not extreme vs the training reference → guardrails MISS the blind spot."""
121
+ from physmap.infra import blindspot_oracle as bo
122
+
123
+ ds = bo.build_dataset(seed=seed)
124
+ X, Xtr = ds.X, ds.X_train
125
+ mu, cov = Xtr.mean(axis=0), np.cov(Xtr.T)
126
+ inv = np.linalg.inv(cov)
127
+ # per-query-point signals
128
+ maha_q = _maha(X, mu, inv)
129
+ residual_q = np.abs(ds.hi_sur - ds.hi_clo)
130
+ variance_q = ds.surrogate.ensemble_variance(X)
131
+ # training-distribution reference (the calibration set)
132
+ reference = {
133
+ "ood": _maha(Xtr, mu, inv),
134
+ "residual": np.abs(ds.surrogate.predict(Xtr) - ds.closures.hi_clo(Xtr[:, 0], Xtr[:, 1])),
135
+ "variance": ds.surrogate.ensemble_variance(Xtr),
136
+ }
137
+ rows = []
138
+ for i in range(len(X)):
139
+ tau, t = float(X[i, 0]), float(X[i, 1])
140
+ mechs = [
141
+ Mechanism("shear", "closure-shear", tau, bo.TAU_LO, bo.TAU_CAL_S, float(ds.c_s[i])),
142
+ Mechanism("exposure", "closure-exposure", t, bo.T_LO, bo.T_CAL_E, float(ds.c_e[i])),
143
+ ]
144
+ rows.append(Row(
145
+ operating_point=(tau, t),
146
+ surrogate_prediction=float(ds.hi_sur[i]),
147
+ cfd_truth=float(ds.hi_true[i]),
148
+ truth_source=TRUTH_FIXTURE,
149
+ cfd_uncertainty=0.0,
150
+ guardrail_signals={"ood": float(maha_q[i]), "residual": float(residual_q[i]),
151
+ "variance": float(variance_q[i])},
152
+ mechanisms=mechs,
153
+ ))
154
+ return rows, reference
155
+
156
+
157
+ def step0_fixture_rows(seed: int = 0) -> list:
158
+ """Just the rows (convenience for callers that don't need the calibration reference)."""
159
+ return step0_fixture(seed=seed)[0]
160
+
161
+
162
+ # ── real-data loader (the doc's swap-in; torch-free) ──────────────────────────
163
+
164
+ def cfd_rows(path) -> list:
165
+ """Load Stage-1 Rows from a JSON payload an external producer wrote (e.g. the real
166
+ PhysicsNeMoAdapter running in a torch env). The payload is {"rows": [ {...}, ... ]}
167
+ where each row dict carries operating_point / surrogate_prediction / cfd_truth /
168
+ truth_source / cfd_uncertainty / guardrail_signals / mechanisms[...]. Torch-free — this
169
+ is the production swap-in for step0_fixture_rows when real CFD data arrives.
170
+ Validation + the independence guard are applied via load_rows()."""
171
+ import json
172
+ from pathlib import Path
173
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
174
+ rows = []
175
+ for d in payload["rows"]:
176
+ mechs = [Mechanism(**m) for m in d["mechanisms"]]
177
+ rows.append(Row(
178
+ operating_point=tuple(d["operating_point"]),
179
+ surrogate_prediction=float(d["surrogate_prediction"]),
180
+ cfd_truth=float(d["cfd_truth"]),
181
+ truth_source=d["truth_source"],
182
+ cfd_uncertainty=float(d.get("cfd_uncertainty", 0.0)),
183
+ guardrail_signals=d["guardrail_signals"],
184
+ mechanisms=mechs,
185
+ meta=d.get("meta", {}),
186
+ ))
187
+ return load_rows(rows)
@@ -0,0 +1,407 @@
1
+ """VehicleConfig — the YAML-backed data shape that drives `build_substrate(...)`.
2
+
3
+ Per the v0.2 architecture refactor (Part 1): everything a vehicle needs is data,
4
+ not code. One YAML per vehicle under `physmap/vehicles/`, parsed into an
5
+ immutable `VehicleConfig`. The substrate engine reads the config + the closure
6
+ registry + the calibration corpus and emits `(rows, reference, meta)` with no
7
+ vehicle-specific Python.
8
+
9
+ VALIDATION LAYERS (fail fast on misconfiguration):
10
+
11
+ 1. Schema validation — every YAML key is recognized; unknown keys raise.
12
+ 2. Geometry vocab check — `geometry.class_` must be in
13
+ `physmap.closures.geometry_classes.ALL_GEOMETRY_CLASSES`.
14
+ 3. Registry reference check — `matched_closure_id` and every
15
+ `reference_closure_ids` entry must exist in
16
+ `physmap.closures.REGISTRY`.
17
+ 4. Mismatch escape-hatch check — `expect_mismatch=True` requires a
18
+ non-empty `mismatch_rationale`; the geometry-match invariant lives
19
+ in the substrate engine (build-time), not here, because it needs
20
+ to compare the closure's geometry_class to the vehicle's class.
21
+
22
+ Reserved keys per schema (rejected if anything else appears):
23
+ vehicle_id, domain, geometry, matched_closure_id, reference_closure_ids,
24
+ cell_bands, data_source, fluid, expect_mismatch, mismatch_rationale.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ import yaml
34
+
35
+ from physmap.closures import REGISTRY
36
+ from physmap.closures.geometry_classes import assert_known_geometry
37
+
38
+
39
+ # ── exceptions ──────────────────────────────────────────────────────────────
40
+
41
+ class VehicleConfigError(ValueError):
42
+ """Raised on any structural / vocab / reference problem in a VehicleConfig."""
43
+
44
+
45
+ # ── reserved top-level keys ─────────────────────────────────────────────────
46
+
47
+ _TOP_LEVEL_KEYS = frozenset({
48
+ "vehicle_id", "domain",
49
+ "geometry", "matched_closure_id", "reference_closure_ids",
50
+ "cell_bands", "data_source", "fluid",
51
+ "expect_mismatch", "mismatch_rationale",
52
+ "benchmark",
53
+ })
54
+
55
+ _GEOMETRY_KEYS = frozenset({"class", "dims"})
56
+ _DATA_SOURCE_KEYS = frozenset({"loader", "path", "dir", "options"})
57
+ _FLUID_KEYS = frozenset({"name", "pr_range"})
58
+ _BENCHMARK_KEYS = frozenset({
59
+ "include", "domain", "surrogate_inputs", "regime", "failure_driver",
60
+ "expected_observability_class", "threshold_calibration", "architecture_axis", "caveat",
61
+ })
62
+ _OBSERVABILITY_CLASSES = frozenset({"OBSERVABLE", "PARTIAL", "UNOBSERVABLE"})
63
+ _CELL_BANDS_TYPES = frozenset({
64
+ "re_bands", # Forrest — single Re cutoffs
65
+ "dh_roughness_bands", # Mudhafar — Dh + roughness flag
66
+ "x_over_d_bands", # NACA — entrance-region cells
67
+ "richardson_bands", # buoyancy middle vehicles (Testi-Grassi) — Ri cutoffs
68
+ "buoyancy_parameter_bands",# Jin sCO2 vertical tube — Liu Bu buoyancy (benign/deploy by split_role)
69
+ "property_variation_bands",# Velazquez sCO2 — property-variation (pseudo-critical) cells
70
+ "freestream_disturbance_bands", # Casper — quiet-vs-noisy hypersonic transition
71
+ "entropy_layer_shock_interaction_bands", # Marineau — bluntness (S_T/X_SW) transition
72
+ "continuous", # Lance & Smith — no bins
73
+ "custom", # escape hatch for vehicle-specific logic
74
+ })
75
+
76
+
77
+ # ── dataclasses ─────────────────────────────────────────────────────────────
78
+
79
+ @dataclass(frozen=True)
80
+ class GeometryConfig:
81
+ """Geometry block of a VehicleConfig.
82
+
83
+ `class_` (trailing underscore to avoid Python keyword collision) is the
84
+ geometry-vocab string compared by the invariant. `dims` is free-form
85
+ (each vehicle records what its physics needs); the substrate engine
86
+ surfaces it into `SubstrateMeta` but does not interpret it directly.
87
+ """
88
+ class_: str
89
+ dims: dict[str, Any] = field(default_factory=dict)
90
+
91
+ def __post_init__(self) -> None:
92
+ assert_known_geometry(self.class_)
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class CellBandsConfig:
97
+ """Cell-bands block. `type` selects the band-assignment strategy used by
98
+ `substrate_engine.assign_cells(...)`. `bands` is type-specific shape;
99
+ `continuous` carries no bands at all.
100
+ """
101
+ type: str
102
+ bands: tuple[dict[str, Any], ...] = field(default_factory=tuple)
103
+
104
+ def __post_init__(self) -> None:
105
+ if self.type not in _CELL_BANDS_TYPES:
106
+ raise VehicleConfigError(
107
+ f"cell_bands.type={self.type!r} not in {sorted(_CELL_BANDS_TYPES)}. "
108
+ f"Add the new type to vehicle_config._CELL_BANDS_TYPES and teach "
109
+ f"substrate_engine.assign_cells about it."
110
+ )
111
+ if self.type == "continuous" and self.bands:
112
+ raise VehicleConfigError(
113
+ "cell_bands.type='continuous' must not carry a `bands` list "
114
+ "(the substrate is continuous in its operating point)."
115
+ )
116
+ if self.type != "continuous" and not self.bands:
117
+ raise VehicleConfigError(
118
+ f"cell_bands.type={self.type!r} requires a non-empty `bands` list."
119
+ )
120
+ # Each band entry must have a name
121
+ for i, b in enumerate(self.bands):
122
+ if not isinstance(b, dict) or "name" not in b:
123
+ raise VehicleConfigError(
124
+ f"cell_bands.bands[{i}] missing 'name' field: {b!r}"
125
+ )
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class DataSourceConfig:
130
+ """Data-source block — which loader the substrate engine should call to
131
+ materialize raw data into row dicts. `path` (file) and `dir` (directory)
132
+ are alternatives — exactly one must be set unless `options` carries
133
+ something loader-specific.
134
+ """
135
+ loader: str
136
+ path: str | None = None
137
+ dir: str | None = None
138
+ options: dict[str, Any] = field(default_factory=dict)
139
+
140
+ def __post_init__(self) -> None:
141
+ if not self.loader:
142
+ raise VehicleConfigError("data_source.loader is required.")
143
+ if self.path is None and self.dir is None and not self.options:
144
+ raise VehicleConfigError(
145
+ f"data_source.loader={self.loader!r} must specify one of "
146
+ f"`path`, `dir`, or `options.*` so the loader knows what to read."
147
+ )
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class FluidConfig:
152
+ """Fluid metadata. Surfaced into row meta for the validity detectors;
153
+ `pr_range` is the working envelope, not the per-row Pr value."""
154
+ name: str
155
+ pr_range: tuple[float, float] | None = None
156
+
157
+ def __post_init__(self) -> None:
158
+ if not self.name:
159
+ raise VehicleConfigError("fluid.name is required.")
160
+ if self.pr_range is not None:
161
+ lo, hi = self.pr_range
162
+ if not (lo <= hi):
163
+ raise VehicleConfigError(
164
+ f"fluid.pr_range invalid: {self.pr_range!r} (lo must be <= hi)."
165
+ )
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class BenchmarkConfig:
170
+ """Declarative benchmark-registry classification. Presence with `include=true`
171
+ puts the vehicle in the benchmark matrix; absence means the vehicle is loadable
172
+ but not benchmarked. Declares INTENT (`expected_observability_class`) + the few
173
+ facts the runner can't infer (regime, surrogate_inputs, failure_driver) — and
174
+ NEVER an outcome label (the cell outcome is computed from raw signals, never
175
+ declared, so a vehicle can't assert its own win)."""
176
+ include: bool
177
+ domain: str
178
+ surrogate_inputs: tuple[str, ...]
179
+ regime: str
180
+ failure_driver: str
181
+ expected_observability_class: str
182
+ threshold_calibration: tuple[float, float] | None = None
183
+ architecture_axis: bool = False
184
+ caveat: str = ""
185
+
186
+ def __post_init__(self) -> None:
187
+ for name, val in (("domain", self.domain), ("regime", self.regime),
188
+ ("failure_driver", self.failure_driver)):
189
+ if not val:
190
+ raise VehicleConfigError(f"benchmark.{name} is required (non-empty).")
191
+ if not self.surrogate_inputs:
192
+ raise VehicleConfigError("benchmark.surrogate_inputs must be non-empty.")
193
+ if self.expected_observability_class not in _OBSERVABILITY_CLASSES:
194
+ raise VehicleConfigError(
195
+ f"benchmark.expected_observability_class="
196
+ f"{self.expected_observability_class!r} not in "
197
+ f"{sorted(_OBSERVABILITY_CLASSES)}."
198
+ )
199
+ if self.threshold_calibration is not None and len(self.threshold_calibration) != 2:
200
+ raise VehicleConfigError(
201
+ f"benchmark.threshold_calibration must be [paper_pct, dig_pct], "
202
+ f"got {self.threshold_calibration!r}."
203
+ )
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class VehicleConfig:
208
+ """The full, validated VehicleConfig. Built only via `load_vehicle_config`
209
+ (which feeds through `from_dict` to enforce schema). Immutable; the
210
+ substrate engine treats it as read-only data.
211
+ """
212
+ vehicle_id: str
213
+ domain: str
214
+ geometry: GeometryConfig
215
+ matched_closure_id: str
216
+ reference_closure_ids: tuple[str, ...]
217
+ cell_bands: CellBandsConfig
218
+ data_source: DataSourceConfig
219
+ fluid: FluidConfig
220
+ expect_mismatch: bool = False
221
+ mismatch_rationale: str = ""
222
+ benchmark: BenchmarkConfig | None = None
223
+
224
+ def __post_init__(self) -> None:
225
+ if not self.vehicle_id:
226
+ raise VehicleConfigError("vehicle_id is required.")
227
+ if not self.domain:
228
+ raise VehicleConfigError("domain is required.")
229
+ if self.matched_closure_id not in REGISTRY:
230
+ raise VehicleConfigError(
231
+ f"matched_closure_id={self.matched_closure_id!r} not in REGISTRY. "
232
+ f"Known: {sorted(REGISTRY.keys())}."
233
+ )
234
+ unknown_refs = [c for c in self.reference_closure_ids if c not in REGISTRY]
235
+ if unknown_refs:
236
+ raise VehicleConfigError(
237
+ f"reference_closure_ids contain unknown entries: {unknown_refs}. "
238
+ f"Known: {sorted(REGISTRY.keys())}."
239
+ )
240
+ # mismatch override discipline
241
+ if self.expect_mismatch and not self.mismatch_rationale.strip():
242
+ raise VehicleConfigError(
243
+ f"vehicle_id={self.vehicle_id!r}: expect_mismatch=true requires a "
244
+ f"non-empty mismatch_rationale (the rationale surfaces in meta and "
245
+ f"is the audit trail for the override)."
246
+ )
247
+ if not self.expect_mismatch and self.mismatch_rationale.strip():
248
+ raise VehicleConfigError(
249
+ f"vehicle_id={self.vehicle_id!r}: mismatch_rationale is set but "
250
+ f"expect_mismatch is false. Either set expect_mismatch=true or "
251
+ f"remove the rationale (a rationale without the override is dead text)."
252
+ )
253
+
254
+
255
+ # ── loader ──────────────────────────────────────────────────────────────────
256
+
257
+ def _ensure_known_keys(name: str, payload: dict, allowed: frozenset[str]) -> None:
258
+ """Reject any keys outside the allowed set. Catches YAML typos that would
259
+ otherwise silently parse and produce a misconfigured vehicle."""
260
+ unknown = set(payload.keys()) - allowed
261
+ if unknown:
262
+ raise VehicleConfigError(
263
+ f"{name}: unknown keys {sorted(unknown)} (allowed: {sorted(allowed)}). "
264
+ f"Either add them to the schema or fix the typo."
265
+ )
266
+
267
+
268
+ def from_dict(payload: dict) -> VehicleConfig:
269
+ """Build a VehicleConfig from a parsed YAML dict. Validates the schema."""
270
+ if not isinstance(payload, dict):
271
+ raise VehicleConfigError(
272
+ f"VehicleConfig payload must be a dict, got {type(payload).__name__}."
273
+ )
274
+ _ensure_known_keys("VehicleConfig", payload, _TOP_LEVEL_KEYS)
275
+
276
+ # Required top-level keys
277
+ for required in ("vehicle_id", "domain", "geometry",
278
+ "matched_closure_id", "cell_bands",
279
+ "data_source", "fluid"):
280
+ if required not in payload:
281
+ raise VehicleConfigError(f"VehicleConfig: missing required key {required!r}.")
282
+
283
+ geom_raw = payload["geometry"]
284
+ if not isinstance(geom_raw, dict):
285
+ raise VehicleConfigError("geometry must be a mapping.")
286
+ _ensure_known_keys("geometry", geom_raw, _GEOMETRY_KEYS)
287
+ geom = GeometryConfig(
288
+ class_=geom_raw["class"], # YAML key is "class"; dataclass uses "class_"
289
+ dims=dict(geom_raw.get("dims") or {}),
290
+ )
291
+
292
+ cell_raw = payload["cell_bands"]
293
+ if not isinstance(cell_raw, dict):
294
+ raise VehicleConfigError("cell_bands must be a mapping.")
295
+ bands_raw = cell_raw.get("bands", [])
296
+ if bands_raw and not isinstance(bands_raw, list):
297
+ raise VehicleConfigError("cell_bands.bands must be a list when present.")
298
+ cells = CellBandsConfig(
299
+ type=cell_raw["type"],
300
+ bands=tuple(dict(b) for b in bands_raw),
301
+ )
302
+
303
+ ds_raw = payload["data_source"]
304
+ if not isinstance(ds_raw, dict):
305
+ raise VehicleConfigError("data_source must be a mapping.")
306
+ _ensure_known_keys("data_source", ds_raw, _DATA_SOURCE_KEYS)
307
+ ds = DataSourceConfig(
308
+ loader=ds_raw["loader"],
309
+ path=ds_raw.get("path"),
310
+ dir=ds_raw.get("dir"),
311
+ options=dict(ds_raw.get("options") or {}),
312
+ )
313
+
314
+ fluid_raw = payload["fluid"]
315
+ if not isinstance(fluid_raw, dict):
316
+ raise VehicleConfigError("fluid must be a mapping.")
317
+ _ensure_known_keys("fluid", fluid_raw, _FLUID_KEYS)
318
+ pr_range_raw = fluid_raw.get("pr_range")
319
+ pr_range: tuple[float, float] | None = None
320
+ if pr_range_raw is not None:
321
+ if not (isinstance(pr_range_raw, (list, tuple)) and len(pr_range_raw) == 2):
322
+ raise VehicleConfigError(
323
+ f"fluid.pr_range must be a 2-element list [lo, hi], got {pr_range_raw!r}."
324
+ )
325
+ pr_range = (float(pr_range_raw[0]), float(pr_range_raw[1]))
326
+ fluid = FluidConfig(name=fluid_raw["name"], pr_range=pr_range)
327
+
328
+ refs = payload.get("reference_closure_ids", []) or []
329
+ if not isinstance(refs, list):
330
+ raise VehicleConfigError("reference_closure_ids must be a list.")
331
+
332
+ bench_raw = payload.get("benchmark")
333
+ benchmark: BenchmarkConfig | None = None
334
+ if bench_raw is not None:
335
+ if not isinstance(bench_raw, dict):
336
+ raise VehicleConfigError("benchmark must be a mapping.")
337
+ _ensure_known_keys("benchmark", bench_raw, _BENCHMARK_KEYS)
338
+ for req in ("include", "domain", "surrogate_inputs", "regime",
339
+ "failure_driver", "expected_observability_class"):
340
+ if req not in bench_raw:
341
+ raise VehicleConfigError(f"benchmark: missing required key {req!r}.")
342
+ si_raw = bench_raw["surrogate_inputs"]
343
+ if not (isinstance(si_raw, (list, tuple)) and si_raw):
344
+ raise VehicleConfigError("benchmark.surrogate_inputs must be a non-empty list.")
345
+ tc_raw = bench_raw.get("threshold_calibration")
346
+ tc: tuple[float, float] | None = None
347
+ if tc_raw is not None:
348
+ if not (isinstance(tc_raw, (list, tuple)) and len(tc_raw) == 2):
349
+ raise VehicleConfigError(
350
+ f"benchmark.threshold_calibration must be [paper_pct, dig_pct], "
351
+ f"got {tc_raw!r}.")
352
+ tc = (float(tc_raw[0]), float(tc_raw[1]))
353
+ benchmark = BenchmarkConfig(
354
+ include=bool(bench_raw["include"]),
355
+ domain=str(bench_raw["domain"]),
356
+ surrogate_inputs=tuple(str(s) for s in si_raw),
357
+ regime=str(bench_raw["regime"]),
358
+ failure_driver=str(bench_raw["failure_driver"]),
359
+ expected_observability_class=str(bench_raw["expected_observability_class"]),
360
+ threshold_calibration=tc,
361
+ architecture_axis=bool(bench_raw.get("architecture_axis", False)),
362
+ caveat=str(bench_raw.get("caveat", "")),
363
+ )
364
+
365
+ return VehicleConfig(
366
+ vehicle_id=payload["vehicle_id"],
367
+ domain=payload["domain"],
368
+ geometry=geom,
369
+ matched_closure_id=payload["matched_closure_id"],
370
+ reference_closure_ids=tuple(refs),
371
+ cell_bands=cells,
372
+ data_source=ds,
373
+ fluid=fluid,
374
+ expect_mismatch=bool(payload.get("expect_mismatch", False)),
375
+ mismatch_rationale=str(payload.get("mismatch_rationale", "")),
376
+ benchmark=benchmark,
377
+ )
378
+
379
+
380
+ def load_vehicle_config(path: str | Path) -> VehicleConfig:
381
+ """Parse a vehicle YAML at `path` into a validated VehicleConfig."""
382
+ path = Path(path)
383
+ if not path.exists():
384
+ raise FileNotFoundError(f"VehicleConfig file not found: {path}")
385
+ try:
386
+ with path.open("r") as fh:
387
+ payload = yaml.safe_load(fh)
388
+ except yaml.YAMLError as exc:
389
+ raise VehicleConfigError(f"YAML parse error in {path}: {exc}") from exc
390
+ if payload is None:
391
+ raise VehicleConfigError(f"VehicleConfig {path}: file is empty.")
392
+ return from_dict(payload)
393
+
394
+
395
+ # Vehicle YAMLs are checkout data, not package data: they name CSV paths that only
396
+ # exist in a clone. Resolved lazily so importing this module never depends on where
397
+ # it was installed from.
398
+ def _vehicles_dir() -> Path:
399
+ from physmap._paths import checkout_path
400
+
401
+ return checkout_path("data", "vehicles", what="the benchmark vehicle specs")
402
+
403
+
404
+ def load_named_vehicle(vehicle_id: str) -> VehicleConfig:
405
+ """Convenience loader: `vehicle_config.load_named_vehicle('forrest')` ->
406
+ `VehicleConfig` parsed from `physmap/vehicles/forrest.yaml`."""
407
+ return load_vehicle_config(_vehicles_dir() / f"{vehicle_id}.yaml")