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
physmap/pipeline/core.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Linear D3 signal pipeline — Detectors -> Aggregator -> Assessment.
|
|
2
|
+
|
|
3
|
+
Per the v0.2 architecture refactor (Part 2): a point flows through a LINEAR
|
|
4
|
+
sequence of stages. Two ROLES of signal distinguished:
|
|
5
|
+
|
|
6
|
+
* DECISION signals — the aggregator weighs them to produce the verdict.
|
|
7
|
+
These are the detectors: ClosureValidity (literature-only),
|
|
8
|
+
DistanceToTraining, GPVariance / EnsembleVariance, optional PDEResidual.
|
|
9
|
+
|
|
10
|
+
* JUSTIFICATION signals — ride along to explain/audit; NEVER weighed for
|
|
11
|
+
the verdict. Evidence provenance, sources, corrections, validity narrative.
|
|
12
|
+
|
|
13
|
+
The pipeline accumulates both. The aggregator consumes ONLY decision signals.
|
|
14
|
+
Justification signals attach to the Assessment for downstream audit. This keeps
|
|
15
|
+
detection and explanation cleanly separated while letting both flow through
|
|
16
|
+
one pipeline.
|
|
17
|
+
|
|
18
|
+
LINEAR, NOT A DAG: a list of decision stages, then a list of justification
|
|
19
|
+
stages, then one aggregator. If you find yourself building conditional
|
|
20
|
+
routing, you have overshot the design.
|
|
21
|
+
|
|
22
|
+
WRAP-NOT-REWRITE: the existing detector classes in `d3_detectors.py` and
|
|
23
|
+
`d3_validity_signal.py` have a uniform `signal(test_X) -> np.ndarray`
|
|
24
|
+
interface. This module provides thin adapters that wrap them, add a
|
|
25
|
+
threshold + rationale, and emit a uniform `DetectorResult` schema. The
|
|
26
|
+
existing math (locked k=3 Mahalanobis, Matérn-5/2 GP, polynomial
|
|
27
|
+
bootstrap, literature L2 distance) stays untouched.
|
|
28
|
+
|
|
29
|
+
PHASE-1 ASSESSMENT IS FLAT — verdict + signals + flat justification +
|
|
30
|
+
rationale. Phase-2 grows the per-detector signals into v0.6
|
|
31
|
+
CredibilityFactor / WeakenerAnnotation nodes (gated on NACA showing lift).
|
|
32
|
+
The field names here are seeds the Phase-2 mapping will reuse verbatim.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from typing import (
|
|
39
|
+
Any, Callable, Literal, Protocol, Sequence, runtime_checkable,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── result schemas ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
SignalRole = Literal["decision", "justification"]
|
|
48
|
+
Verdict_Label = Literal["fire", "quiet"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DetectorResult:
|
|
53
|
+
"""One detector's output for ONE test point.
|
|
54
|
+
|
|
55
|
+
`threshold` is None for literature-only detectors (ValidityRangeDistance)
|
|
56
|
+
that fire on `score > 0` with no calibration step. `rationale` is a
|
|
57
|
+
human-readable line that downstream audit + Phase-2 graph nodes will
|
|
58
|
+
use verbatim.
|
|
59
|
+
"""
|
|
60
|
+
detector_name: str
|
|
61
|
+
score: float
|
|
62
|
+
fired: bool
|
|
63
|
+
threshold: float | None
|
|
64
|
+
rationale: str
|
|
65
|
+
role: SignalRole = "decision"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Verdict:
|
|
70
|
+
"""Aggregator output — verdict + rationale for the per-point decision."""
|
|
71
|
+
label: Verdict_Label
|
|
72
|
+
rationale: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Assessment:
|
|
77
|
+
"""Per-point assessment — flat in Phase 1.
|
|
78
|
+
|
|
79
|
+
Phase-2 mapping (NOT BUILT YET; gated on NACA result):
|
|
80
|
+
`decision_signals[*]` -> v0.6 CredibilityFactor / WeakenerAnnotation
|
|
81
|
+
`justification_signals[*]` -> v0.6 hasEvidence / hasJustification
|
|
82
|
+
`verdict` -> Disposition.actionClass
|
|
83
|
+
`rationale` -> OffsetRationale / residualRiskJustification
|
|
84
|
+
"""
|
|
85
|
+
operating_point: tuple
|
|
86
|
+
verdict: Verdict_Label
|
|
87
|
+
decision_signals: dict[str, DetectorResult]
|
|
88
|
+
justification_signals: dict[str, dict[str, Any]]
|
|
89
|
+
rationale: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── protocols ───────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
@runtime_checkable
|
|
95
|
+
class Detector(Protocol):
|
|
96
|
+
"""Decision-signal stage. Wraps an underlying `signal(test_X) -> ndarray`
|
|
97
|
+
detector and emits one DetectorResult per test point via `evaluate`."""
|
|
98
|
+
name: str
|
|
99
|
+
role: SignalRole
|
|
100
|
+
|
|
101
|
+
def evaluate(self, test_X: np.ndarray) -> list[DetectorResult]: ...
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@runtime_checkable
|
|
105
|
+
class JustificationStage(Protocol):
|
|
106
|
+
"""Justification-signal stage. Attaches provenance / evidence / corrections
|
|
107
|
+
to a per-point assessment. Sees the decision signals but does NOT influence
|
|
108
|
+
the verdict (the aggregator consumes decisions only).
|
|
109
|
+
"""
|
|
110
|
+
name: str
|
|
111
|
+
role: SignalRole # always "justification"
|
|
112
|
+
|
|
113
|
+
def enrich(self, row_meta: dict,
|
|
114
|
+
decision_signals: dict[str, DetectorResult]) -> dict[str, Any]: ...
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@runtime_checkable
|
|
118
|
+
class Aggregator(Protocol):
|
|
119
|
+
"""Decision-signal -> Verdict reducer. Phase-1 implementations are
|
|
120
|
+
AnyFired, CorpusGated, WeightedVote (in `d3_aggregators.py`). Phase-2
|
|
121
|
+
plugs in the defeasible-adjudication reasoner."""
|
|
122
|
+
|
|
123
|
+
name: str
|
|
124
|
+
|
|
125
|
+
def combine(self, decision_signals: dict[str, DetectorResult]) -> Verdict: ...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── detector adapters ──────────────────────────────────────────────────────
|
|
129
|
+
#
|
|
130
|
+
# One generic adapter wraps every existing detector. Differences (name,
|
|
131
|
+
# inner instance, rationale verbiage) are passed in at construction. The
|
|
132
|
+
# underlying detectors are untouched — adapters only ADD the threshold +
|
|
133
|
+
# rationale + role layer.
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
RationaleFn = Callable[[float, float | None, bool], str]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _default_rationale(name: str) -> RationaleFn:
|
|
140
|
+
"""Generic rationale template. Override per detector for richer language."""
|
|
141
|
+
def _build(score: float, threshold: float | None, fired: bool) -> str:
|
|
142
|
+
if not fired:
|
|
143
|
+
if threshold is None:
|
|
144
|
+
return f"{name}: quiet (score={score:.3g}, no threshold)"
|
|
145
|
+
return f"{name}: quiet (score={score:.3g} <= threshold={threshold:.3g})"
|
|
146
|
+
if threshold is None:
|
|
147
|
+
return f"{name}: FIRED (score={score:.3g} > 0; literature-only)"
|
|
148
|
+
return f"{name}: FIRED (score={score:.3g} > threshold={threshold:.3g})"
|
|
149
|
+
return _build
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class DetectorAdapter:
|
|
154
|
+
"""Generic adapter — wraps any object with a `signal(test_X) -> ndarray`
|
|
155
|
+
method (every existing detector in d3_detectors.py / d3_validity_signal.py
|
|
156
|
+
satisfies this) and emits DetectorResults.
|
|
157
|
+
|
|
158
|
+
`threshold` is None for literature-only detectors. `role` defaults to
|
|
159
|
+
"decision" — pass "justification" only for stages that should ride along
|
|
160
|
+
for audit without feeding the verdict.
|
|
161
|
+
"""
|
|
162
|
+
name: str
|
|
163
|
+
inner: Any # something with .signal(test_X) -> np.ndarray
|
|
164
|
+
threshold: float | None = None
|
|
165
|
+
role: SignalRole = "decision"
|
|
166
|
+
rationale_fn: RationaleFn | None = None
|
|
167
|
+
extra_meta: dict[str, Any] = field(default_factory=dict)
|
|
168
|
+
|
|
169
|
+
def __post_init__(self) -> None:
|
|
170
|
+
if not hasattr(self.inner, "signal"):
|
|
171
|
+
raise TypeError(
|
|
172
|
+
f"DetectorAdapter inner object must expose .signal(test_X) -> ndarray; "
|
|
173
|
+
f"got {type(self.inner).__name__} which lacks `signal`."
|
|
174
|
+
)
|
|
175
|
+
if self.rationale_fn is None:
|
|
176
|
+
self.rationale_fn = _default_rationale(self.name)
|
|
177
|
+
|
|
178
|
+
def signal(self, test_X: np.ndarray) -> np.ndarray:
|
|
179
|
+
"""Pass-through to the inner detector. Kept so existing call sites
|
|
180
|
+
that expect `.signal(...)` keep working when handed an adapter."""
|
|
181
|
+
return self.inner.signal(test_X)
|
|
182
|
+
|
|
183
|
+
def evaluate(self, test_X: np.ndarray) -> list[DetectorResult]:
|
|
184
|
+
scores = np.asarray(self.signal(test_X), dtype=float)
|
|
185
|
+
results: list[DetectorResult] = []
|
|
186
|
+
for s in scores:
|
|
187
|
+
s_f = float(s)
|
|
188
|
+
if self.threshold is None:
|
|
189
|
+
fired = s_f > 0.0
|
|
190
|
+
else:
|
|
191
|
+
fired = s_f > self.threshold
|
|
192
|
+
rationale = self.rationale_fn(s_f, self.threshold, fired)
|
|
193
|
+
results.append(DetectorResult(
|
|
194
|
+
detector_name=self.name,
|
|
195
|
+
score=s_f,
|
|
196
|
+
fired=bool(fired),
|
|
197
|
+
threshold=self.threshold,
|
|
198
|
+
rationale=rationale,
|
|
199
|
+
role=self.role,
|
|
200
|
+
))
|
|
201
|
+
return results
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── concrete adapter factories ──────────────────────────────────────────────
|
|
205
|
+
#
|
|
206
|
+
# These mirror the four+one existing detectors. Importing the inner classes
|
|
207
|
+
# is deferred to the factory body so this module doesn't pull sklearn at
|
|
208
|
+
# import time (the test that asserts "no heavy imports" stays green).
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def make_distance_adapter(train_X: np.ndarray, *,
|
|
212
|
+
threshold: float | None = None,
|
|
213
|
+
k: int = 3,
|
|
214
|
+
name: str = "distance") -> DetectorAdapter:
|
|
215
|
+
"""Mahalanobis k-NN distance baseline. k=3 is locked v0.2."""
|
|
216
|
+
from physmap.pipeline.detectors import DistanceDetector
|
|
217
|
+
inner = DistanceDetector(train_X=train_X, k=k)
|
|
218
|
+
return DetectorAdapter(
|
|
219
|
+
name=name, inner=inner, threshold=threshold,
|
|
220
|
+
rationale_fn=_distance_rationale(k),
|
|
221
|
+
extra_meta={"k": k},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _distance_rationale(k: int) -> RationaleFn:
|
|
226
|
+
def _build(score: float, threshold: float | None, fired: bool) -> str:
|
|
227
|
+
kind = f"Mahalanobis k={k} mean k-NN distance"
|
|
228
|
+
if not fired:
|
|
229
|
+
return f"distance: quiet ({kind}={score:.3g} <= threshold={threshold:.3g})"
|
|
230
|
+
return f"distance: FIRED ({kind}={score:.3g} > threshold={threshold:.3g})"
|
|
231
|
+
return _build
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# GP-variance relative-variance threshold FLOOR. The gp_variance threshold is
|
|
235
|
+
# calibrated at a percentile of TRAIN self-scores (guardrail._fit_core and
|
|
236
|
+
# gate_core.build_decision_adapters). That percentile-of-self DEGENERATES under
|
|
237
|
+
# dense training: a GP that interpolates near-duplicate inputs has near-zero
|
|
238
|
+
# posterior std at train points, collapsing the threshold toward 0, so it fires on
|
|
239
|
+
# any test point with even a benign relative variance. Floor the calibrated
|
|
240
|
+
# threshold at a small absolute relative variance below which the GP is
|
|
241
|
+
# definitionally confident.
|
|
242
|
+
#
|
|
243
|
+
# Justification (documented judgment — Benchmark v0.4; full record in
|
|
244
|
+
# results/benchmark_v0_4/gpvar_floor_investigation.md). The pathology is confirmed
|
|
245
|
+
# on TWO independent dense-training cases: Casper (benign ~1.06% predictive variance
|
|
246
|
+
# tripping a 0.0035 threshold under 159-pt training) and NACA's cross-validated set
|
|
247
|
+
# (gp_variance 0.05-0.72%, threshold collapsed to ~7e-4 by ~40 near-duplicate pts).
|
|
248
|
+
# Sweeping the floor over the v0.4 cross-domain matrix, any value in [0.02, 0.20] is
|
|
249
|
+
# INVARIANT for every cell whose baseline genuinely catches the failure (the NACA
|
|
250
|
+
# matrix WIN, the dirker/velazquez middles, and the Marineau negative control are
|
|
251
|
+
# all unchanged) and recovers Casper. The WIDTH of that invariant band is the
|
|
252
|
+
# evidence the floor is principled, not tuned-to-win: it is not a knob that trades
|
|
253
|
+
# cells off against each other; it is the range over which the benchmark does not
|
|
254
|
+
# move. 0.05 (5% relative predictive variance) sits centrally in the band.
|
|
255
|
+
#
|
|
256
|
+
# SCOPE: applied at BOTH gp_variance threshold-calibration sites — the shipped
|
|
257
|
+
# public API (guardrail._fit_core) and the Stage-1 harness (gate_core). Effect on
|
|
258
|
+
# the frozen path is verdict-NEUTRAL: phase1 stays byte-identical (Pareto counts
|
|
259
|
+
# unchanged); only the NACA cross-validated phase2 disposition SUB-mix refines (the
|
|
260
|
+
# artifactual restrict-cou rows — gp_variance firing on <1% variance — become
|
|
261
|
+
# characterize-region) and is rebanked (results/physmap_d3_phase2_gate). The NACA
|
|
262
|
+
# wpd path (gp_variance ~0.36, above floor) is provably unchanged.
|
|
263
|
+
GP_VARIANCE_REL_FLOOR = 0.05
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def make_gp_variance_adapter(train_X: np.ndarray, train_y: np.ndarray, *,
|
|
267
|
+
threshold: float | None = None,
|
|
268
|
+
n_restarts: int = 3,
|
|
269
|
+
random_state: int = 20260605,
|
|
270
|
+
name: str = "gp_variance") -> DetectorAdapter:
|
|
271
|
+
"""GP with NEUTRAL mean + Matérn-5/2 kernel. Locked v0.2; do not change."""
|
|
272
|
+
from physmap.pipeline.detectors import GPVarianceDetector
|
|
273
|
+
inner = GPVarianceDetector(
|
|
274
|
+
train_X=train_X, train_y=train_y,
|
|
275
|
+
n_restarts=n_restarts, random_state=random_state,
|
|
276
|
+
)
|
|
277
|
+
return DetectorAdapter(
|
|
278
|
+
name=name, inner=inner, threshold=threshold,
|
|
279
|
+
rationale_fn=_gp_rationale(),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _gp_rationale() -> RationaleFn:
|
|
284
|
+
def _build(score: float, threshold: float | None, fired: bool) -> str:
|
|
285
|
+
kind = "GP posterior std / |mean|"
|
|
286
|
+
if not fired:
|
|
287
|
+
return f"gp_variance: quiet ({kind}={score:.3g} <= threshold={threshold:.3g})"
|
|
288
|
+
return f"gp_variance: FIRED ({kind}={score:.3g} > threshold={threshold:.3g})"
|
|
289
|
+
return _build
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def make_ensemble_variance_adapter(train_X: np.ndarray, train_y: np.ndarray, *,
|
|
293
|
+
threshold: float | None = None,
|
|
294
|
+
n_bootstraps: int = 50,
|
|
295
|
+
degree: int = 2,
|
|
296
|
+
random_state: int = 20260605,
|
|
297
|
+
name: str = "ensemble_variance") -> DetectorAdapter:
|
|
298
|
+
"""Bootstrapped degree-2 polynomial ensemble disagreement. Locked v0.2."""
|
|
299
|
+
from physmap.pipeline.detectors import EnsembleVarianceDetector
|
|
300
|
+
inner = EnsembleVarianceDetector(
|
|
301
|
+
train_X=train_X, train_y=train_y,
|
|
302
|
+
n_bootstraps=n_bootstraps, degree=degree, random_state=random_state,
|
|
303
|
+
)
|
|
304
|
+
return DetectorAdapter(
|
|
305
|
+
name=name, inner=inner, threshold=threshold,
|
|
306
|
+
rationale_fn=_ensemble_rationale(n_bootstraps, degree),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _ensemble_rationale(n_boot: int, degree: int) -> RationaleFn:
|
|
311
|
+
def _build(score: float, threshold: float | None, fired: bool) -> str:
|
|
312
|
+
kind = f"bootstrap deg-{degree} ensemble (n_boot={n_boot}) std/|mean|"
|
|
313
|
+
if not fired:
|
|
314
|
+
return f"ensemble_variance: quiet ({kind}={score:.3g} <= threshold={threshold:.3g})"
|
|
315
|
+
return f"ensemble_variance: FIRED ({kind}={score:.3g} > threshold={threshold:.3g})"
|
|
316
|
+
return _build
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def make_closure_validity_adapter(closure_id: str,
|
|
320
|
+
feature_names: Sequence[str], *,
|
|
321
|
+
name: str = "closure_validity",
|
|
322
|
+
corpus_path: Any = None) -> DetectorAdapter:
|
|
323
|
+
"""Literature-derived validity-range-distance signal. THE DIFFERENTIATOR.
|
|
324
|
+
|
|
325
|
+
No threshold: fires on `score > 0` (i.e., outside the closure's
|
|
326
|
+
corpus-recorded validity rectangle). NO training-data dependence — pure
|
|
327
|
+
literature lookup. This is the detector whose structural property
|
|
328
|
+
differentiates PhysMAP from input-distribution novelty baselines.
|
|
329
|
+
"""
|
|
330
|
+
from physmap.pipeline.validity_signal import ValidityRangeDistanceDetector
|
|
331
|
+
kwargs: dict[str, Any] = {
|
|
332
|
+
"closure_id": closure_id,
|
|
333
|
+
"feature_names": list(feature_names),
|
|
334
|
+
}
|
|
335
|
+
if corpus_path is not None:
|
|
336
|
+
kwargs["corpus_path"] = corpus_path
|
|
337
|
+
inner = ValidityRangeDistanceDetector(**kwargs)
|
|
338
|
+
return DetectorAdapter(
|
|
339
|
+
name=name,
|
|
340
|
+
inner=inner,
|
|
341
|
+
threshold=None, # literature-only; fires on > 0
|
|
342
|
+
rationale_fn=_validity_rationale(closure_id),
|
|
343
|
+
extra_meta={"closure_id": closure_id, "feature_names": tuple(feature_names)},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _validity_rationale(closure_id: str) -> RationaleFn:
|
|
348
|
+
def _build(score: float, threshold: float | None, fired: bool) -> str:
|
|
349
|
+
# threshold is always None for the validity detector
|
|
350
|
+
if not fired:
|
|
351
|
+
return (f"closure_validity: quiet (test point INSIDE the validated "
|
|
352
|
+
f"rectangle of {closure_id!r}; L2-distance=0)")
|
|
353
|
+
return (f"closure_validity: FIRED (test point OUTSIDE the validated "
|
|
354
|
+
f"rectangle of {closure_id!r}; L2-distance={score:.3g})")
|
|
355
|
+
return _build
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ── the pipeline ────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@dataclass
|
|
362
|
+
class Pipeline:
|
|
363
|
+
"""Linear D3 signal pipeline.
|
|
364
|
+
|
|
365
|
+
Order of operations per `run(rows)`:
|
|
366
|
+
1. Extract features once via `feature_extractor`.
|
|
367
|
+
2. Each decision stage emits one DetectorResult per row (batch eval).
|
|
368
|
+
3. Each justification stage enriches per-row provenance from row.meta
|
|
369
|
+
(and may consult the just-computed decision signals).
|
|
370
|
+
4. The aggregator reduces ONLY decision signals -> Verdict per row.
|
|
371
|
+
5. Assemble Assessment objects (operating_point, verdict, signals,
|
|
372
|
+
justification, rationale) — one per input row.
|
|
373
|
+
|
|
374
|
+
`feature_extractor` is a callable `list[dict] -> np.ndarray`; the
|
|
375
|
+
canonical implementation is `d3_detectors.extract_features_batch`.
|
|
376
|
+
"""
|
|
377
|
+
feature_extractor: Callable[[Sequence[dict]], np.ndarray]
|
|
378
|
+
decision_stages: list[Detector]
|
|
379
|
+
aggregator: Aggregator
|
|
380
|
+
justification_stages: list[JustificationStage] = field(default_factory=list)
|
|
381
|
+
|
|
382
|
+
def __post_init__(self) -> None:
|
|
383
|
+
if not self.decision_stages:
|
|
384
|
+
raise ValueError(
|
|
385
|
+
"Pipeline requires at least one decision stage; otherwise the "
|
|
386
|
+
"aggregator has nothing to weigh."
|
|
387
|
+
)
|
|
388
|
+
for stage in self.decision_stages:
|
|
389
|
+
if getattr(stage, "role", "decision") != "decision":
|
|
390
|
+
raise ValueError(
|
|
391
|
+
f"decision_stages must have role='decision'; stage "
|
|
392
|
+
f"{stage.name!r} has role={getattr(stage, 'role', '?')!r}. "
|
|
393
|
+
f"Move it to justification_stages."
|
|
394
|
+
)
|
|
395
|
+
for stage in self.justification_stages:
|
|
396
|
+
if getattr(stage, "role", "justification") != "justification":
|
|
397
|
+
raise ValueError(
|
|
398
|
+
f"justification_stages must have role='justification'; "
|
|
399
|
+
f"stage {stage.name!r} has role="
|
|
400
|
+
f"{getattr(stage, 'role', '?')!r}."
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
def run(self, rows: Sequence[Any]) -> list[Assessment]:
|
|
404
|
+
"""Score a sequence of stage-1 Row objects through the pipeline.
|
|
405
|
+
|
|
406
|
+
`rows` items must expose `.meta` (dict) and `.operating_point` (tuple);
|
|
407
|
+
the canonical `physmap.substrate.stage1_ingest.Row` satisfies this.
|
|
408
|
+
"""
|
|
409
|
+
if not rows:
|
|
410
|
+
return []
|
|
411
|
+
metas = [r.meta for r in rows]
|
|
412
|
+
test_X = self.feature_extractor(metas)
|
|
413
|
+
|
|
414
|
+
# Step 2 — decision stages, batched
|
|
415
|
+
per_detector: dict[str, list[DetectorResult]] = {}
|
|
416
|
+
for stage in self.decision_stages:
|
|
417
|
+
per_detector[stage.name] = stage.evaluate(test_X)
|
|
418
|
+
if len(per_detector[stage.name]) != len(rows):
|
|
419
|
+
raise RuntimeError(
|
|
420
|
+
f"detector {stage.name!r} returned "
|
|
421
|
+
f"{len(per_detector[stage.name])} results for {len(rows)} "
|
|
422
|
+
f"rows; should be 1-to-1."
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Step 3 — justification stages, per row (cheap; not vectorized)
|
|
426
|
+
# Step 4 — aggregator per row
|
|
427
|
+
# Step 5 — assemble
|
|
428
|
+
assessments: list[Assessment] = []
|
|
429
|
+
for i, row in enumerate(rows):
|
|
430
|
+
decision = {name: results[i] for name, results in per_detector.items()}
|
|
431
|
+
justification: dict[str, dict[str, Any]] = {}
|
|
432
|
+
for js in self.justification_stages:
|
|
433
|
+
justification[js.name] = js.enrich(row.meta, decision)
|
|
434
|
+
verdict = self.aggregator.combine(decision)
|
|
435
|
+
assessments.append(Assessment(
|
|
436
|
+
operating_point=row.operating_point,
|
|
437
|
+
verdict=verdict.label,
|
|
438
|
+
decision_signals=decision,
|
|
439
|
+
justification_signals=justification,
|
|
440
|
+
rationale=verdict.rationale,
|
|
441
|
+
))
|
|
442
|
+
return assessments
|