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,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)