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,430 @@
|
|
|
1
|
+
"""Observability score — how recoverable a vehicle's failure-driving variable
|
|
2
|
+
is from its surrogate inputs, over the failure region. 0 (orthogonal, NACA-like
|
|
3
|
+
— only the literature corpus can see the failure) → 1 (on-axis, Forrest-like —
|
|
4
|
+
the failure axis IS an input, so baselines see it and the corpus is redundant).
|
|
5
|
+
|
|
6
|
+
LOCKED DEFAULT + INTERFACE; MIDDLE SCORES PROVISIONAL
|
|
7
|
+
-----------------------------------------------------
|
|
8
|
+
The locked default estimator is cross-validated R² of a k-NN regression of the
|
|
9
|
+
standardized failure variable on the standardized surrogate inputs, clipped to
|
|
10
|
+
[0,1] (`cv_r2_knn`). The estimator is a pluggable strategy; `max_spearman` and
|
|
11
|
+
`nmi_kraskov` are cross-checks.
|
|
12
|
+
|
|
13
|
+
CV-R² conflates "the failure variable IS an input" with "the failure variable
|
|
14
|
+
is recoverable in THIS dataset." These coincide at the poles (NACA orthogonal
|
|
15
|
+
by design → 0; Forrest Re is an input → 1) but can diverge in the middle, where
|
|
16
|
+
the score becomes sampling-dependent. Therefore:
|
|
17
|
+
|
|
18
|
+
* `cross_estimator_agreement` checks all three estimators agree on pole
|
|
19
|
+
placement (NACA ≈ 0, Forrest ≡ 1) — agreement is what licenses the lock.
|
|
20
|
+
* `subsampling_stability` resamples the region and flags a score `provisional`
|
|
21
|
+
if it is unstable — a middle vehicle's score is not trusted until it passes.
|
|
22
|
+
Poles pass naturally (orthogonal → stably ~0; degenerate → exactly 1).
|
|
23
|
+
|
|
24
|
+
The measure is locked in `physmap/results/prereg/observability_v0_1.json`
|
|
25
|
+
before any vehicle is classified, so the axis is not tuned to the verdicts it
|
|
26
|
+
is later plotted against.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass, replace as dc_replace
|
|
32
|
+
from typing import Sequence
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
from physmap.pipeline.detectors import extract_features_batch
|
|
37
|
+
from physmap.pipeline.vehicle_spec import vehicle_spec
|
|
38
|
+
from physmap.substrate.engine import build_substrate
|
|
39
|
+
from physmap.substrate.vehicle_config import load_named_vehicle
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── locked parameters (mirror observability_v0_1.json) ──────────────────────
|
|
43
|
+
|
|
44
|
+
DEFAULT_ESTIMATOR = "cv_r2_knn"
|
|
45
|
+
RNG_SEED = 20260605
|
|
46
|
+
K_NEIGHBORS = 5
|
|
47
|
+
SMALL_N_MIN = 5 # n < this -> no CV, in-sample fallback
|
|
48
|
+
CV_MIN_N = 8 # SMALL_N_MIN <= n < this -> LOO; else 5-fold
|
|
49
|
+
DEFAULT_STABILITY_TOLERANCE = 0.15 # subsampling std bar for "stable"
|
|
50
|
+
POLE_AGREEMENT_TOLERANCE = 0.25 # cross-estimator spread bar at the poles
|
|
51
|
+
|
|
52
|
+
# A failure-driving variable (a meta key) would appear in the surrogate-input
|
|
53
|
+
# space under one of these standardized feature names. If any is an input, the
|
|
54
|
+
# failure axis is directly observable → observability ≡ 1 by definition.
|
|
55
|
+
_FAILURE_VAR_INPUT_ALIASES: dict[str, tuple[str, ...]] = {
|
|
56
|
+
"Re": ("log10_Re", "Re"),
|
|
57
|
+
"x_over_D": ("x_over_D", "log10_x_over_D"),
|
|
58
|
+
"Dh_um": ("log10_Dh_mm", "Dh_um", "Dh_mm"),
|
|
59
|
+
"Ri": ("Ri", "log10_Ri"),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── result schemas ──────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ObservabilityResult:
|
|
67
|
+
score: float # in [0, 1] (NaN only for an empty region)
|
|
68
|
+
estimator_name: str
|
|
69
|
+
n_region: int
|
|
70
|
+
degenerate_on_axis: bool # failure var IS a surrogate input
|
|
71
|
+
raw_statistic: float | None # pre-clip statistic (None if degenerate)
|
|
72
|
+
notes: str
|
|
73
|
+
provisional: bool = False # middle-vehicle score not yet stability-confirmed
|
|
74
|
+
stability: dict | None = None
|
|
75
|
+
|
|
76
|
+
def as_dict(self) -> dict:
|
|
77
|
+
return {
|
|
78
|
+
"score": self.score, "estimator": self.estimator_name,
|
|
79
|
+
"n_region": self.n_region, "degenerate_on_axis": self.degenerate_on_axis,
|
|
80
|
+
"raw_statistic": self.raw_statistic, "notes": self.notes,
|
|
81
|
+
"provisional": self.provisional, "stability": self.stability,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class StabilityResult:
|
|
87
|
+
mean: float
|
|
88
|
+
std: float
|
|
89
|
+
spread_p5_p95: float
|
|
90
|
+
n_boot: int
|
|
91
|
+
tolerance: float
|
|
92
|
+
stable: bool
|
|
93
|
+
estimator_name: str
|
|
94
|
+
notes: str = ""
|
|
95
|
+
|
|
96
|
+
def as_dict(self) -> dict:
|
|
97
|
+
return {
|
|
98
|
+
"mean": self.mean, "std": self.std, "spread_p5_p95": self.spread_p5_p95,
|
|
99
|
+
"n_boot": self.n_boot, "tolerance": self.tolerance, "stable": self.stable,
|
|
100
|
+
"estimator": self.estimator_name, "notes": self.notes,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── estimators (pluggable strategies; cv_r2_knn is the locked default) ───────
|
|
105
|
+
|
|
106
|
+
def _zscore(M: np.ndarray) -> np.ndarray:
|
|
107
|
+
M = np.asarray(M, dtype=float)
|
|
108
|
+
mu = M.mean(axis=0)
|
|
109
|
+
sd = M.std(axis=0)
|
|
110
|
+
sd = np.where(sd == 0.0, 1.0, sd)
|
|
111
|
+
return (M - mu) / sd
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _est_cv_r2_knn(Y: np.ndarray, X: np.ndarray, rng_seed: int,
|
|
115
|
+
k_neighbors: int = K_NEIGHBORS) -> tuple[float, str]:
|
|
116
|
+
"""Cross-validated R² of k-NN regression: failure var ~ surrogate inputs.
|
|
117
|
+
|
|
118
|
+
This is "fraction of the failure variable's variance recoverable from the
|
|
119
|
+
inputs." 0 when inputs carry no info (NACA: x/D varies at fixed Re,Pr →
|
|
120
|
+
negative R² → clipped to 0). Robust at small n via an LOO / in-sample
|
|
121
|
+
ladder.
|
|
122
|
+
"""
|
|
123
|
+
from sklearn.neighbors import KNeighborsRegressor
|
|
124
|
+
from sklearn.model_selection import KFold, LeaveOneOut, cross_val_score
|
|
125
|
+
from sklearn.metrics import r2_score
|
|
126
|
+
|
|
127
|
+
n = len(Y)
|
|
128
|
+
Xs = _zscore(X)
|
|
129
|
+
Ys = _zscore(Y.reshape(-1, 1)).ravel()
|
|
130
|
+
|
|
131
|
+
if n < SMALL_N_MIN:
|
|
132
|
+
k = min(k_neighbors, max(1, n - 1))
|
|
133
|
+
model = KNeighborsRegressor(n_neighbors=k)
|
|
134
|
+
model.fit(Xs, Ys)
|
|
135
|
+
r2 = float(r2_score(Ys, model.predict(Xs))) # optimistic in-sample
|
|
136
|
+
return r2, f"cv_r2_knn:small_n_in_sample(n={n},k={k})"
|
|
137
|
+
|
|
138
|
+
if n < CV_MIN_N:
|
|
139
|
+
# LOO: each train fold is all-but-one — no sorted-data fold artifact.
|
|
140
|
+
splitter = LeaveOneOut()
|
|
141
|
+
k = min(k_neighbors, max(1, n - 2)) # train fold has n-1 pts
|
|
142
|
+
note = f"cv_r2_knn:loo(n={n},k={k})"
|
|
143
|
+
else:
|
|
144
|
+
# SHUFFLED 5-fold. Without shuffle, KFold makes contiguous folds, which
|
|
145
|
+
# on Re-sorted rows become extrapolation blocks and wreck R^2 — a fold
|
|
146
|
+
# artifact, not a property of the data. Shuffle (seeded) fixes it.
|
|
147
|
+
splitter = KFold(n_splits=5, shuffle=True, random_state=rng_seed)
|
|
148
|
+
k = min(k_neighbors, max(1, (n - n // 5) - 1))
|
|
149
|
+
note = f"cv_r2_knn:5fold_shuffled(n={n},k={k})"
|
|
150
|
+
|
|
151
|
+
model = KNeighborsRegressor(n_neighbors=k)
|
|
152
|
+
scores = cross_val_score(model, Xs, Ys, cv=splitter, scoring="r2")
|
|
153
|
+
return float(np.mean(scores)), note
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _est_max_spearman(Y: np.ndarray, X: np.ndarray, rng_seed: int,
|
|
157
|
+
k_neighbors: int = K_NEIGHBORS) -> tuple[float, str]:
|
|
158
|
+
"""Max |Spearman rho| between the failure var and any single input.
|
|
159
|
+
Cheapest, most robust at tiny n; per-input + monotone-only cross-check."""
|
|
160
|
+
import warnings
|
|
161
|
+
from scipy.stats import ConstantInputWarning, spearmanr
|
|
162
|
+
best = 0.0
|
|
163
|
+
with warnings.catch_warnings():
|
|
164
|
+
warnings.simplefilter("ignore", ConstantInputWarning)
|
|
165
|
+
for j in range(X.shape[1]):
|
|
166
|
+
rho, _ = spearmanr(X[:, j], Y) # NaN if a column is constant
|
|
167
|
+
if not np.isnan(rho):
|
|
168
|
+
best = max(best, abs(float(rho)))
|
|
169
|
+
return best, "max_abs_spearman"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _est_nmi_kraskov(Y: np.ndarray, X: np.ndarray, rng_seed: int,
|
|
173
|
+
k_neighbors: int = 3) -> tuple[float, str]:
|
|
174
|
+
"""Kraskov k-NN mutual information (per input), mapped to a correlation-
|
|
175
|
+
equivalent on [0,1) via the Gaussian relation rho = sqrt(1 - exp(-2·MI)).
|
|
176
|
+
A nonlinear-dependence cross-check; uses the strongest single input as a
|
|
177
|
+
lower bound on joint recoverability."""
|
|
178
|
+
from sklearn.feature_selection import mutual_info_regression
|
|
179
|
+
n = len(Y)
|
|
180
|
+
mi = mutual_info_regression(
|
|
181
|
+
X, Y, n_neighbors=min(k_neighbors, max(1, n - 1)), random_state=rng_seed,
|
|
182
|
+
)
|
|
183
|
+
mi_max = float(np.max(mi)) if len(mi) else 0.0
|
|
184
|
+
nmi = float(np.sqrt(1.0 - np.exp(-2.0 * max(0.0, mi_max))))
|
|
185
|
+
return nmi, "nmi_kraskov_gaussian_equiv(max_input)"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
ESTIMATORS = {
|
|
189
|
+
"cv_r2_knn": _est_cv_r2_knn,
|
|
190
|
+
"max_spearman": _est_max_spearman,
|
|
191
|
+
"nmi_kraskov": _est_nmi_kraskov,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── core score ───────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
def observability_score(
|
|
198
|
+
rows: Sequence,
|
|
199
|
+
failure_var: str,
|
|
200
|
+
surrogate_inputs: Sequence[str],
|
|
201
|
+
region_mask: np.ndarray | None = None,
|
|
202
|
+
estimator: str = DEFAULT_ESTIMATOR,
|
|
203
|
+
*,
|
|
204
|
+
rng_seed: int = RNG_SEED,
|
|
205
|
+
k_neighbors: int = K_NEIGHBORS,
|
|
206
|
+
) -> ObservabilityResult:
|
|
207
|
+
"""Observability of `failure_var` from `surrogate_inputs` over the region.
|
|
208
|
+
|
|
209
|
+
Order of operations:
|
|
210
|
+
1. Restrict to the region (region_mask, or all rows).
|
|
211
|
+
2. Degenerate check: if the failure variable IS a surrogate input,
|
|
212
|
+
return 1.0 without fitting (Forrest path).
|
|
213
|
+
3. Otherwise run the estimator; clip the raw statistic to [0,1].
|
|
214
|
+
"""
|
|
215
|
+
if estimator not in ESTIMATORS:
|
|
216
|
+
raise ValueError(f"unknown estimator {estimator!r}; choices: {sorted(ESTIMATORS)}")
|
|
217
|
+
|
|
218
|
+
if region_mask is not None:
|
|
219
|
+
region_mask = np.asarray(region_mask, dtype=bool)
|
|
220
|
+
region_rows = [r for r, m in zip(rows, region_mask) if m]
|
|
221
|
+
else:
|
|
222
|
+
region_rows = list(rows)
|
|
223
|
+
n = len(region_rows)
|
|
224
|
+
|
|
225
|
+
# Degenerate (on-axis) check — purely structural, independent of the data.
|
|
226
|
+
aliases = _FAILURE_VAR_INPUT_ALIASES.get(failure_var, (failure_var,))
|
|
227
|
+
inputs = tuple(surrogate_inputs)
|
|
228
|
+
hit = next((a for a in aliases if a in inputs), None)
|
|
229
|
+
if hit is not None:
|
|
230
|
+
return ObservabilityResult(
|
|
231
|
+
score=1.0, estimator_name=estimator, n_region=n,
|
|
232
|
+
degenerate_on_axis=True, raw_statistic=None,
|
|
233
|
+
notes=(f"failure var {failure_var!r} is a surrogate input "
|
|
234
|
+
f"(as {hit!r}); observability = 1 by definition"),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if n < 3:
|
|
238
|
+
return ObservabilityResult(
|
|
239
|
+
score=float("nan"), estimator_name=estimator, n_region=n,
|
|
240
|
+
degenerate_on_axis=False, raw_statistic=None,
|
|
241
|
+
notes=f"region too small to estimate (n={n} < 3)",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
Y = np.array([float(r.meta[failure_var]) for r in region_rows], dtype=float)
|
|
245
|
+
if float(np.std(Y)) == 0.0:
|
|
246
|
+
# Failure var constant over the region → nothing to recover; treat as
|
|
247
|
+
# orthogonal (no information gradient for the inputs to track).
|
|
248
|
+
return ObservabilityResult(
|
|
249
|
+
score=0.0, estimator_name=estimator, n_region=n,
|
|
250
|
+
degenerate_on_axis=False, raw_statistic=0.0,
|
|
251
|
+
notes=f"failure var {failure_var!r} constant over region; score=0",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
X = extract_features_batch([r.meta for r in region_rows], list(surrogate_inputs))
|
|
255
|
+
raw, note = ESTIMATORS[estimator](Y, X, rng_seed, k_neighbors)
|
|
256
|
+
score = float(np.clip(raw, 0.0, 1.0))
|
|
257
|
+
return ObservabilityResult(
|
|
258
|
+
score=score, estimator_name=estimator, n_region=n,
|
|
259
|
+
degenerate_on_axis=False, raw_statistic=float(raw), notes=note,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── per-vehicle convenience wrapper ──────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
def vehicle_observability(
|
|
266
|
+
vehicle_id: str,
|
|
267
|
+
*,
|
|
268
|
+
estimator: str = DEFAULT_ESTIMATOR,
|
|
269
|
+
region: str = "failure",
|
|
270
|
+
csv_override: str | None = None,
|
|
271
|
+
check_stability: bool = False,
|
|
272
|
+
stability_tolerance: float = DEFAULT_STABILITY_TOLERANCE,
|
|
273
|
+
rng_seed: int = RNG_SEED,
|
|
274
|
+
) -> ObservabilityResult:
|
|
275
|
+
"""Observability of a named vehicle. Uses `vehicle_spec()` so the region is
|
|
276
|
+
the SAME failure region the Stage-1 sweep evaluates (D1/D2 must agree)."""
|
|
277
|
+
cfg = load_named_vehicle(vehicle_id)
|
|
278
|
+
if csv_override is not None:
|
|
279
|
+
from dataclasses import replace as _dc
|
|
280
|
+
cfg = _dc(cfg, data_source=_dc(cfg.data_source, path=csv_override))
|
|
281
|
+
spec = vehicle_spec(cfg)
|
|
282
|
+
rows, _, _ = build_substrate(cfg)
|
|
283
|
+
|
|
284
|
+
if region == "failure":
|
|
285
|
+
mask = np.array([spec.split.test_predicate(r.meta) for r in rows], dtype=bool)
|
|
286
|
+
elif region == "all":
|
|
287
|
+
mask = np.ones(len(rows), dtype=bool)
|
|
288
|
+
else:
|
|
289
|
+
raise ValueError(f"region must be 'failure' or 'all', got {region!r}")
|
|
290
|
+
|
|
291
|
+
res = observability_score(
|
|
292
|
+
rows, spec.failure_var, spec.baseline_feature_names,
|
|
293
|
+
region_mask=mask, estimator=estimator, rng_seed=rng_seed,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Stability gate: non-degenerate vehicles only. Poles pass naturally
|
|
297
|
+
# (orthogonal → stably ~0), so no hardcoded pole-exemption list is needed.
|
|
298
|
+
if check_stability and not res.degenerate_on_axis and res.n_region >= 4:
|
|
299
|
+
region_rows = [r for r, m in zip(rows, mask) if m]
|
|
300
|
+
stab = subsampling_stability(
|
|
301
|
+
region_rows, spec.failure_var, spec.baseline_feature_names,
|
|
302
|
+
estimator=estimator, tolerance=stability_tolerance, rng_seed=rng_seed,
|
|
303
|
+
)
|
|
304
|
+
res = dc_replace(res, provisional=(not stab.stable), stability=stab.as_dict())
|
|
305
|
+
return res
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ── estimator-validity checks (gate whether the axis means anything) ─────────
|
|
309
|
+
|
|
310
|
+
def subsampling_stability(
|
|
311
|
+
region_rows: Sequence,
|
|
312
|
+
failure_var: str,
|
|
313
|
+
surrogate_inputs: Sequence[str],
|
|
314
|
+
*,
|
|
315
|
+
estimator: str = DEFAULT_ESTIMATOR,
|
|
316
|
+
n_boot: int = 200,
|
|
317
|
+
frac: float = 0.8,
|
|
318
|
+
tolerance: float = DEFAULT_STABILITY_TOLERANCE,
|
|
319
|
+
rng_seed: int = RNG_SEED,
|
|
320
|
+
) -> StabilityResult:
|
|
321
|
+
"""Resample the failure region (subsample `frac` without replacement) and
|
|
322
|
+
recompute the score `n_boot` times. A score is `stable` if its bootstrap
|
|
323
|
+
std is within `tolerance` — distinguishing physics-observability (stable
|
|
324
|
+
under resampling) from a sampling-design artifact (unstable). This is the
|
|
325
|
+
required gate before a MIDDLE vehicle's score is trusted on the axis."""
|
|
326
|
+
n = len(region_rows)
|
|
327
|
+
if n < 4:
|
|
328
|
+
return StabilityResult(
|
|
329
|
+
mean=float("nan"), std=float("nan"), spread_p5_p95=float("nan"),
|
|
330
|
+
n_boot=0, tolerance=tolerance, stable=False, estimator_name=estimator,
|
|
331
|
+
notes=f"region too small to bootstrap (n={n} < 4)",
|
|
332
|
+
)
|
|
333
|
+
rng = np.random.default_rng(rng_seed)
|
|
334
|
+
size = max(3, int(round(frac * n)))
|
|
335
|
+
scores: list[float] = []
|
|
336
|
+
for b in range(n_boot):
|
|
337
|
+
idx = rng.choice(n, size=size, replace=False)
|
|
338
|
+
sub = [region_rows[i] for i in idx]
|
|
339
|
+
r = observability_score(
|
|
340
|
+
sub, failure_var, surrogate_inputs,
|
|
341
|
+
estimator=estimator, rng_seed=rng_seed + b + 1,
|
|
342
|
+
)
|
|
343
|
+
if not np.isnan(r.score):
|
|
344
|
+
scores.append(r.score)
|
|
345
|
+
arr = np.array(scores, dtype=float)
|
|
346
|
+
std = float(np.std(arr))
|
|
347
|
+
spread = float(np.percentile(arr, 95) - np.percentile(arr, 5))
|
|
348
|
+
return StabilityResult(
|
|
349
|
+
mean=float(np.mean(arr)), std=std, spread_p5_p95=spread,
|
|
350
|
+
n_boot=len(scores), tolerance=tolerance, stable=(std <= tolerance),
|
|
351
|
+
estimator_name=estimator,
|
|
352
|
+
notes=f"subsample frac={frac} without replacement",
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def cross_estimator_agreement(
|
|
357
|
+
vehicle_id: str,
|
|
358
|
+
*,
|
|
359
|
+
estimators: Sequence[str] = ("cv_r2_knn", "max_spearman", "nmi_kraskov"),
|
|
360
|
+
region: str = "failure",
|
|
361
|
+
tolerance: float = POLE_AGREEMENT_TOLERANCE,
|
|
362
|
+
csv_override: str | None = None,
|
|
363
|
+
) -> dict:
|
|
364
|
+
"""Run every estimator on a pole and check they agree on its placement
|
|
365
|
+
(spread within `tolerance`). Agreement at the endpoints is what licenses
|
|
366
|
+
the locked default — if the estimator choice changed the score, it would
|
|
367
|
+
be the estimator, not the physics, doing the work."""
|
|
368
|
+
scores = {
|
|
369
|
+
est: vehicle_observability(
|
|
370
|
+
vehicle_id, estimator=est, region=region, csv_override=csv_override,
|
|
371
|
+
).score
|
|
372
|
+
for est in estimators
|
|
373
|
+
}
|
|
374
|
+
vals = [v for v in scores.values() if not np.isnan(v)]
|
|
375
|
+
spread = (max(vals) - min(vals)) if vals else float("nan")
|
|
376
|
+
return {
|
|
377
|
+
"vehicle_id": vehicle_id, "region": region, "scores": scores,
|
|
378
|
+
"spread": spread, "tolerance": tolerance,
|
|
379
|
+
"agree": bool(vals and spread <= tolerance),
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
def main(argv: list[str] | None = None) -> int:
|
|
386
|
+
import argparse
|
|
387
|
+
|
|
388
|
+
parser = argparse.ArgumentParser(
|
|
389
|
+
description="Compute a vehicle's observability score (0=orthogonal, 1=on-axis)."
|
|
390
|
+
)
|
|
391
|
+
parser.add_argument("vehicle_id", help="e.g. naca_tn1451, forrest")
|
|
392
|
+
parser.add_argument("--estimator", default=DEFAULT_ESTIMATOR, choices=sorted(ESTIMATORS))
|
|
393
|
+
parser.add_argument("--region", default="failure", choices=["failure", "all"])
|
|
394
|
+
parser.add_argument("--stability", action="store_true",
|
|
395
|
+
help="run the subsampling-stability gate (sets provisional)")
|
|
396
|
+
parser.add_argument("--agreement", action="store_true",
|
|
397
|
+
help="report cross-estimator pole-agreement instead")
|
|
398
|
+
parser.add_argument("--csv-override", default=None)
|
|
399
|
+
args = parser.parse_args(argv)
|
|
400
|
+
|
|
401
|
+
if args.agreement:
|
|
402
|
+
rep = cross_estimator_agreement(
|
|
403
|
+
args.vehicle_id, region=args.region, csv_override=args.csv_override,
|
|
404
|
+
)
|
|
405
|
+
print(f"cross-estimator agreement for {args.vehicle_id} ({args.region} region):")
|
|
406
|
+
for est, sc in rep["scores"].items():
|
|
407
|
+
print(f" {est:>14}: {sc:.4f}")
|
|
408
|
+
print(f" spread={rep['spread']:.4f} agree={rep['agree']} (tol={rep['tolerance']})")
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
res = vehicle_observability(
|
|
412
|
+
args.vehicle_id, estimator=args.estimator, region=args.region,
|
|
413
|
+
csv_override=args.csv_override, check_stability=args.stability,
|
|
414
|
+
)
|
|
415
|
+
print(f"vehicle: {args.vehicle_id}")
|
|
416
|
+
print(f"estimator: {res.estimator_name}")
|
|
417
|
+
print(f"region n: {res.n_region}")
|
|
418
|
+
print(f"degenerate: {res.degenerate_on_axis}")
|
|
419
|
+
print(f"raw stat: {res.raw_statistic}")
|
|
420
|
+
print(f"SCORE: {res.score:.4f}")
|
|
421
|
+
print(f"provisional:{res.provisional}")
|
|
422
|
+
if res.stability:
|
|
423
|
+
s = res.stability
|
|
424
|
+
print(f"stability: std={s['std']:.4f} (tol={s['tolerance']}) stable={s['stable']}")
|
|
425
|
+
print(f"notes: {res.notes}")
|
|
426
|
+
return 0
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
if __name__ == "__main__":
|
|
430
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""PhysMAP D3 — explicit Surrogate class for the real-surrogate protocol.
|
|
2
|
+
|
|
3
|
+
Per prereg v0.3 (locked 2026-06-05): the surrogate is an explicit
|
|
4
|
+
first-class object trained on a defined set, with .predict() and
|
|
5
|
+
.predict_variance() methods. The GP-variance baseline is the surrogate's
|
|
6
|
+
own posterior std — NOT a separately-fit GP. This removes the E1
|
|
7
|
+
ambiguity by making "what the surrogate predicts" a measurable quantity
|
|
8
|
+
distinct from baseline detectors.
|
|
9
|
+
|
|
10
|
+
Locked v0.3 choices:
|
|
11
|
+
- Model class: GP (sklearn GaussianProcessRegressor)
|
|
12
|
+
- Mean function: neutral (constant; normalize_y=True; NOT closure-as-mean)
|
|
13
|
+
- Inputs: (log10_Re, Pr) — surrogate inputs locked in prereg
|
|
14
|
+
- Kernel: one of {matern_5_2, matern_3_2, rbf} — selected per v0.4 screen
|
|
15
|
+
- Training set: fully-developed (x/D >= 10) ONLY; developing never in
|
|
16
|
+
training (per non-negotiable #2)
|
|
17
|
+
|
|
18
|
+
Surrogate-error categorization (locked v0.3, threshold values deferred
|
|
19
|
+
to v0.4 per calibration formulas):
|
|
20
|
+
ACCURATE: error <= accuracy_threshold (do-no-harm cell input)
|
|
21
|
+
MARGINAL: accuracy_threshold < error <= lift_threshold (dead band)
|
|
22
|
+
WRONG: error > lift_threshold (lift target)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Literal, Sequence
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from sklearn.gaussian_process import GaussianProcessRegressor
|
|
33
|
+
from sklearn.gaussian_process.kernels import (
|
|
34
|
+
Matern, RBF, ConstantKernel, WhiteKernel,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ErrorBucket = Literal["ACCURATE", "MARGINAL", "WRONG"]
|
|
39
|
+
|
|
40
|
+
KERNEL_MENU_V0_3 = ("matern_5_2", "matern_3_2", "rbf")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def make_kernel(kernel_name: str, n_features: int):
|
|
44
|
+
"""Build a sklearn kernel from the v0.3-locked menu."""
|
|
45
|
+
if kernel_name == "matern_5_2":
|
|
46
|
+
inner = Matern(length_scale=[1.0]*n_features,
|
|
47
|
+
length_scale_bounds=(1e-2, 1e2), nu=2.5)
|
|
48
|
+
elif kernel_name == "matern_3_2":
|
|
49
|
+
inner = Matern(length_scale=[1.0]*n_features,
|
|
50
|
+
length_scale_bounds=(1e-2, 1e2), nu=1.5)
|
|
51
|
+
elif kernel_name == "rbf":
|
|
52
|
+
inner = RBF(length_scale=[1.0]*n_features,
|
|
53
|
+
length_scale_bounds=(1e-2, 1e2))
|
|
54
|
+
else:
|
|
55
|
+
raise ValueError(f"Unknown kernel '{kernel_name}'; must be one of "
|
|
56
|
+
f"{KERNEL_MENU_V0_3}")
|
|
57
|
+
return (
|
|
58
|
+
ConstantKernel(constant_value=1.0, constant_value_bounds=(1e-3, 1e3))
|
|
59
|
+
* inner
|
|
60
|
+
+ WhiteKernel(noise_level=1.0, noise_level_bounds=(1e-5, 1e2))
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Surrogate:
|
|
66
|
+
"""Explicit surrogate trained on a defined set.
|
|
67
|
+
|
|
68
|
+
Per prereg v0.3: the surrogate is a GP with NEUTRAL mean (normalize_y=True
|
|
69
|
+
centers training y; equivalent to a constant prior mean), one of the
|
|
70
|
+
locked kernels, fit on training_X (n_train × n_features) + training_y.
|
|
71
|
+
|
|
72
|
+
.predict(test_X) returns the surrogate's prediction (the practitioner's
|
|
73
|
+
deployed prediction). .predict_variance(test_X) returns the surrogate's
|
|
74
|
+
own posterior std — this IS the GP-variance baseline (no separate fit).
|
|
75
|
+
"""
|
|
76
|
+
training_X: np.ndarray
|
|
77
|
+
training_y: np.ndarray
|
|
78
|
+
kernel_name: str = "matern_5_2"
|
|
79
|
+
n_restarts: int = 3
|
|
80
|
+
random_state: int = 20260605
|
|
81
|
+
|
|
82
|
+
_gp: GaussianProcessRegressor = field(init=False, default=None)
|
|
83
|
+
_n_features: int = field(init=False, default=0)
|
|
84
|
+
|
|
85
|
+
def __post_init__(self):
|
|
86
|
+
n, d = self.training_X.shape
|
|
87
|
+
if n < 2:
|
|
88
|
+
raise ValueError(f"Surrogate needs >= 2 training points, got {n}")
|
|
89
|
+
self._n_features = d
|
|
90
|
+
kernel = make_kernel(self.kernel_name, d)
|
|
91
|
+
self._gp = GaussianProcessRegressor(
|
|
92
|
+
kernel=kernel,
|
|
93
|
+
normalize_y=True, # constant-mean prior via training-y centering
|
|
94
|
+
n_restarts_optimizer=self.n_restarts,
|
|
95
|
+
random_state=self.random_state,
|
|
96
|
+
)
|
|
97
|
+
self._gp.fit(self.training_X, self.training_y)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def n_train(self) -> int:
|
|
101
|
+
return len(self.training_y)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def fitted_kernel(self):
|
|
105
|
+
return self._gp.kernel_
|
|
106
|
+
|
|
107
|
+
def predict(self, test_X: np.ndarray) -> np.ndarray:
|
|
108
|
+
"""Surrogate's prediction (posterior mean) at each test point."""
|
|
109
|
+
return self._gp.predict(test_X, return_std=False)
|
|
110
|
+
|
|
111
|
+
def predict_variance(self, test_X: np.ndarray) -> np.ndarray:
|
|
112
|
+
"""Surrogate's posterior std at each test point.
|
|
113
|
+
This IS the GP-variance baseline (no separate fit per prereg v0.3)."""
|
|
114
|
+
_, std = self._gp.predict(test_X, return_std=True)
|
|
115
|
+
return std
|
|
116
|
+
|
|
117
|
+
def predict_relative_variance(self, test_X: np.ndarray) -> np.ndarray:
|
|
118
|
+
"""Posterior std divided by surrogate predicted mean magnitude.
|
|
119
|
+
The relative variance is what the GP-variance detector reports."""
|
|
120
|
+
mean, std = self._gp.predict(test_X, return_std=True)
|
|
121
|
+
floor = max(1.0, float(np.abs(self.training_y).mean()) * 0.01)
|
|
122
|
+
return std / np.maximum(np.abs(mean), floor)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── surrogate error analysis ────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class SurrogateError:
|
|
129
|
+
"""Per-test-point surrogate prediction vs measured Nu, with categorization."""
|
|
130
|
+
test_X: np.ndarray
|
|
131
|
+
predicted_Nu: np.ndarray # surrogate's prediction
|
|
132
|
+
measured_Nu: np.ndarray # ground truth at test point
|
|
133
|
+
accuracy_threshold_pct: float # ACCURATE iff |error|/|measured| <= this
|
|
134
|
+
lift_threshold_pct: float # WRONG iff |error|/|measured| > this
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def abs_error(self) -> np.ndarray:
|
|
138
|
+
return np.abs(self.predicted_Nu - self.measured_Nu)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def rel_error_pct(self) -> np.ndarray:
|
|
142
|
+
return self.abs_error / np.maximum(np.abs(self.measured_Nu), 1e-9) * 100.0
|
|
143
|
+
|
|
144
|
+
def bucket(self) -> list[ErrorBucket]:
|
|
145
|
+
"""Per-point bucket: ACCURATE / MARGINAL / WRONG."""
|
|
146
|
+
rel_pct = self.rel_error_pct
|
|
147
|
+
buckets: list[ErrorBucket] = []
|
|
148
|
+
for r in rel_pct:
|
|
149
|
+
if r <= self.accuracy_threshold_pct:
|
|
150
|
+
buckets.append("ACCURATE")
|
|
151
|
+
elif r > self.lift_threshold_pct:
|
|
152
|
+
buckets.append("WRONG")
|
|
153
|
+
else:
|
|
154
|
+
buckets.append("MARGINAL")
|
|
155
|
+
return buckets
|
|
156
|
+
|
|
157
|
+
def is_accurate(self) -> np.ndarray:
|
|
158
|
+
return self.rel_error_pct <= self.accuracy_threshold_pct
|
|
159
|
+
|
|
160
|
+
def is_wrong(self) -> np.ndarray:
|
|
161
|
+
return self.rel_error_pct > self.lift_threshold_pct
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── threshold calibration (from prereg-locked formulas) ──────────────────────
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class ThresholdCalibration:
|
|
168
|
+
"""Implements the prereg-locked calibration formulas.
|
|
169
|
+
|
|
170
|
+
sigma_combined := sqrt(paper_unc_pct² + dig_unc_pct²) measured from dev
|
|
171
|
+
accuracy_threshold := 2.0 × sigma_combined (ACCURATE iff <=)
|
|
172
|
+
lift_threshold := 3.0 × sigma_combined (WRONG iff >)
|
|
173
|
+
|
|
174
|
+
Per user-directed decomposition (2026-06-05):
|
|
175
|
+
paper_uncertainty_pct = 3.0 (NACA TN-1451 line 329 REPRODUCIBILITY,
|
|
176
|
+
explicit text: 'reproducibility of values of fc was found to be
|
|
177
|
+
within 3 percent'). NOT 5%; the 5% is the MAX TOTAL EXPERIMENTAL
|
|
178
|
+
ERROR (different statistic that includes systematic + reading +
|
|
179
|
+
calibration bias on top of reproducibility). For sigma_combined
|
|
180
|
+
we use the per-measurement noise floor (3% reproducibility), which
|
|
181
|
+
is the right object to combine with digitization noise.
|
|
182
|
+
digitization_uncertainty_pct = MEASURED FROM WPD per-marker scatter
|
|
183
|
+
in the asymptotic region (where the true value is ~flat so
|
|
184
|
+
observed scatter is mostly digitization noise + paper-noise).
|
|
185
|
+
Use `dig_unc_from_observed_asymptotic_cv()` for the proper
|
|
186
|
+
quadrature-subtraction with the floor.
|
|
187
|
+
visual-vs-WPD delta: used to VERIFY the dig_unc estimate, NOT to
|
|
188
|
+
re-derive total noise from the figure (that would conflate
|
|
189
|
+
the two sources — the paper already gave us paper_unc).
|
|
190
|
+
|
|
191
|
+
v0.4 plugs in WPD-measured dig_unc; v0.4 may NOT change formulas.
|
|
192
|
+
"""
|
|
193
|
+
paper_uncertainty_pct: float = 3.0 # NACA TN-1451 reproducibility (line 329)
|
|
194
|
+
digitization_uncertainty_pct: float = 3.0 # WPD typical default; v0.4 sets from data
|
|
195
|
+
wpd_instrument_precision_floor_pct: float = 1.0 # WPD's stated reading precision
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def dig_unc_from_observed_asymptotic_cv(
|
|
199
|
+
observed_asymptotic_cv_pct: float,
|
|
200
|
+
paper_unc_pct: float = 3.0,
|
|
201
|
+
wpd_instrument_floor_pct: float = 1.0,
|
|
202
|
+
) -> tuple[float, str]:
|
|
203
|
+
"""Compute dig_unc by quadrature-subtracting paper noise from observed.
|
|
204
|
+
|
|
205
|
+
Per user 2026-06-05 directive (prevents imaginary-sqrt crash):
|
|
206
|
+
if the WPD asymptotic CV is ≤ paper_unc, the quadrature subtraction
|
|
207
|
+
sqrt(CV² − paper²) would go imaginary. That's NOT 'negative noise';
|
|
208
|
+
it's 'asymptotic scatter is below the paper-noise resolution floor'.
|
|
209
|
+
In that case, fall back to the WPD instrument's stated reading
|
|
210
|
+
precision (default 1%) as the dig_unc floor.
|
|
211
|
+
|
|
212
|
+
Returns (dig_unc_pct, regime_tag) where regime_tag describes
|
|
213
|
+
which branch was taken (informational, not used in calibration).
|
|
214
|
+
"""
|
|
215
|
+
if observed_asymptotic_cv_pct > paper_unc_pct:
|
|
216
|
+
# Normal regime: scatter dominated by dig noise above paper floor
|
|
217
|
+
dig_unc = float(np.sqrt(observed_asymptotic_cv_pct ** 2 -
|
|
218
|
+
paper_unc_pct ** 2))
|
|
219
|
+
return dig_unc, "above_paper_floor"
|
|
220
|
+
else:
|
|
221
|
+
# Quiet asymptote regime: scatter ≤ paper noise; use WPD instrument floor
|
|
222
|
+
return wpd_instrument_floor_pct, "below_paper_floor_use_instrument_precision"
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def sigma_combined(self) -> float:
|
|
226
|
+
return float(np.sqrt(self.paper_uncertainty_pct ** 2 +
|
|
227
|
+
self.digitization_uncertainty_pct ** 2))
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def accuracy_threshold_pct(self) -> float:
|
|
231
|
+
return 2.0 * self.sigma_combined
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def lift_threshold_pct(self) -> float:
|
|
235
|
+
return 3.0 * self.sigma_combined
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def dead_band_pct(self) -> tuple[float, float]:
|
|
239
|
+
return (self.accuracy_threshold_pct, self.lift_threshold_pct)
|
|
240
|
+
|
|
241
|
+
# Gate 1 derived thresholds (per locked formulas)
|
|
242
|
+
@property
|
|
243
|
+
def gate_1_median_fd_holdout_error_pct_max(self) -> float:
|
|
244
|
+
return 2.0 * self.sigma_combined
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def gate_1_p95_fd_holdout_error_pct_max(self) -> float:
|
|
248
|
+
return max(25.0, 3.0 * self.sigma_combined)
|
|
249
|
+
|
|
250
|
+
def gate_1_median_gp_relative_std_max(self, train_y: np.ndarray) -> float:
|
|
251
|
+
return 0.5 * float(train_y.std()) / max(float(np.abs(train_y).mean()), 1e-9)
|