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,324 @@
1
+ """Phase-2C defeasible-adjudication aggregator.
2
+
3
+ Per the v0.2 architecture refactor spec, Part 4:
4
+
5
+ > The Disposition is NOT an accumulation/vote of factors. It is a DEFEASIBLE
6
+ > ADJUDICATION of them. ... A weakener can be OFFSET by other evidence, made
7
+ > NON-DISPOSITIVE by agreement, MODULATED by threshold distance; a defeater
8
+ > can be SUSTAINED but JUSTIFIED with residual-risk rationale.
9
+
10
+ This module is the Phase-2 replacement for the simple Phase-1
11
+ `AnyFired`/`CorpusGated`/`WeightedVote` aggregators (Step 6) and for
12
+ the Phase-2A default action-class chooser in `assessment_v06.py`. It
13
+ takes the per-point WeakenerAnnotations and decision signals and
14
+ produces:
15
+
16
+ * action_class ∈ v0.6 DISPOSITION_ACTION_CLASSES
17
+ * residual_risk_justification (free text; required when
18
+ action_class == 'accept-residual-risk')
19
+ * offset_rationales — OffsetRationale nodes naming the
20
+ weakeners that were offset and why
21
+
22
+ ACTION-CLASS SELECTION RULES (the Phase-2C policy):
23
+
24
+ 0 weakeners -> accept-residual-risk
25
+ only literature (OutOfValidatedRange) fires -> characterize-region
26
+ (the v0.3 NACA finding: literature bound has geometry-coarse
27
+ precision; describe the region before deploying — see
28
+ docs/findings/PhysMAP_D3_NACA_EntranceRegion_Findings_v0_1.md)
29
+ novelty alone (distance / gp_variance / ensemble) -> acquire-validation
30
+ (input baselines fire; need more validation data in the OOD region)
31
+ novelty + literature fire -> restrict-cou
32
+ (both bases agree something is wrong; narrow the CoU)
33
+ corpus-error alone -> accept-residual-risk
34
+ (training-distribution error magnitude; explainable as known noise)
35
+ 3+ distinct patterns / fallback -> change-cou
36
+ (multiple uncorrelated weakeners; model swap or major change indicated)
37
+
38
+ These rules implement the spec's CHARACTER of defeasibility — the
39
+ controlled-vocab outputs are the v0.6-correct way to express "accept
40
+ with conditions" / "restrict" / "review" / etc. (the spec's abstract
41
+ verbs from the merged design map to these 5 strings via the disposition
42
+ SHACL pack).
43
+
44
+ OFFSETS are NOT generated by the default rules. They're a Phase-2C+
45
+ extension point: an OffsetRule callable can be plugged into the
46
+ adjudicator to produce OffsetRationale nodes (e.g., "agreement-non-
47
+ dispositive" when multiple detectors fire on the same Discrepancy by
48
+ construction, or "threshold-distance-modulates" when score barely
49
+ exceeds tau). Future weakener-engine patterns hook in here.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ from dataclasses import dataclass, field
55
+ from typing import Callable, Sequence
56
+
57
+ from physmap.pipeline.assessment_v06 import (
58
+ DISPOSITION_ACTION_CLASSES,
59
+ OffsetRationaleNode,
60
+ WeakenerAnnotationNode,
61
+ )
62
+ from physmap.pipeline.core import DetectorResult
63
+
64
+
65
+ # Pattern-id buckets (one entry per pattern produced by
66
+ # `assessment_v06._PATTERN_ID_BY_DETECTOR`). Keep in sync with that
67
+ # mapping; any drift here makes the adjudicator's rules vacuously
68
+ # inapplicable.
69
+ NOVELTY_PATTERNS = frozenset({
70
+ "InputOutOfTrainingDistribution",
71
+ "SurrogateVarianceExceedsThreshold",
72
+ "EnsembleDisagreement",
73
+ })
74
+ LITERATURE_PATTERN = "OutOfValidatedRange"
75
+ CORPUS_ERROR_PATTERN = "CorpusErrorMagnitudeAboveThreshold"
76
+
77
+
78
+ # ── offset-rule plug-in (Phase-2C+ extension point) ─────────────────────────
79
+
80
+ OffsetRuleFn = Callable[
81
+ [Sequence[WeakenerAnnotationNode], dict[str, DetectorResult], str],
82
+ list[OffsetRationaleNode],
83
+ ]
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class OffsetRule:
88
+ """A named, pluggable rule that may emit OffsetRationale nodes from a
89
+ set of weakeners + the underlying decision signals + the
90
+ Discrepancy/Disposition id. The default adjudicator ships with NO
91
+ rules — Phase-2C is intentionally conservative on offsets. Add rules
92
+ via `DefeasibleAdjudicator(offset_rules=[...])` to extend."""
93
+ name: str
94
+ apply: OffsetRuleFn
95
+
96
+
97
+ # ── adjudication result ─────────────────────────────────────────────────────
98
+
99
+ @dataclass(frozen=True)
100
+ class AdjudicationResult:
101
+ """Output of a single per-point adjudication. Populates the
102
+ Disposition's actionClass + residualRiskJustification and the
103
+ OffsetRationale node list the v0.6 subgraph carries alongside."""
104
+ action_class: str
105
+ residual_risk_justification: str
106
+ offset_rationales: list[OffsetRationaleNode] = field(default_factory=list)
107
+ surviving_weakener_ids: list[str] = field(default_factory=list)
108
+ rule_fired: str = "" # name of the action-class rule that fired
109
+
110
+ def __post_init__(self) -> None:
111
+ if self.action_class not in DISPOSITION_ACTION_CLASSES:
112
+ raise ValueError(
113
+ f"AdjudicationResult.action_class={self.action_class!r} not "
114
+ f"in v0.6 controlled vocab {DISPOSITION_ACTION_CLASSES}."
115
+ )
116
+ # accept-residual-risk REQUIRES residual_risk_justification, by
117
+ # both the spec (PART 4) and natural-language sense. SHACL doesn't
118
+ # pin this (it leaves residualRiskJustification optional at the
119
+ # NodeShape level), but the adjudicator is the right place to
120
+ # enforce it — wrong omission here would be a silent disposition.
121
+ if (self.action_class == "accept-residual-risk"
122
+ and not self.residual_risk_justification.strip()):
123
+ raise ValueError(
124
+ "AdjudicationResult: action_class='accept-residual-risk' "
125
+ "requires non-empty residual_risk_justification. "
126
+ "(Acceptance without rationale is the failure mode the "
127
+ "spec's defeasibility discipline guards against.)"
128
+ )
129
+
130
+
131
+ # ── the adjudicator ─────────────────────────────────────────────────────────
132
+
133
+ @dataclass
134
+ class DefeasibleAdjudicator:
135
+ """Phase-2C defeasible-adjudication aggregator. Reads per-point
136
+ WeakenerAnnotations (from the Phase-1 → v0.6 mapper) and decision
137
+ signals, applies offset rules, and emits an AdjudicationResult.
138
+
139
+ Designed to be plugged into `assessment_v06.assessment_to_v06_subgraph`
140
+ via the `adjudicator` kwarg (Phase-2C overrides the Phase-2A default
141
+ chooser).
142
+ """
143
+ name: str = "defeasible_adjudicator"
144
+ offset_rules: list[OffsetRule] = field(default_factory=list)
145
+
146
+ def adjudicate(
147
+ self,
148
+ weakeners: Sequence[WeakenerAnnotationNode],
149
+ decision_signals: dict[str, DetectorResult],
150
+ discrepancy_id: str,
151
+ ) -> AdjudicationResult:
152
+ # Step 1: apply offset rules. Each rule returns OffsetRationales
153
+ # that point at CredibilityFactor ids (refers_to_factor). The
154
+ # mapping from rationale -> weakener is by-pattern: any
155
+ # OffsetRationale whose refers_to_factor matches a weakener's
156
+ # corresponding factor id is considered to offset that weakener.
157
+ offsets: list[OffsetRationaleNode] = []
158
+ for rule in self.offset_rules:
159
+ offsets.extend(rule.apply(weakeners, decision_signals, discrepancy_id))
160
+
161
+ # Step 2: filter the weakener set down to "surviving" (not offset).
162
+ # An OffsetRationale refers to a CredibilityFactor by id; our
163
+ # WeakenerAnnotation ids embed the detector name (Phase-2A
164
+ # convention: `weakener:.../<detector_name>`), and the
165
+ # factor ids embed the same suffix. Match by id-suffix.
166
+ offset_factor_ids = {o.refers_to_factor for o in offsets}
167
+ def _is_offset(w: WeakenerAnnotationNode) -> bool:
168
+ # Phase-2A id convention: weakener:.../detector and factor:.../detector
169
+ # share the trailing detector segment. Strip the "weakener:" prefix.
170
+ detector_segment = w.id_.rsplit("/", 1)[-1]
171
+ for fid in offset_factor_ids:
172
+ if fid.endswith(f"/{detector_segment}"):
173
+ return True
174
+ return False
175
+
176
+ surviving = [w for w in weakeners if not _is_offset(w)]
177
+
178
+ # Step 3: action-class selection (the v0.6 controlled vocab dispatch).
179
+ action_class, residual_just, rule_fired = self._select_action_class(surviving)
180
+
181
+ return AdjudicationResult(
182
+ action_class=action_class,
183
+ residual_risk_justification=residual_just,
184
+ offset_rationales=offsets,
185
+ surviving_weakener_ids=[w.id_ for w in surviving],
186
+ rule_fired=rule_fired,
187
+ )
188
+
189
+ # ── action-class rules ──────────────────────────────────────────────────
190
+
191
+ def _select_action_class(
192
+ self, surviving: Sequence[WeakenerAnnotationNode],
193
+ ) -> tuple[str, str, str]:
194
+ """Map surviving weakeners → (action_class, residual_just, rule_fired).
195
+ The five-controlled-vocab dispatch documented in the module docstring.
196
+ """
197
+ if not surviving:
198
+ return (
199
+ "accept-residual-risk",
200
+ "No weakeners surviving adjudication; accept with residual "
201
+ "risk per default cautious policy. (No defeaters fired or all "
202
+ "were offset.)",
203
+ "no-weakeners",
204
+ )
205
+
206
+ patterns = {w.pattern_id for w in surviving}
207
+ has_novelty = bool(patterns & NOVELTY_PATTERNS)
208
+ has_literature = LITERATURE_PATTERN in patterns
209
+ has_corpus_error = CORPUS_ERROR_PATTERN in patterns
210
+
211
+ # Only the literature signal fires (no novelty, no corpus-error).
212
+ # This is the NACA misaligned-bound case per the v0.3 findings:
213
+ # the literature bound is geometry-coarse; characterize the region.
214
+ if patterns == {LITERATURE_PATTERN}:
215
+ return (
216
+ "characterize-region",
217
+ "",
218
+ "literature-only-misaligned-bound",
219
+ )
220
+
221
+ # Novelty + literature: both bases agree something is OOD. Restrict.
222
+ if has_novelty and has_literature:
223
+ return (
224
+ "restrict-cou",
225
+ "",
226
+ "novelty-and-literature-agree",
227
+ )
228
+
229
+ # Novelty alone (input distribution flags but no literature concern):
230
+ # acquire more validation data in the OOD region.
231
+ if has_novelty and not has_literature and not has_corpus_error:
232
+ return (
233
+ "acquire-validation",
234
+ "",
235
+ "novelty-alone",
236
+ )
237
+
238
+ # Corpus k-NN error alone — explainable noise, accept with risk.
239
+ if patterns == {CORPUS_ERROR_PATTERN}:
240
+ return (
241
+ "accept-residual-risk",
242
+ "Sole weakener is training-distribution corpus k-NN error "
243
+ "magnitude. This is a fit-quality signal, not an OOD or "
244
+ "literature-validity signal; accept with residual risk.",
245
+ "corpus-error-alone",
246
+ )
247
+
248
+ # Fallback: multi-pattern complex case (literature + corpus-error,
249
+ # or three+ patterns). The CoU itself may be miscast — change-cou.
250
+ return (
251
+ "change-cou",
252
+ "",
253
+ "complex-multi-pattern-fallback",
254
+ )
255
+
256
+
257
+ # ── built-in offset rules ───────────────────────────────────────────────────
258
+ #
259
+ # Phase-2C ships these as optional library entries; the default
260
+ # DefeasibleAdjudicator has them DISABLED (offset_rules=[]) until each
261
+ # is exercised by a downstream gate that wants the looser policy. The
262
+ # spec lists three offset mechanisms — implementing them as named rules
263
+ # makes their use deliberate.
264
+
265
+
266
+ def agreement_non_dispositive_rule(
267
+ weakeners: Sequence[WeakenerAnnotationNode],
268
+ decision_signals: dict[str, DetectorResult],
269
+ discrepancy_id: str,
270
+ ) -> list[OffsetRationaleNode]:
271
+ """When BOTH a novelty pattern AND the literature pattern fire on the
272
+ same Discrepancy, the AGREEMENT makes each weakener
273
+ non-dispositive — the two signals aren't independent voters; they're
274
+ different cuts of the same OOD condition. Per the v0.6 vocab
275
+ (`agreementMakesNonDispositive`), the literature weakener is the one
276
+ typically offset (since the input-distribution signal carries the
277
+ independent evidential weight).
278
+ """
279
+ patterns = {w.pattern_id for w in weakeners}
280
+ has_novelty = bool(patterns & NOVELTY_PATTERNS)
281
+ has_literature = LITERATURE_PATTERN in patterns
282
+ if not (has_novelty and has_literature):
283
+ return []
284
+ # Find the literature weakener and emit an OffsetRationale for the
285
+ # corresponding factor.
286
+ out: list[OffsetRationaleNode] = []
287
+ for w in weakeners:
288
+ if w.pattern_id == LITERATURE_PATTERN:
289
+ detector_segment = w.id_.rsplit("/", 1)[-1]
290
+ base = w.id_.replace("weakener:", "").rsplit("/", 1)[0]
291
+ factor_id = f"factor:{base}/{detector_segment}"
292
+ offset_id = f"offset:{base}/{detector_segment}/agreement-non-dispositive"
293
+ out.append(OffsetRationaleNode(
294
+ id_=offset_id,
295
+ refers_to_factor=factor_id,
296
+ justification=(
297
+ "agreementMakesNonDispositive: literature weakener "
298
+ "(OutOfValidatedRange) co-fires with an independent "
299
+ "input-distribution novelty weakener on the same "
300
+ "Discrepancy. The novelty signal carries the "
301
+ "evidential weight; the literature signal is "
302
+ "non-dispositive by agreement."
303
+ ),
304
+ ))
305
+ return out
306
+
307
+
308
+ AGREEMENT_NON_DISPOSITIVE = OffsetRule(
309
+ name="agreement-non-dispositive",
310
+ apply=agreement_non_dispositive_rule,
311
+ )
312
+
313
+
314
+ __all__ = [
315
+ "NOVELTY_PATTERNS",
316
+ "LITERATURE_PATTERN",
317
+ "CORPUS_ERROR_PATTERN",
318
+ "OffsetRule",
319
+ "OffsetRuleFn",
320
+ "AdjudicationResult",
321
+ "DefeasibleAdjudicator",
322
+ "agreement_non_dispositive_rule",
323
+ "AGREEMENT_NON_DISPOSITIVE",
324
+ ]
@@ -0,0 +1,309 @@
1
+ """PhysMAP D3 detectors — baseline + corpus signals for the differentiator test.
2
+
3
+ Per LOCKED design v0.2 (docs/specs/PhysMAP_D3_Detector_Design_LOCKED_v0_2.md):
4
+
5
+ - DistanceDetector: Mahalanobis k-NN distance to training set (k=3).
6
+ - GPVarianceDetector: GP with NEUTRAL mean function (constant fit),
7
+ Matérn-5/2 kernel. Primary variance baseline.
8
+ - EnsembleVarianceDetector: Bootstrapped degree-2 polynomial ensemble.
9
+ Secondary robustness check on GP.
10
+
11
+ All baseline detectors accept a configurable feature space:
12
+ Sets A/B (within-Forrest): ["log10_Re", "Pr"]
13
+ Sets C/D (cross-substrate): ["log10_Re", "Pr", "log10_Dh_mm", "alpha_star",
14
+ "heating_pattern_indicator", "roughness_relative"]
15
+
16
+ User-locked design choices (v0.2 pushbacks):
17
+ 1. Geometry features INCLUDED for Sets C/D distance baseline — steelman.
18
+ 2. Within-substrate training only (Forrest benign).
19
+ 3. GP mean is NEUTRAL (constant fit) — NOT closure-as-mean. Closure-as-mean
20
+ would re-leak the closure into the baseline; variance would behave oddly
21
+ at sub-critical Re because prior mean (the closure) fails there.
22
+ 5. Success criterion: baseline CEILING is primary; corpus floor secondary.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from typing import Sequence
29
+
30
+ import numpy as np
31
+
32
+ from sklearn.gaussian_process import GaussianProcessRegressor
33
+ from sklearn.gaussian_process.kernels import Matern, ConstantKernel, WhiteKernel
34
+ from sklearn.preprocessing import PolynomialFeatures
35
+ from sklearn.linear_model import LinearRegression
36
+
37
+
38
+
39
+ # ── feature-space specifications (locked design v0.2) ───────────────────────
40
+
41
+ FEATURE_SPACE_FORREST_INTERNAL = ["log10_Re", "Pr"]
42
+ FEATURE_SPACE_CROSS_SUBSTRATE = [
43
+ "log10_Re", "Pr", "log10_Dh_mm", "alpha_star",
44
+ "heating_pattern_indicator", "roughness_relative",
45
+ ]
46
+
47
+ # Forrest defaults for substrate-internal feature extraction (Mudhafar rows
48
+ # always carry their own geometry meta; Forrest rows omit them and get these
49
+ # defaults so cross-substrate test sets work uniformly):
50
+ FORREST_DEFAULT_DH_MM = 3.79
51
+ FORREST_DEFAULT_ALPHA_STAR = 0.035
52
+ FORREST_DEFAULT_HEATING_PATTERN = 0.0 # one-sided
53
+ FORREST_DEFAULT_ROUGHNESS = 0.0 # smooth
54
+
55
+
56
+ def extract_features(meta: dict, feature_names: Sequence[str]) -> np.ndarray:
57
+ """Extract a feature vector from a substrate row's meta dict.
58
+
59
+ For Forrest rows that don't carry geometry meta, fall back to Forrest's
60
+ standard geometry constants. For Mudhafar rows, the meta dict carries
61
+ explicit Dh, alpha_star, etc. For NACA TN-1451 rows, meta carries
62
+ x_over_D explicitly.
63
+
64
+ Two separable feature spaces:
65
+ - SURROGATE INPUT SPACE: what the practitioner's surrogate sees.
66
+ e.g. (log10_Re, Pr) for aggregate HE. Used by baseline detectors.
67
+ - PHYSICAL CONTEXT SPACE: full per-row metadata, including coords
68
+ like x_over_D that the surrogate omits but the corpus knows about.
69
+ Used by the validity-range-distance detector.
70
+
71
+ The same meta dict carries both; the caller controls which subset gets
72
+ extracted by passing different feature_names lists per detector.
73
+ """
74
+ features = []
75
+ for fname in feature_names:
76
+ if fname == "log10_Re":
77
+ features.append(float(np.log10(meta["Re"])))
78
+ elif fname == "Pr":
79
+ features.append(float(meta["Pr"]))
80
+ elif fname == "log10_Dh_mm":
81
+ features.append(float(np.log10(meta.get("Dh_mm", FORREST_DEFAULT_DH_MM))))
82
+ elif fname == "alpha_star":
83
+ features.append(float(meta.get("alpha_star", FORREST_DEFAULT_ALPHA_STAR)))
84
+ elif fname == "heating_pattern_indicator":
85
+ features.append(float(meta.get("heating_pattern_indicator",
86
+ FORREST_DEFAULT_HEATING_PATTERN)))
87
+ elif fname == "roughness_relative":
88
+ features.append(float(meta.get("roughness_relative",
89
+ FORREST_DEFAULT_ROUGHNESS)))
90
+ elif fname == "x_over_D":
91
+ features.append(float(meta["x_over_D"]))
92
+ elif fname == "log10_x_over_D":
93
+ features.append(float(np.log10(meta["x_over_D"])))
94
+ elif fname == "Ri":
95
+ # Richardson number (buoyancy failure variable). Hard KeyError if
96
+ # absent (like x_over_D) — a richardson_bands vehicle MUST carry it.
97
+ features.append(float(meta["Ri"]))
98
+ elif fname == "log10_Ri":
99
+ ri = float(meta["Ri"])
100
+ if ri <= 0:
101
+ raise ValueError(f"log10_Ri requires Ri>0, got {ri}")
102
+ features.append(float(np.log10(ri)))
103
+ elif fname == "ratio_mu_w_b":
104
+ features.append(float(meta["ratio_mu_w_b"]))
105
+ elif fname == "Bu":
106
+ # Liu buoyancy parameter (Jin sCO2 vertical-tube failure variable). Raw passthrough
107
+ # (the validity detector compares raw Bu to the corpus max 1.3e-5 directly). Hard KeyError
108
+ # if absent — a buoyancy_parameter_bands vehicle MUST carry it.
109
+ features.append(float(meta["Bu"]))
110
+ # ── aerospace / hypersonic-transition features (Casper, Marineau) ──
111
+ # Raw passthroughs (no log): the Mahalanobis/GP baselines auto-scale via
112
+ # the training covariance, so raw physical units are fine; the validity
113
+ # detector compares raw values to corpus min/max directly.
114
+ elif fname == "M": # Mach number (Casper surrogate input)
115
+ features.append(float(meta["M"]))
116
+ elif fname == "Re_per_m_e6": # unit Reynolds /m, millions (Casper)
117
+ features.append(float(meta["Re_per_m_e6"]))
118
+ elif fname == "x_m": # axial position, m (Casper)
119
+ features.append(float(meta["x_m"]))
120
+ elif fname == "freestream_noise_pct": # tunnel RMS-Pitot % (Casper validity coord)
121
+ features.append(float(meta["freestream_noise_pct"]))
122
+ elif fname == "Re_per_m": # unit Reynolds /m (Marineau surrogate input)
123
+ features.append(float(meta["Re_per_m"]))
124
+ elif fname == "Rn_mm": # nose-tip radius, mm (Marineau surrogate input)
125
+ features.append(float(meta["Rn_mm"]))
126
+ elif fname == "st_xsw_ratio": # entropy-layer/shock ratio S_T/X_SW (Marineau validity coord)
127
+ features.append(float(meta["st_xsw_ratio"]))
128
+ else:
129
+ raise ValueError(f"Unknown feature: {fname}")
130
+ return np.asarray(features, dtype=float)
131
+
132
+
133
+ # Feature-space specs for the entrance-region (NACA TN-1451) scenario:
134
+ #
135
+ # v0.3 (CURRENT, per user 2026-06-05 directive): baselines SEE x_over_D.
136
+ # This is the test of the simplified vehicle requirement — omitting x/D
137
+ # from baselines is the "omission contortion" explicitly forbidden. The
138
+ # honest test: does corpus add lift when baselines have access to x/D?
139
+ # If yes → corpus encoded literature knowledge baselines couldn't recover
140
+ # even with x/D in inputs. If no → differentiator fails (honest null).
141
+ FEATURE_SPACE_ENTRANCE_REGION = ["log10_Re", "Pr", "x_over_D"]
142
+
143
+ # v0.2 (DEPRECATED, retained for diagnostic comparison only): baselines
144
+ # omit x/D. The synthetic STRONG result that came out of this framing was
145
+ # the omission contortion — it manufactured lift by making baselines
146
+ # structurally blind to the failure axis. Do NOT use in real-data D3.
147
+ SURROGATE_INPUTS_NACA = ["log10_Re", "Pr"] # deprecated v0.2
148
+ PHYSICAL_CONTEXT_NACA = ["log10_Re", "Pr", "x_over_D"] # same as ENTRANCE_REGION above
149
+
150
+
151
+ def extract_features_batch(metas: Sequence[dict],
152
+ feature_names: Sequence[str]) -> np.ndarray:
153
+ """Stack feature vectors from a list of meta dicts into a (n, d) array."""
154
+ return np.stack([extract_features(m, feature_names) for m in metas], axis=0)
155
+
156
+
157
+ # ── DistanceDetector ─────────────────────────────────────────────────────────
158
+
159
+ @dataclass
160
+ class DistanceDetector:
161
+ """Mahalanobis k-NN distance to training set.
162
+
163
+ Signal at a test point = mean Mahalanobis distance to k nearest training
164
+ points (default k=3). Larger signal = farther from training distribution.
165
+
166
+ The Mahalanobis metric uses the training-set covariance matrix, so it
167
+ auto-handles per-feature scaling and feature correlations.
168
+ """
169
+ train_X: np.ndarray
170
+ k: int = 3
171
+ cov_regularization: float = 1e-6
172
+
173
+ inv_cov: np.ndarray = field(init=False)
174
+
175
+ def __post_init__(self):
176
+ n, d = self.train_X.shape
177
+ if n < 2:
178
+ raise ValueError(f"DistanceDetector needs >= 2 training points, got {n}")
179
+ if d == 1:
180
+ var = self.train_X.var(ddof=1)
181
+ self.inv_cov = np.array([[1.0 / max(var, self.cov_regularization)]])
182
+ else:
183
+ cov = np.cov(self.train_X, rowvar=False, ddof=1)
184
+ cov_reg = cov + self.cov_regularization * np.eye(d)
185
+ self.inv_cov = np.linalg.inv(cov_reg)
186
+
187
+ def signal(self, test_X: np.ndarray) -> np.ndarray:
188
+ n_test = len(test_X)
189
+ scores = np.zeros(n_test, dtype=float)
190
+ k_eff = min(self.k, len(self.train_X))
191
+ for i, x in enumerate(test_X):
192
+ diffs = self.train_X - x # (n_train, d)
193
+ d2 = np.einsum('ij,jk,ik->i', diffs, self.inv_cov, diffs)
194
+ d = np.sqrt(np.clip(d2, 0.0, None))
195
+ d_sorted = np.sort(d)
196
+ scores[i] = d_sorted[:k_eff].mean()
197
+ return scores
198
+
199
+
200
+ # ── GPVarianceDetector ───────────────────────────────────────────────────────
201
+
202
+ @dataclass
203
+ class GPVarianceDetector:
204
+ """GP regression with NEUTRAL mean function (constant), Matérn-5/2 kernel.
205
+
206
+ Per locked design v0.2 (3): the mean function is NOT the matched closure.
207
+ Closure-as-mean would re-leak the closure into the baseline — the GP's
208
+ variance would behave oddly at sub-critical Re precisely because its
209
+ prior mean (the closure) fails there. Constant mean keeps the GP
210
+ genuinely closure-blind; variance grows from training-data distance only.
211
+
212
+ Signal at a test point = posterior predictive std / posterior mean
213
+ (relative variance). Larger signal = more uncertain prediction.
214
+ """
215
+ train_X: np.ndarray
216
+ train_y: np.ndarray
217
+ n_restarts: int = 3
218
+ random_state: int = 20260605
219
+
220
+ gp: GaussianProcessRegressor = field(init=False)
221
+
222
+ def __post_init__(self):
223
+ n, d = self.train_X.shape
224
+ # Matérn-5/2 kernel with per-feature length scales, fit by marginal
225
+ # likelihood. ConstantKernel on the front sets the prior signal
226
+ # variance. WhiteKernel models noise; bounds let it be small if the
227
+ # data is clean, larger if noisy.
228
+ kernel = (
229
+ ConstantKernel(constant_value=1.0, constant_value_bounds=(1e-3, 1e3))
230
+ * Matern(
231
+ length_scale=[1.0] * d,
232
+ length_scale_bounds=(1e-2, 1e2),
233
+ nu=2.5,
234
+ )
235
+ + WhiteKernel(noise_level=1.0, noise_level_bounds=(1e-5, 1e2))
236
+ )
237
+ self.gp = GaussianProcessRegressor(
238
+ kernel=kernel,
239
+ normalize_y=True, # subtract train_y mean → "constant" prior mean
240
+ n_restarts_optimizer=self.n_restarts,
241
+ random_state=self.random_state,
242
+ )
243
+ self.gp.fit(self.train_X, self.train_y)
244
+
245
+ def signal(self, test_X: np.ndarray) -> np.ndarray:
246
+ mean, std = self.gp.predict(test_X, return_std=True)
247
+ # Relative variance: std / max(|mean|, small_floor). Floor avoids
248
+ # divide-by-near-zero artifacts when posterior mean is small.
249
+ floor = max(1.0, float(np.abs(self.train_y).mean()) * 0.01)
250
+ return std / np.maximum(np.abs(mean), floor)
251
+
252
+
253
+ # ── EnsembleVarianceDetector ─────────────────────────────────────────────────
254
+
255
+ @dataclass
256
+ class EnsembleVarianceDetector:
257
+ """Bootstrapped polynomial ensemble. Secondary robustness check on GP.
258
+
259
+ Closure-blind by construction: just polynomial regression of Nu against
260
+ features, fit on bootstrap resamples of training data. Ensemble disagreement
261
+ at test point = variance signal. Used to cross-check the GP's verdict.
262
+ """
263
+ train_X: np.ndarray
264
+ train_y: np.ndarray
265
+ n_bootstraps: int = 50
266
+ degree: int = 2
267
+ random_state: int = 20260605
268
+
269
+ models: list = field(init=False, default_factory=list)
270
+ poly: PolynomialFeatures = field(init=False)
271
+
272
+ def __post_init__(self):
273
+ self.poly = PolynomialFeatures(degree=self.degree, include_bias=False)
274
+ train_X_poly = self.poly.fit_transform(self.train_X)
275
+ rng = np.random.default_rng(self.random_state)
276
+ n = len(self.train_y)
277
+ self.models = []
278
+ for _ in range(self.n_bootstraps):
279
+ idx = rng.integers(0, n, size=n)
280
+ model = LinearRegression().fit(train_X_poly[idx], self.train_y[idx])
281
+ self.models.append(model)
282
+
283
+ def signal(self, test_X: np.ndarray) -> np.ndarray:
284
+ test_X_poly = self.poly.transform(test_X)
285
+ preds = np.array([m.predict(test_X_poly) for m in self.models]) # (n_boot, n_test)
286
+ means = preds.mean(axis=0)
287
+ stds = preds.std(axis=0, ddof=1)
288
+ floor = max(1.0, float(np.abs(self.train_y).mean()) * 0.01)
289
+ return stds / np.maximum(np.abs(means), floor)
290
+
291
+
292
+ # ── comparison metric (Cohen's d) ────────────────────────────────────────────
293
+
294
+ def cohens_d(scores_a: np.ndarray, scores_b: np.ndarray) -> float:
295
+ """Cohen's d for two independent samples (a vs b).
296
+
297
+ Positive d means scores_a > scores_b on average (the signal fires more
298
+ on set A). Returns nan if either sample has n < 2.
299
+ """
300
+ n_a, n_b = len(scores_a), len(scores_b)
301
+ if n_a < 2 or n_b < 2:
302
+ return float("nan")
303
+ var_a = scores_a.var(ddof=1)
304
+ var_b = scores_b.var(ddof=1)
305
+ pooled_var = ((n_a - 1) * var_a + (n_b - 1) * var_b) / (n_a + n_b - 2)
306
+ pooled_std = float(np.sqrt(pooled_var))
307
+ if pooled_std == 0:
308
+ return 0.0
309
+ return float((scores_a.mean() - scores_b.mean()) / pooled_std)