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