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,273 @@
1
+ """PhysMAP D3 — literature-derived validity-range-distance signal.
2
+
3
+ Per user directive after the sweep finding:
4
+ "Track B's signal was never the physics-validity signal PhysMAP claims —
5
+ that signal (literature validity-range distance) still needs to be
6
+ built and tested against genuinely-quiet baselines on real data."
7
+
8
+ This module implements the ACTUAL PhysMAP physics-validity claim: a signal
9
+ that depends ONLY on the literature-derived closure validated_range from
10
+ `corpus.jsonl`, NOT on training data. That's the structural property that
11
+ differentiates PhysMAP from input-distribution novelty detectors.
12
+
13
+ Signal definition:
14
+ Given a test point (Re, Pr, ...) and a matched closure_id, look up the
15
+ closure's `validated_range` from corpus.jsonl. For each coordinate in
16
+ the range, compute the relative distance from the test point's value to
17
+ the [min, max] interval:
18
+ - 0 if min <= value <= max
19
+ - (min - value) / min if value < min
20
+ - (value - max) / max if value > max
21
+ Combine across coordinates as L2 norm. Signal > 0 iff test point is
22
+ outside the validated rectangle on at least one coordinate.
23
+
24
+ Key property: this signal has NO training-data dependence. It catches
25
+ out-of-validity points regardless of how training is distributed.
26
+ That's the structural mechanism that lets PhysMAP differentiate from
27
+ novelty detectors, IF such a differentiator cell exists in the data.
28
+
29
+ Important: just because this signal fires doesn't mean it differentiates
30
+ from baselines. Baselines fire when test points are far from training.
31
+ A clean differentiator requires test points that are:
32
+ - INSIDE the training distribution (baselines genuinely quiet)
33
+ - OUTSIDE the closure's validated range (validity-distance fires)
34
+ These conditions can only co-occur if training data includes points
35
+ outside the closure's validated range — i.e., a practitioner trained on
36
+ data spanning the closure's validity boundary.
37
+
38
+ For Forrest:
39
+ Modified Sparrow-Cur validated_range: Re [10000, 70000], Pr [2.2, 5.4].
40
+ Forrest data includes:
41
+ - Sub-critical Re<4000 (OUTSIDE validated Re range)
42
+ - Transition Re 4000-10000 (OUTSIDE validated Re range)
43
+ - Benign Re>=10000 (INSIDE validated range)
44
+ If training includes any sub-critical or transition points, the closure's
45
+ validity boundary cuts THROUGH the training distribution. Test points on
46
+ the wrong side of that internal boundary would be baseline-quiet but
47
+ validity-loud — the genuine differentiator cell.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import json
53
+ from dataclasses import dataclass, field
54
+ from pathlib import Path
55
+ from typing import Sequence
56
+
57
+ import numpy as np
58
+
59
+ from physmap.corpus.calibration import resolve_corpus_path
60
+
61
+
62
+ # Map D3 feature names to corpus.jsonl coordinate names.
63
+ # Geometry features (Dh, alpha_star, etc.) are NOT in corpus validated_range
64
+ # (yet); they're in regime_context as prose. So those features don't
65
+ # contribute to validity-distance — they're for baselines only.
66
+ #
67
+ # x_over_D added after the Track-C-extension for Gnielinski. The validity
68
+ # signal now fires on entrance-region (x/D < 10) test points where the
69
+ # bare correlation is invalid per textbook (Bergman/Incropera 7th ed §8.5).
70
+ FEATURE_TO_CORPUS_COORD = {
71
+ "log10_Re": "reynolds_number", # validated_range stores raw Re
72
+ "Pr": "prandtl_number",
73
+ "x_over_D": "x_over_D", # straight passthrough
74
+ "log10_x_over_D": "x_over_D", # for log-scale axial-position features
75
+ "Ri": "richardson_number", # buoyancy validity ceiling (Stage-3 middle).
76
+ # raw Ri (NOT log) — signal() compares the raw
77
+ # value to the corpus min/max directly.
78
+ "ratio_mu_w_b": "viscosity_ratio_wall_bulk", # property-variation bound (Velazquez sCO2)
79
+ "Bu": "liu_buoyancy_parameter", # buoyancy validity bound (Jin sCO2 vertical tube;
80
+ # raw Bu vs corpus max 1.3e-5 directly)
81
+ # aerospace / hypersonic-transition validity coords (raw passthrough — the
82
+ # signal() comparison uses the raw value vs corpus min/max directly):
83
+ "freestream_noise_pct": "freestream_noise_rms_pitot_pct", # Casper (Pate-Stainback bound)
84
+ "st_xsw_ratio": "entropy_layer_shock_ratio", # Marineau (entropy-layer/shock bound)
85
+ }
86
+
87
+
88
+ # Status weighting for picking the GOVERNING (dispositive) fired bound when a
89
+ # point is past multiple bounds at once: a confirmed bound outranks a claimed
90
+ # one at equal margin. Mirrors the corpus bound_status vocabulary.
91
+ _STATUS_WEIGHT = {
92
+ "confirmed": 1.0,
93
+ "confirmed-contested": 0.9,
94
+ "extrapolated": 0.6,
95
+ "claimed": 0.4,
96
+ }
97
+ _DEFAULT_STATUS_WEIGHT = 0.4
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class PerBoundMargin:
102
+ """One bound's graded distance-past-bound for one test point.
103
+
104
+ `margin` is the same per-coordinate relative distance `signal()` squares into
105
+ its L2 norm: 0 inside [min, max], > 0 outside. `side` ∈ {below, above, inside}.
106
+ This is the graded, un-collapsed signal (the Tier-1 finding: graded margin
107
+ ensembles better than binary fire-at-bound). The detector reports which bound
108
+ was violated and by how much; it does NOT decide observability — that routing
109
+ lives in the aggregator.
110
+ """
111
+ coord: str # corpus bound variable, e.g. "x_over_D"
112
+ feature_name: str # the feature column read, e.g. "x_over_D" or "log10_Re"
113
+ margin: float # graded distance-past-bound (0 inside; >0 outside)
114
+ bound_status: str # confirmed | confirmed-contested | extrapolated | claimed
115
+ side: str # below | above | inside
116
+
117
+
118
+ @dataclass
119
+ class ValidityRangeDistanceDetector:
120
+ """PhysMAP's literature-derived physics-validity signal.
121
+
122
+ Reads the matched closure's validated_range from corpus.jsonl. At each
123
+ test point, computes distance from the test point's coordinates to the
124
+ validated rectangle. Signal > 0 iff test point is outside the
125
+ rectangle on at least one coordinate.
126
+
127
+ NO training-data dependence. Pure literature lookup.
128
+ """
129
+ closure_id: str
130
+ feature_names: Sequence[str]
131
+ # Resolves the ACTIVE corpus (env → premium → bundled seed) at construction
132
+ # time; a dev/editable tree gets the full premium corpus unchanged, a core-only
133
+ # install gets the bundled seed. Pass an explicit path to override.
134
+ corpus_path: Path = field(default_factory=resolve_corpus_path)
135
+
136
+ valid_ranges: dict[str, tuple[float, float]] = field(default_factory=dict, init=False)
137
+ # bound_status[coord] mirrors the corpus bound_status; used by governing_bound's
138
+ # status weighting. Populated alongside valid_ranges; signal() never reads it.
139
+ bound_status: dict[str, str] = field(default_factory=dict, init=False)
140
+
141
+ def __post_init__(self):
142
+ self.valid_ranges = self._load_validated_ranges()
143
+ if not self.valid_ranges:
144
+ raise ValueError(
145
+ f"closure_id '{self.closure_id}' has no usable validated_range "
146
+ f"in corpus.jsonl (or closure_id not found)"
147
+ )
148
+ self.bound_status = self._load_bound_status()
149
+
150
+ def _load_validated_ranges(self) -> dict[str, tuple[float, float]]:
151
+ ranges = {}
152
+ with open(self.corpus_path) as f:
153
+ for line in f:
154
+ line = line.strip()
155
+ if not line:
156
+ continue
157
+ entry = json.loads(line)
158
+ if entry.get("closure_id") != self.closure_id:
159
+ continue
160
+ for vr in entry.get("validated_range", []):
161
+ coord = vr["coord"]
162
+ vmin = float(vr["min"])
163
+ vmax = float(vr["max"])
164
+ ranges[coord] = (vmin, vmax)
165
+ break
166
+ return ranges
167
+
168
+ def signal(self, test_X: np.ndarray) -> np.ndarray:
169
+ """Per-test-point validity-distance to the closure's validated range.
170
+
171
+ For each test point and each feature that maps to a corpus coord,
172
+ compute the relative distance to the [min, max] interval and
173
+ combine as L2 norm. Geometry features (not in corpus) contribute 0.
174
+ """
175
+ n_test = len(test_X)
176
+ scores = np.zeros(n_test, dtype=float)
177
+ for i, x in enumerate(test_X):
178
+ sq_dist = 0.0
179
+ for j, fname in enumerate(self.feature_names):
180
+ coord = FEATURE_TO_CORPUS_COORD.get(fname)
181
+ if coord is None or coord not in self.valid_ranges:
182
+ continue
183
+ # Convert feature value back to physical units for coord lookup
184
+ if fname == "log10_Re":
185
+ value = 10.0 ** float(x[j])
186
+ elif fname == "log10_x_over_D":
187
+ value = 10.0 ** float(x[j])
188
+ else:
189
+ value = float(x[j])
190
+ vmin, vmax = self.valid_ranges[coord]
191
+ if value < vmin:
192
+ rel = (vmin - value) / max(vmin, 1e-9)
193
+ sq_dist += rel ** 2
194
+ elif value > vmax:
195
+ rel = (value - vmax) / max(vmax, 1e-9)
196
+ sq_dist += rel ** 2
197
+ # else: in [min, max], contributes 0
198
+ scores[i] = float(np.sqrt(sq_dist))
199
+ return scores
200
+
201
+ # ── graded per-bound margins + governing fired bound (additive) ───────────
202
+ # signal() above is left byte-identical (the locked phase1_gate depends on
203
+ # it). These methods expose the SAME per-coordinate relative distances
204
+ # un-collapsed, plus which bound governs — what the observability-weighted
205
+ # aggregator needs to route on the fired bound VARIABLE.
206
+
207
+ def _load_bound_status(self) -> dict[str, str]:
208
+ """{coord: bound_status} for the matched closure. A separate read keeps
209
+ _load_validated_ranges() and signal() byte-identical."""
210
+ statuses: dict[str, str] = {}
211
+ with open(self.corpus_path) as f:
212
+ for line in f:
213
+ line = line.strip()
214
+ if not line:
215
+ continue
216
+ entry = json.loads(line)
217
+ if entry.get("closure_id") != self.closure_id:
218
+ continue
219
+ for vr in entry.get("validated_range", []):
220
+ statuses[vr["coord"]] = vr.get("bound_status", "")
221
+ break
222
+ return statuses
223
+
224
+ def evaluate_bounds(self, test_X: np.ndarray) -> list[list[PerBoundMargin]]:
225
+ """Per test point, the graded per-bound margins — the un-collapsed terms
226
+ signal() L2-combines. One PerBoundMargin per (feature → corpus coord) the
227
+ detector can evaluate; margin > 0 means the point is past that bound. The
228
+ per-coordinate math mirrors signal() exactly."""
229
+ out: list[list[PerBoundMargin]] = []
230
+ for x in test_X:
231
+ row: list[PerBoundMargin] = []
232
+ for j, fname in enumerate(self.feature_names):
233
+ coord = FEATURE_TO_CORPUS_COORD.get(fname)
234
+ if coord is None or coord not in self.valid_ranges:
235
+ continue
236
+ if fname in ("log10_Re", "log10_x_over_D"):
237
+ value = 10.0 ** float(x[j])
238
+ else:
239
+ value = float(x[j])
240
+ vmin, vmax = self.valid_ranges[coord]
241
+ if value < vmin:
242
+ margin = (vmin - value) / max(vmin, 1e-9)
243
+ side = "below"
244
+ elif value > vmax:
245
+ margin = (value - vmax) / max(vmax, 1e-9)
246
+ side = "above"
247
+ else:
248
+ margin = 0.0
249
+ side = "inside"
250
+ row.append(PerBoundMargin(
251
+ coord=coord, feature_name=fname, margin=float(margin),
252
+ bound_status=self.bound_status.get(coord, ""), side=side,
253
+ ))
254
+ out.append(row)
255
+ return out
256
+
257
+ def governing_bound(
258
+ self, per_bound: Sequence[PerBoundMargin], *, status_weighting: bool = True,
259
+ ) -> PerBoundMargin | None:
260
+ """The governing (dispositive) fired bound for one point: the most-
261
+ violated, status-weighted when `status_weighting` (a confirmed bound
262
+ outranks a claimed one at equal margin). None if the point is inside
263
+ every bound."""
264
+ fired = [b for b in per_bound if b.margin > 0.0]
265
+ if not fired:
266
+ return None
267
+
268
+ def _key(b: PerBoundMargin) -> float:
269
+ w = (_STATUS_WEIGHT.get(b.bound_status, _DEFAULT_STATUS_WEIGHT)
270
+ if status_weighting else 1.0)
271
+ return w * b.margin
272
+
273
+ return max(fired, key=_key)
@@ -0,0 +1,287 @@
1
+ """Per-vehicle parameterization shared by the observability score (D1) and the
2
+ Stage-1 ensemble sweep (D2).
3
+
4
+ The failure-region split, the surrogate-input + validity feature spaces, the
5
+ closure_id, and the failure-driving variable are ALL derived here from the
6
+ VehicleConfig (chiefly `cell_bands.type`). D1 computes the observability score
7
+ over the failure region; D2 evaluates the sweep over the same region. Defining
8
+ the region ONCE here is what keeps them from drifting apart — if they diverged,
9
+ the Stage-3 axis plot would pair a score over one region with a verdict over
10
+ another (a silent correctness bug).
11
+
12
+ Step-scope: the NACA (`x_over_d_bands`) row is wired now. Forrest (`re_bands`)
13
+ is added with the observability work (Step 1); Mudhafar (`dh_roughness_bands`)
14
+ and Testi-Grassi (`richardson_bands`) with the scaffolding (Step 4). Unwired
15
+ band types raise a clear NotImplementedError naming the step that wires them.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import Callable
22
+
23
+ from physmap.substrate.vehicle_config import VehicleConfig
24
+
25
+
26
+ # Failure-driving variable per cell-bands type — the meta key whose
27
+ # recoverability from the surrogate inputs IS the observability score.
28
+ # None = the vehicle has no single failure axis (caller must pass failure_var).
29
+ FAILURE_VAR_BY_CELL_BANDS_TYPE: dict[str, str | None] = {
30
+ "x_over_d_bands": "x_over_D", # NACA — entrance region
31
+ "re_bands": "Re", # Forrest — sub-critical Re
32
+ "dh_roughness_bands": "Dh_um", # Mudhafar — hydraulic diameter
33
+ "richardson_bands": "Ri", # buoyancy middle vehicles (Testi-Grassi)
34
+ "buoyancy_parameter_bands": "Bu", # Jin sCO2 vertical tube — Liu buoyancy parameter
35
+ "property_variation_bands": "ratio_mu_w_b", # Velazquez sCO2 — wall/bulk viscosity ratio
36
+ "freestream_disturbance_bands": "freestream_noise_pct", # Casper — tunnel freestream noise
37
+ "entropy_layer_shock_interaction_bands": "st_xsw_ratio", # Marineau — S_T/X_SW (bluntness)
38
+ "continuous": None,
39
+ "custom": None,
40
+ }
41
+
42
+ # NACA entrance-region locked boundary: x/D >= 10 trains (fully developed);
43
+ # x/D < 10 is the failure region. Mirrors phase1_gate.X_OVER_D_CUTOFF.
44
+ NACA_X_OVER_D_CUTOFF = 10.0
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class SplitSpec:
49
+ """How the failure region is carved from a vehicle's operating points.
50
+
51
+ `train_predicate` / `test_predicate` operate on a row's `.meta` dict. The
52
+ failure region (test) is where the surrogate is deployed beyond the regime
53
+ its closure was validated on; train is the in-distribution complement.
54
+ """
55
+ kind: str
56
+ train_predicate: Callable[[dict], bool]
57
+ test_predicate: Callable[[dict], bool]
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class VehicleSpec:
62
+ """Everything the observability score + Stage-1 sweep need that is not in
63
+ the bare VehicleConfig: the two feature spaces, the closure for the
64
+ validity detector, the failure-driving variable, and the failure-region
65
+ split."""
66
+ vehicle_id: str
67
+ baseline_feature_names: tuple[str, ...]
68
+ validity_feature_names: tuple[str, ...]
69
+ closure_id: str
70
+ failure_var: str
71
+ split: SplitSpec
72
+
73
+
74
+ def vehicle_spec(cfg: VehicleConfig) -> VehicleSpec:
75
+ """Derive the VehicleSpec from a VehicleConfig, keyed on `cell_bands.type`."""
76
+ bands_type = cfg.cell_bands.type
77
+ closure_id = cfg.matched_closure_id
78
+
79
+ if bands_type == "x_over_d_bands":
80
+ # NACA: baselines see (log10_Re, Pr); the validity detector also sees
81
+ # x/D (the omitted failure axis). Locked cutoff x/D = 10. These feature
82
+ # spaces + closure reproduce phase1_gate's constants exactly, so the
83
+ # sweep's NACA arm is byte-identical to the locked gate.
84
+ cutoff = NACA_X_OVER_D_CUTOFF
85
+ return VehicleSpec(
86
+ vehicle_id=cfg.vehicle_id,
87
+ baseline_feature_names=("log10_Re", "Pr"),
88
+ validity_feature_names=("log10_Re", "Pr", "x_over_D"),
89
+ closure_id=closure_id,
90
+ failure_var="x_over_D",
91
+ split=SplitSpec(
92
+ kind="x_over_d_threshold",
93
+ train_predicate=lambda m, c=cutoff: m["x_over_D"] >= c,
94
+ test_predicate=lambda m, c=cutoff: m["x_over_D"] < c,
95
+ ),
96
+ )
97
+
98
+ if bands_type == "re_bands":
99
+ # Forrest: failure axis is Reynolds number, which IS a surrogate input
100
+ # (log10_Re) — the high-observability pole. The validity space carries
101
+ # no extra coord (Sparrow-Cur's validated range is (Re, Pr) only).
102
+ # Failure region = lowest Re band (sub-critical); train = top catch-all
103
+ # band (benign turbulent); the transition band is excluded from both
104
+ # (indeterminate, per forrest_resolvability).
105
+ bands = cfg.cell_bands.bands
106
+ sub_critical_cut = float(bands[0]["re_lt"]) # e.g. 4000 (sub-critical < this)
107
+ benign_lo = float(bands[-2]["re_lt"]) # e.g. 10000 (benign >= this)
108
+ return VehicleSpec(
109
+ vehicle_id=cfg.vehicle_id,
110
+ baseline_feature_names=("log10_Re", "Pr"),
111
+ validity_feature_names=("log10_Re", "Pr"),
112
+ closure_id=closure_id,
113
+ failure_var="Re",
114
+ split=SplitSpec(
115
+ kind="re_bands",
116
+ train_predicate=lambda m, lo=benign_lo: m["Re"] >= lo,
117
+ test_predicate=lambda m, c=sub_critical_cut: m["Re"] < c,
118
+ ),
119
+ )
120
+
121
+ if bands_type == "dh_roughness_bands":
122
+ # Mudhafar (out-of-envelope control): the steelman baseline is the
123
+ # cross-substrate feature space, which INCLUDES geometry (log10_Dh_mm).
124
+ # So the failure axis Dh IS a baseline input → high-observability /
125
+ # baseline-sufficient pole (the spec's prediction). Failure region =
126
+ # small-diameter OR rough cells; train = standard smooth.
127
+ bands = cfg.cell_bands.bands
128
+ dh_min = next((float(b["dh_um_min"]) for b in bands if "dh_um_min" in b), 200.0)
129
+ cross = ("log10_Re", "Pr", "log10_Dh_mm", "alpha_star",
130
+ "heating_pattern_indicator", "roughness_relative")
131
+ return VehicleSpec(
132
+ vehicle_id=cfg.vehicle_id,
133
+ baseline_feature_names=cross,
134
+ validity_feature_names=cross,
135
+ closure_id=closure_id,
136
+ failure_var="Dh_um",
137
+ split=SplitSpec(
138
+ kind="dh_roughness",
139
+ train_predicate=lambda m, lo=dh_min: (
140
+ float(m.get("Dh_um", 0.0)) >= lo and not m.get("rough", False)),
141
+ test_predicate=lambda m, lo=dh_min: (
142
+ float(m.get("Dh_um", 1e18)) < lo or bool(m.get("rough", False))),
143
+ ),
144
+ )
145
+
146
+ if bands_type == "richardson_bands":
147
+ # Buoyancy middle vehicle (Testi-Grassi): the surrogate is a
148
+ # forced-convection (Re, Pr) closure with the buoyancy variable
149
+ # (Richardson number) OMITTED. Ri is NOT a baseline input but is
150
+ # partially correlated with Re/heat flux → MIDDLE observability by
151
+ # construction. Failure region = where buoyancy matters (Ri >= the
152
+ # forced-dominated cutoff); train = forced-dominated (low Ri).
153
+ #
154
+ # The corpus Ri-bound IS wired (Dirker/Meyer/Reid water vehicle): the
155
+ # MATCHED closure aung-worku-mixed-convection-1986 carries the
156
+ # richardson_number ceiling (corpus.jsonl); detectors.extract_features
157
+ # supports "Ri"/"log10_Ri", and validity_feature_names is ("Ri",) below.
158
+ # NOTE: aung-worku bounds ONLY richardson_number, so the public
159
+ # CredibilityGuardrail's derived (log10_Re, Pr, Ri) validity space is
160
+ # behaviorally IDENTICAL to this ("Ri",) — Re/Pr have no aung-worku bound
161
+ # and contribute zero distance (verified). The dedicated mixed-convection
162
+ # closure (NOT the shared gnielinski) keeps the Ri bound off NACA/Forrest.
163
+ # baseline_feature_names keeps (log10_Re, Pr) — buoyancy omission from the
164
+ # surrogate inputs IS the failure mechanism.
165
+ bands = cfg.cell_bands.bands
166
+ ri_forced_cut = float(bands[0].get("ri_lt", 0.1))
167
+ return VehicleSpec(
168
+ vehicle_id=cfg.vehicle_id,
169
+ baseline_feature_names=("log10_Re", "Pr"),
170
+ validity_feature_names=("Ri",),
171
+ closure_id=closure_id,
172
+ failure_var="Ri",
173
+ split=SplitSpec(
174
+ kind="richardson_bands",
175
+ train_predicate=lambda m, c=ri_forced_cut: float(m["Ri"]) < c,
176
+ test_predicate=lambda m, c=ri_forced_cut: float(m["Ri"]) >= c,
177
+ ),
178
+ )
179
+
180
+ if bands_type == "buoyancy_parameter_bands":
181
+ # Jin sCO2 vertical-tube buoyancy vehicle: the surrogate is a CONSTANT-PROPERTY,
182
+ # direction-OMITTING Dittus-Boelter (Re, Pr) closure; the omitted failure driver is the Liu
183
+ # buoyancy parameter Bu (wall-aware), absent from the (Re, Pr) inputs and only PARTIALLY
184
+ # recoverable from them (it carries the wall-temperature/HTD signal) -> MIDDLE observability,
185
+ # MEASURED via cv_r2_knn. The matched closure dittus-boelter-buoyancy-sco2 carries the corpus
186
+ # Bu <= 1.3e-5 bound (REGIME_TO_CLOSURES[MIXED_CONVECTION_VERTICAL_TUBE]); validity_feature_names
187
+ # adds "Bu", so the corpus detector fires in the upward-HTD deploy while the (Re, Pr) baseline
188
+ # stays quiet (the matched up/down inputs are identical — the A6(ii) toggle). The benign/deploy
189
+ # split is the physical regime label `split_role` (benign = downward / high-G / low-q NHT;
190
+ # deploy = upward HTD), precomputed offline — NOT a Bu threshold (downward q=43.9 has Bu>1.3e-5
191
+ # from buoyancy ENHANCEMENT yet is benign NHT). baseline keeps (log10_Re, Pr): buoyancy
192
+ # omission from the surrogate inputs IS the failure mechanism.
193
+ return VehicleSpec(
194
+ vehicle_id=cfg.vehicle_id,
195
+ baseline_feature_names=("log10_Re", "Pr"),
196
+ validity_feature_names=("log10_Re", "Pr", "Bu"),
197
+ closure_id=closure_id,
198
+ failure_var="Bu",
199
+ split=SplitSpec(
200
+ kind="buoyancy_parameter_bands",
201
+ train_predicate=lambda m: str(m["split_role"]) == "benign",
202
+ test_predicate=lambda m: str(m["split_role"]) == "deploy",
203
+ ),
204
+ )
205
+
206
+ if bands_type == "property_variation_bands":
207
+ # Velazquez sCO2 property-variation middle vehicle: the surrogate is a
208
+ # CONSTANT-PROPERTY (Re, Pr) closure (Gnielinski); the wall/bulk viscosity
209
+ # ratio mu_w/mu_b is OMITTED and drives the failure near the pseudo-critical
210
+ # point. mu_w/mu_b is NOT a baseline input but is partially correlated with
211
+ # Pr (which rises toward pseudo-critical) -> MIDDLE observability, to be
212
+ # MEASURED (could land nearer the observable pole; that is the open question).
213
+ # Buoyancy confound isolated by pressure: only p >= p_min_mpa, where the paper
214
+ # (Fig. 11) shows buoyancy negligible (>= 15 MPa). Failure region = near
215
+ # pseudo-critical (|T_b - T_pc| < abs_dt_pc_lt); train = far from it (benign
216
+ # property variation), same pressure subset.
217
+ bands = cfg.cell_bands.bands
218
+ b0 = bands[0]
219
+ p_min = float(b0.get("p_min_mpa", 15.0))
220
+ dt_cut = float(b0.get("abs_dt_pc_lt", 10.0))
221
+ return VehicleSpec(
222
+ vehicle_id=cfg.vehicle_id,
223
+ baseline_feature_names=("log10_Re", "Pr"),
224
+ validity_feature_names=("log10_Re", "Pr", "ratio_mu_w_b"),
225
+ closure_id=closure_id,
226
+ failure_var="ratio_mu_w_b",
227
+ split=SplitSpec(
228
+ kind="property_variation_bands",
229
+ train_predicate=lambda m, p=p_min, c=dt_cut: (
230
+ float(m["p_MPa"]) >= p and float(m["abs_dT_pc"]) >= c),
231
+ test_predicate=lambda m, p=p_min, c=dt_cut: (
232
+ float(m["p_MPa"]) >= p and float(m["abs_dT_pc"]) < c),
233
+ ),
234
+ )
235
+
236
+ if bands_type == "freestream_disturbance_bands":
237
+ # Casper hypersonic transition (aerospace PHYSMAP_WINS). The surrogate
238
+ # sees (Mach, unit Reynolds, axial x); the omitted driver is the tunnel
239
+ # freestream disturbance (freestream_noise_pct), a distinct flow-
240
+ # environment axis NOT recoverable from the inputs -> UNOBSERVABLE. The
241
+ # validity space adds freestream_noise_pct, and the corpus Pate-Stainback
242
+ # bound fires on the QUIET deploy while the baseline stays silent (quiet
243
+ # deploy is interior in the Mach-spanned training). Train = noisy
244
+ # (fs >= cut), deploy = quiet (fs < cut); band[0].fs_lt is the 0.5% edge.
245
+ fs_cut = float(cfg.cell_bands.bands[0].get("fs_lt", 0.5))
246
+ return VehicleSpec(
247
+ vehicle_id=cfg.vehicle_id,
248
+ baseline_feature_names=("M", "Re_per_m_e6", "x_m"),
249
+ validity_feature_names=("M", "Re_per_m_e6", "x_m", "freestream_noise_pct"),
250
+ closure_id=closure_id,
251
+ failure_var="freestream_noise_pct",
252
+ split=SplitSpec(
253
+ kind="freestream_disturbance_bands",
254
+ train_predicate=lambda m, c=fs_cut: float(m["freestream_noise_pct"]) >= c,
255
+ test_predicate=lambda m, c=fs_cut: float(m["freestream_noise_pct"]) < c,
256
+ ),
257
+ )
258
+
259
+ if bands_type == "entropy_layer_shock_interaction_bands":
260
+ # Marineau bluntness transition (aerospace NEGATIVE CONTROL). The
261
+ # surrogate sees (unit Reynolds, nose radius). The failure driver
262
+ # S_T/X_SW (st_xsw_ratio) is a deterministic function of nose radius (a
263
+ # surrogate input) at fixed Mach -> OBSERVABLE; the steelman baseline
264
+ # fires on the large-bluntness deploy (exterior in Rn). The validity
265
+ # space adds st_xsw_ratio and the corpus bound fires there too, but the
266
+ # baseline is NOT quiet -> no clean lift (the discrimination control).
267
+ # Train = benign (S_T/X_SW >= cut), deploy = failure (< cut).
268
+ st_cut = float(cfg.cell_bands.bands[0].get("st_xsw_lt", 0.1))
269
+ return VehicleSpec(
270
+ vehicle_id=cfg.vehicle_id,
271
+ baseline_feature_names=("Re_per_m", "Rn_mm"),
272
+ validity_feature_names=("Re_per_m", "Rn_mm", "st_xsw_ratio"),
273
+ closure_id=closure_id,
274
+ failure_var="st_xsw_ratio",
275
+ split=SplitSpec(
276
+ kind="entropy_layer_shock_interaction_bands",
277
+ train_predicate=lambda m, c=st_cut: float(m["st_xsw_ratio"]) >= c,
278
+ test_predicate=lambda m, c=st_cut: float(m["st_xsw_ratio"]) < c,
279
+ ),
280
+ )
281
+
282
+ raise NotImplementedError(
283
+ f"vehicle_spec: cell_bands.type={bands_type!r} not wired. "
284
+ f"Supported: x_over_d_bands, re_bands, dh_roughness_bands, richardson_bands, "
285
+ f"buoyancy_parameter_bands, property_variation_bands, freestream_disturbance_bands, "
286
+ f"entropy_layer_shock_interaction_bands."
287
+ )
physmap/release.py ADDED
@@ -0,0 +1,81 @@
1
+ """Release state — what this build of PhysMAP is allowed to claim.
2
+
3
+ Three states. The state is a CONSTANT fixed at release time. It is deliberately
4
+ **not** derived from which files happen to be present on disk: a command must not
5
+ appear or disappear because a data directory was copied in. That would make the
6
+ package's claims a function of the user's filesystem instead of a function of what
7
+ was actually reviewed and released.
8
+
9
+ GUARDRAIL closure/observability method, the NACA x/D regression, and the
10
+ v0.4 seven-vehicle benchmark. No causal-materiality claim.
11
+
12
+ CAUSAL_PREVIEW the above, plus the causal-materiality API exercised on
13
+ synthetic and unit fixtures only. No precision/recall/F1,
14
+ no expected_metrics.json, and NO `reproduce` subcommand.
15
+
16
+ NAFEMS_REPRODUCTION the above, plus the complete redistributable benchmark:
17
+ a locked reconstruction protocol, independent experimental
18
+ truth, a geometry-appropriate forced-convection
19
+ counterfactual, and expected metrics.
20
+
21
+ Promotion to NAFEMS_REPRODUCTION is gated by `physmap.benchmarks.readiness`, which
22
+ is a RELEASE-ENGINEERING check run in CI before this constant may be changed. It is
23
+ never consulted at runtime to decide what the CLI offers.
24
+
25
+ Why the causal numbers in the NAFEMS abstract are not reproduced here: the original
26
+ study's inputs are not available, and the experimental-truth basis is unresolved.
27
+ A reconstruction under a locked protocol will produce its OWN numbers. Those will
28
+ not be the abstract's numbers, and this package never presents them as such.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from enum import Enum
34
+
35
+ __all__ = [
36
+ "ReleaseState",
37
+ "CURRENT_RELEASE_STATE",
38
+ "EvidenceState",
39
+ "emits_performance_metrics",
40
+ "has_reproduce_command",
41
+ "causal_api_is_preview",
42
+ ]
43
+
44
+
45
+ class ReleaseState(str, Enum):
46
+ GUARDRAIL = "guardrail"
47
+ CAUSAL_PREVIEW = "causal_preview"
48
+ NAFEMS_REPRODUCTION = "nafems_reproduction"
49
+
50
+
51
+ #: Set at release time. Changing this requires the readiness gate to pass in CI.
52
+ CURRENT_RELEASE_STATE: ReleaseState = ReleaseState.CAUSAL_PREVIEW
53
+
54
+
55
+ class EvidenceState(str, Enum):
56
+ """What kind of thing produced a result. Carried on every causal assessment and
57
+ screen so no emitted artifact can be mistaken for a validated performance claim."""
58
+
59
+ #: Generated by a synthetic or unit fixture. Demonstrates mechanics, proves nothing
60
+ #: about the physical world.
61
+ SYNTHETIC_FIXTURE = "synthetic_fixture"
62
+ #: A stated case: the refusal logic and its reason code, with no evidence inputs
63
+ #: behind it. The two screened-out vehicles are this.
64
+ DECLARATIVE = "declarative"
65
+ #: Backed by checked-in evidence inputs with cleared provenance.
66
+ MEASURED = "measured"
67
+
68
+
69
+ def emits_performance_metrics(state: ReleaseState | None = None) -> bool:
70
+ """True only where precision/recall/F1 may legitimately be produced."""
71
+ return (state or CURRENT_RELEASE_STATE) is ReleaseState.NAFEMS_REPRODUCTION
72
+
73
+
74
+ def has_reproduce_command(state: ReleaseState | None = None) -> bool:
75
+ """Whether the CLI exposes `reproduce`. Static per release, by design."""
76
+ return (state or CURRENT_RELEASE_STATE) is ReleaseState.NAFEMS_REPRODUCTION
77
+
78
+
79
+ def causal_api_is_preview(state: ReleaseState | None = None) -> bool:
80
+ """True where the causal-materiality API ships but carries no performance claim."""
81
+ return (state or CURRENT_RELEASE_STATE) is ReleaseState.CAUSAL_PREVIEW
@@ -0,0 +1,9 @@
1
+ """Controlled stress tests of the causal path.
2
+
3
+ A stress test here is a deliberately constructed situation with a known answer, used to show
4
+ what a check can and cannot see. It is a development demonstration, not an evaluation: no
5
+ performance metric is computed or implied. Each one reproduces from the checkout with one
6
+ command and is drift-checked against a committed bank, the same way `physmap benchmark run`
7
+ is -- and each is kept apart from that benchmark, which measures closure validity and
8
+ observability, not materiality.
9
+ """