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.
- physmap/__init__.py +61 -0
- physmap/_paths.py +69 -0
- physmap/applicability/__init__.py +0 -0
- physmap/applicability/fixtures.py +83 -0
- physmap/applicability/screen.py +99 -0
- physmap/baselines/__init__.py +0 -0
- physmap/benchmarks/__init__.py +0 -0
- physmap/benchmarks/benchmark_report.py +405 -0
- physmap/benchmarks/benchmark_v0_4.py +424 -0
- physmap/benchmarks/compare.py +149 -0
- physmap/benchmarks/registry.py +217 -0
- physmap/benchmarks/report.py +224 -0
- physmap/cli.py +301 -0
- physmap/closures/__init__.py +48 -0
- physmap/closures/data/__init__.py +7 -0
- physmap/closures/data/closure_index.json +2997 -0
- physmap/closures/formulas.py +213 -0
- physmap/closures/geometry_classes.py +109 -0
- physmap/closures/index.py +393 -0
- physmap/closures/registry.py +313 -0
- physmap/compat/__init__.py +0 -0
- physmap/core/__init__.py +0 -0
- physmap/core/mechanism.py +69 -0
- physmap/core/signals.py +50 -0
- physmap/corpus/__init__.py +12 -0
- physmap/corpus/calibration.py +543 -0
- physmap/corpus/data/__init__.py +12 -0
- physmap/corpus/data/corpus_seed.jsonl +15 -0
- physmap/corpus/data/evidence_claims_seed.jsonl +21 -0
- physmap/corpus/data/evidence_sources_seed.jsonl +8 -0
- physmap/corpus/data/premium_coverage.json +60 -0
- physmap/corpus/evidence.py +871 -0
- physmap/explain/__init__.py +0 -0
- physmap/explain/benchmark.py +101 -0
- physmap/explain/causal.py +82 -0
- physmap/guardrail/__init__.py +38 -0
- physmap/guardrail/aggregator_observability.py +187 -0
- physmap/guardrail/classify.py +147 -0
- physmap/guardrail/configs.py +120 -0
- physmap/guardrail/corpus_regimes.py +208 -0
- physmap/guardrail/detector_conformal.py +129 -0
- physmap/guardrail/detector_density.py +74 -0
- physmap/guardrail/enums.py +69 -0
- physmap/guardrail/graph.py +73 -0
- physmap/guardrail/guardrail.py +606 -0
- physmap/guardrail/io.py +201 -0
- physmap/guardrail/regime_observability.py +519 -0
- physmap/guardrail/render.py +159 -0
- physmap/guardrail/weighting_heuristic.py +216 -0
- physmap/infra/__init__.py +23 -0
- physmap/infra/blindspot_oracle.py +356 -0
- physmap/infra/corpus_runtime.py +275 -0
- physmap/integrations/__init__.py +0 -0
- physmap/materiality/__init__.py +0 -0
- physmap/materiality/estimator.py +239 -0
- physmap/materiality/independence.py +92 -0
- physmap/materiality/surrogate_fit.py +293 -0
- physmap/observability/__init__.py +0 -0
- physmap/pipeline/__init__.py +58 -0
- physmap/pipeline/aggregators.py +199 -0
- physmap/pipeline/assessment_v06.py +509 -0
- physmap/pipeline/core.py +442 -0
- physmap/pipeline/defeasible_aggregator.py +324 -0
- physmap/pipeline/detectors.py +309 -0
- physmap/pipeline/observability.py +430 -0
- physmap/pipeline/surrogate.py +251 -0
- physmap/pipeline/validity_signal.py +273 -0
- physmap/pipeline/vehicle_spec.py +287 -0
- physmap/release.py +81 -0
- physmap/stress_tests/__init__.py +9 -0
- physmap/stress_tests/lewis_reuse.py +517 -0
- physmap/substrate/__init__.py +28 -0
- physmap/substrate/corpus_real.py +206 -0
- physmap/substrate/engine.py +209 -0
- physmap/substrate/forrest.py +249 -0
- physmap/substrate/loaders.py +2176 -0
- physmap/substrate/naca_tn1451.py +379 -0
- physmap/substrate/naca_wpd_loader.py +187 -0
- physmap/substrate/stage1_ingest.py +187 -0
- physmap/substrate/vehicle_config.py +407 -0
- physmap-0.2.0.dist-info/METADATA +270 -0
- physmap-0.2.0.dist-info/RECORD +88 -0
- physmap-0.2.0.dist-info/WHEEL +5 -0
- physmap-0.2.0.dist-info/entry_points.txt +2 -0
- physmap-0.2.0.dist-info/licenses/LICENSE +21 -0
- physmap-0.2.0.dist-info/licenses/LICENSE-CORPUS +469 -0
- physmap-0.2.0.dist-info/licenses/NOTICE +77 -0
- 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")
|