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,606 @@
|
|
|
1
|
+
"""The public CredibilityGuardrail — construct → fit → assess → save.
|
|
2
|
+
|
|
3
|
+
Surrogate-aware, closure-aware credibility guard. It wraps the shipped pipeline
|
|
4
|
+
internals (statistical baselines + the closure-validity corpus detector + the
|
|
5
|
+
defeasible adjudicator + the v0.6 assessment graph) behind a clean API, and adds
|
|
6
|
+
the one new mechanism: structural observability. The verdict is driven by the
|
|
7
|
+
surrogate's INPUT COORDINATES and the closure validity bounds — not by the
|
|
8
|
+
prediction value — so predictions are needed only for the audit graph.
|
|
9
|
+
|
|
10
|
+
Polymorphic fit/assess on ndarray | Path (file or dir) converge to an I/O-free
|
|
11
|
+
core (_fit_core / _assess_core) that operates on per-row coordinate dicts. The
|
|
12
|
+
fit-time bound-variable check fails loudly if the data lacks a resolved closure's
|
|
13
|
+
bound variable — the safety net against a silent dead differentiator.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import csv
|
|
19
|
+
import glob
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Sequence
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from physmap.corpus.calibration import get_validated_range
|
|
26
|
+
from physmap.corpus.evidence import load_claims, load_sources, query_validity_story
|
|
27
|
+
from physmap.guardrail.aggregator_observability import (
|
|
28
|
+
CORPUS_NAME,
|
|
29
|
+
ObservabilityWeightedAggregator,
|
|
30
|
+
)
|
|
31
|
+
from physmap.guardrail.classify import (
|
|
32
|
+
classify_observability,
|
|
33
|
+
coord_to_feature,
|
|
34
|
+
coord_to_meta_key,
|
|
35
|
+
input_to_feature,
|
|
36
|
+
load_default_corpus_index,
|
|
37
|
+
)
|
|
38
|
+
from physmap.guardrail.configs import (
|
|
39
|
+
Assessment,
|
|
40
|
+
ClosureValidityDetectorConfig,
|
|
41
|
+
ColumnMap,
|
|
42
|
+
ConformalResidualDetectorConfig,
|
|
43
|
+
DetectorResult,
|
|
44
|
+
DistanceDetectorConfig,
|
|
45
|
+
GPVarianceDetectorConfig,
|
|
46
|
+
NoveltyDetectorConfig,
|
|
47
|
+
)
|
|
48
|
+
from physmap.closures.index import (
|
|
49
|
+
CLOSURE_INDEX,
|
|
50
|
+
ClosureResolutionError,
|
|
51
|
+
ResolutionCase,
|
|
52
|
+
classify_closure,
|
|
53
|
+
match_closure,
|
|
54
|
+
resolution_message,
|
|
55
|
+
)
|
|
56
|
+
from physmap.guardrail.corpus_regimes import REGIME_TO_CLOSURES
|
|
57
|
+
from physmap.guardrail.detector_conformal import CONFORMAL_NAME, ConformalResidualDetector
|
|
58
|
+
from physmap.guardrail.detector_density import DensityNoveltyDetector
|
|
59
|
+
from physmap.guardrail.enums import (
|
|
60
|
+
AggregatorKind,
|
|
61
|
+
Device,
|
|
62
|
+
DetectorKind,
|
|
63
|
+
Observability,
|
|
64
|
+
)
|
|
65
|
+
from physmap.guardrail.graph import KIND_BY_NAME, NAME_BY_KIND
|
|
66
|
+
from physmap.guardrail.render import (
|
|
67
|
+
render_baseline,
|
|
68
|
+
render_partial,
|
|
69
|
+
render_partial_graded,
|
|
70
|
+
render_unobservable,
|
|
71
|
+
)
|
|
72
|
+
from physmap.guardrail.weighting_heuristic import resolve_partial
|
|
73
|
+
from physmap.pipeline.core import (
|
|
74
|
+
DetectorResult as CoreDetectorResult,
|
|
75
|
+
GP_VARIANCE_REL_FLOOR,
|
|
76
|
+
make_distance_adapter,
|
|
77
|
+
make_gp_variance_adapter,
|
|
78
|
+
)
|
|
79
|
+
from physmap.pipeline.detectors import extract_features_batch
|
|
80
|
+
from physmap.pipeline.validity_signal import (
|
|
81
|
+
_STATUS_WEIGHT,
|
|
82
|
+
PerBoundMargin,
|
|
83
|
+
ValidityRangeDistanceDetector,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
DEFAULT_OPERATING_PCT = 99.0 # percentile of train self-scores for distance/GP thresholds
|
|
88
|
+
|
|
89
|
+
_DEFAULT_DETECTORS = (
|
|
90
|
+
NoveltyDetectorConfig(),
|
|
91
|
+
GPVarianceDetectorConfig(),
|
|
92
|
+
ClosureValidityDetectorConfig(),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _coerce(v):
|
|
97
|
+
"""Best-effort float coercion for CSV cells; leave non-numeric as-is."""
|
|
98
|
+
try:
|
|
99
|
+
return float(v)
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
return v
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _default_config_for(kind: DetectorKind):
|
|
105
|
+
return {
|
|
106
|
+
DetectorKind.NOVELTY_DENSITY: NoveltyDetectorConfig(),
|
|
107
|
+
DetectorKind.DISTANCE_TO_TRAINING: DistanceDetectorConfig(),
|
|
108
|
+
DetectorKind.GP_VARIANCE: GPVarianceDetectorConfig(),
|
|
109
|
+
DetectorKind.CLOSURE_VALIDITY: ClosureValidityDetectorConfig(),
|
|
110
|
+
DetectorKind.CONFORMAL_RESIDUAL: ConformalResidualDetectorConfig(),
|
|
111
|
+
}[kind]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class CredibilityGuardrail:
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
surrogate_inputs: Sequence[str],
|
|
119
|
+
regime,
|
|
120
|
+
closure_id: str | None = None,
|
|
121
|
+
detectors: Sequence = _DEFAULT_DETECTORS,
|
|
122
|
+
aggregator: AggregatorKind = AggregatorKind.OBSERVABILITY_WEIGHTED,
|
|
123
|
+
surrogate=None,
|
|
124
|
+
operating_pct: float = DEFAULT_OPERATING_PCT,
|
|
125
|
+
):
|
|
126
|
+
if aggregator is not AggregatorKind.OBSERVABILITY_WEIGHTED:
|
|
127
|
+
raise NotImplementedError(
|
|
128
|
+
f"aggregator {aggregator.value!r} is wired but not implemented; "
|
|
129
|
+
f"only OBSERVABILITY_WEIGHTED ships now (it is the product value)."
|
|
130
|
+
)
|
|
131
|
+
self.surrogate_inputs = list(surrogate_inputs)
|
|
132
|
+
self.regime = regime
|
|
133
|
+
self.detectors = tuple(detectors)
|
|
134
|
+
self.aggregator_kind = aggregator
|
|
135
|
+
self.surrogate = surrogate
|
|
136
|
+
self.operating_pct = float(operating_pct)
|
|
137
|
+
|
|
138
|
+
self._corpus_index = load_default_corpus_index()
|
|
139
|
+
active_ids = set(self._corpus_index)
|
|
140
|
+
regime_closures = tuple(REGIME_TO_CLOSURES.get(regime, ()))
|
|
141
|
+
|
|
142
|
+
# Direct closure naming (closure_id= or free-text matched against the open
|
|
143
|
+
# index aliases) is the higher-fidelity demand signal — it must resolve for
|
|
144
|
+
# case 2b/3 closures too. Resolve it now; honor it in fit().
|
|
145
|
+
self._closure_request = closure_id
|
|
146
|
+
self._resolved_request_id = None
|
|
147
|
+
if closure_id:
|
|
148
|
+
self._resolved_request_id = (
|
|
149
|
+
closure_id if (closure_id in CLOSURE_INDEX or closure_id in active_ids)
|
|
150
|
+
else match_closure(closure_id)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Closures usable NOW (bounds in the active corpus): the regime's resolved
|
|
154
|
+
# set plus a directly-named closure if it happens to be active.
|
|
155
|
+
resolved = [c for c in regime_closures if c in active_ids]
|
|
156
|
+
if (self._resolved_request_id and self._resolved_request_id in active_ids
|
|
157
|
+
and self._resolved_request_id not in resolved):
|
|
158
|
+
resolved.append(self._resolved_request_id)
|
|
159
|
+
self._resolved_closures = resolved
|
|
160
|
+
|
|
161
|
+
# Resolution disposition (case 2a/2b/3) — computed here so construction is
|
|
162
|
+
# cheap and introspectable (mode/observability work pre-fit); raised in fit().
|
|
163
|
+
self._resolution = self._classify_resolution(active_ids, regime_closures)
|
|
164
|
+
self._observability = classify_observability(
|
|
165
|
+
self.surrogate_inputs, regime, self._corpus_index
|
|
166
|
+
)
|
|
167
|
+
self._detector_configs = self._resolve_detectors(self.detectors)
|
|
168
|
+
self._baseline_feature_names = [input_to_feature(s) for s in self.surrogate_inputs]
|
|
169
|
+
self._validity_feature_names = self._compute_validity_features()
|
|
170
|
+
self._region_keys = self._compute_region_keys()
|
|
171
|
+
self._agg = ObservabilityWeightedAggregator()
|
|
172
|
+
|
|
173
|
+
# fitted state (populated by _fit_core / load)
|
|
174
|
+
self._fitted = False
|
|
175
|
+
self._distance = None
|
|
176
|
+
self._gp = None
|
|
177
|
+
self._density = None
|
|
178
|
+
self._conformal = None
|
|
179
|
+
self._validity_detectors: list[tuple[str, ValidityRangeDistanceDetector]] = []
|
|
180
|
+
self._graded = True
|
|
181
|
+
self._status_weighting = True
|
|
182
|
+
self._claims = []
|
|
183
|
+
self._sources_index = {}
|
|
184
|
+
|
|
185
|
+
# ── setup helpers ─────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _resolve_detectors(specs) -> dict:
|
|
189
|
+
out = {}
|
|
190
|
+
for spec in specs:
|
|
191
|
+
if isinstance(spec, DetectorKind):
|
|
192
|
+
kind, cfg = spec, _default_config_for(spec)
|
|
193
|
+
else:
|
|
194
|
+
kind, cfg = spec.kind, spec
|
|
195
|
+
out[NAME_BY_KIND[kind]] = cfg
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
def _classify_resolution(self, active_ids: set[str], regime_closures: tuple[str, ...]):
|
|
199
|
+
"""Decide whether fit() must raise a resolution error (case 2a/2b/3), or
|
|
200
|
+
None to proceed. Preserves the existing statistical-only behaviour: an
|
|
201
|
+
UNLISTED / unmapped regime with no direct closure never raises."""
|
|
202
|
+
facets = {"regime": getattr(self.regime, "name", str(self.regime))}
|
|
203
|
+
|
|
204
|
+
# 1. Direct closure naming takes precedence and must work for 2b/3.
|
|
205
|
+
if self._closure_request:
|
|
206
|
+
if self._resolved_request_id is None:
|
|
207
|
+
return (self._closure_request, ResolutionCase.UNREGISTERED, facets)
|
|
208
|
+
case = classify_closure(self._resolved_request_id, active_ids)
|
|
209
|
+
return None if case is ResolutionCase.ACTIVE else (self._resolved_request_id, case, facets)
|
|
210
|
+
|
|
211
|
+
# 2. Regime path. Empty/UNLISTED → statistical-only (no raise, unchanged).
|
|
212
|
+
if not regime_closures:
|
|
213
|
+
return None
|
|
214
|
+
# Any closure already active → normal operation (case 1).
|
|
215
|
+
if any(c in active_ids for c in regime_closures):
|
|
216
|
+
return None
|
|
217
|
+
# None active: all premium-only → 2a; otherwise surface the first
|
|
218
|
+
# registered-but-uncurated (2b) — a seed install hitting a premium regime.
|
|
219
|
+
cases = [(c, classify_closure(c, active_ids)) for c in regime_closures]
|
|
220
|
+
if all(case is ResolutionCase.PREMIUM_ONLY for _, case in cases):
|
|
221
|
+
return (regime_closures[0], ResolutionCase.PREMIUM_ONLY, facets)
|
|
222
|
+
for cid, case in cases:
|
|
223
|
+
if case in (ResolutionCase.NO_BOUNDS, ResolutionCase.PREMIUM_ONLY):
|
|
224
|
+
return (cid, case, facets)
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def _raise_if_unresolved(self) -> None:
|
|
228
|
+
"""fit() entry guard — raise the factual one-line resolution error if the
|
|
229
|
+
requested closure's bounds are not in the active corpus."""
|
|
230
|
+
if self._resolution is None:
|
|
231
|
+
return
|
|
232
|
+
name, case, facets = self._resolution
|
|
233
|
+
raise ClosureResolutionError(resolution_message(name, case, facets=facets), case=case)
|
|
234
|
+
|
|
235
|
+
def _compute_validity_features(self) -> list[str]:
|
|
236
|
+
feats = list(self._baseline_feature_names)
|
|
237
|
+
for cid in self._resolved_closures:
|
|
238
|
+
for b in self._corpus_index[cid].validated_range:
|
|
239
|
+
f = coord_to_feature(b.coord)
|
|
240
|
+
if f and f not in feats:
|
|
241
|
+
feats.append(f)
|
|
242
|
+
return feats
|
|
243
|
+
|
|
244
|
+
def _compute_region_keys(self) -> list[str]:
|
|
245
|
+
keys = list(self.surrogate_inputs)
|
|
246
|
+
for cid in self._resolved_closures:
|
|
247
|
+
for b in self._corpus_index[cid].validated_range:
|
|
248
|
+
mk = coord_to_meta_key(b.coord)
|
|
249
|
+
if mk and mk not in keys:
|
|
250
|
+
keys.append(mk)
|
|
251
|
+
return keys
|
|
252
|
+
|
|
253
|
+
# ── I/O-free core ─────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
def _validate_bound_variables(self, metas: list[dict]) -> None:
|
|
256
|
+
"""Fail loudly if the data lacks a resolved closure's bound variable —
|
|
257
|
+
the safety net against a silent dead differentiator."""
|
|
258
|
+
if not metas:
|
|
259
|
+
return
|
|
260
|
+
present = set(metas[0].keys())
|
|
261
|
+
for cid in self._resolved_closures:
|
|
262
|
+
for b in self._corpus_index[cid].validated_range:
|
|
263
|
+
mk = coord_to_meta_key(b.coord)
|
|
264
|
+
if mk is None:
|
|
265
|
+
continue # no known data column for this coord; can't check
|
|
266
|
+
if mk not in present:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"regime {self.regime.name} checks a bound on {b.coord}, but "
|
|
269
|
+
f"the data does not contain it (expected column {mk!r}); the "
|
|
270
|
+
f"closure-validity detector cannot evaluate this bound. "
|
|
271
|
+
f"Include {mk!r} in the assessment coordinates."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _fit_core(self, train_metas, train_y, train_pred) -> "CredibilityGuardrail":
|
|
275
|
+
self._validate_bound_variables(train_metas)
|
|
276
|
+
train_baseline_X = extract_features_batch(train_metas, self._baseline_feature_names)
|
|
277
|
+
|
|
278
|
+
if "distance" in self._detector_configs:
|
|
279
|
+
cfg = self._detector_configs["distance"]
|
|
280
|
+
self._check_device(cfg.device)
|
|
281
|
+
probe = make_distance_adapter(train_baseline_X, k=cfg.k)
|
|
282
|
+
tau = float(np.percentile(probe.signal(train_baseline_X), self.operating_pct))
|
|
283
|
+
self._distance = make_distance_adapter(train_baseline_X, threshold=tau, k=cfg.k)
|
|
284
|
+
|
|
285
|
+
if "gp_variance" in self._detector_configs:
|
|
286
|
+
if train_y is None:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"the GP-variance baseline needs training truth; pass train_y "
|
|
289
|
+
"(ndarray) or a truth column (Path via ColumnMap.truth)."
|
|
290
|
+
)
|
|
291
|
+
cfg = self._detector_configs["gp_variance"]
|
|
292
|
+
self._check_device(cfg.device)
|
|
293
|
+
probe = make_gp_variance_adapter(train_baseline_X, np.asarray(train_y, dtype=float))
|
|
294
|
+
tau = float(np.percentile(probe.signal(train_baseline_X), self.operating_pct))
|
|
295
|
+
tau = max(tau, GP_VARIANCE_REL_FLOOR) # floor: percentile-of-self degenerates on dense training
|
|
296
|
+
self._gp = make_gp_variance_adapter(
|
|
297
|
+
train_baseline_X, np.asarray(train_y, dtype=float), threshold=tau
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if "conformal_residual" in self._detector_configs:
|
|
301
|
+
if train_y is None:
|
|
302
|
+
raise ValueError(
|
|
303
|
+
"the conformal-residual detector (observable-pole mode) needs training "
|
|
304
|
+
"truth; pass train_y (ndarray) or a truth column (Path via ColumnMap.truth)."
|
|
305
|
+
)
|
|
306
|
+
cfg = self._detector_configs["conformal_residual"]
|
|
307
|
+
self._check_device(cfg.device)
|
|
308
|
+
self._conformal = ConformalResidualDetector(
|
|
309
|
+
train_baseline_X, np.asarray(train_y, dtype=float),
|
|
310
|
+
alpha=cfg.alpha, calib_frac=cfg.calib_frac, random_state=cfg.random_state,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if "novelty_density" in self._detector_configs:
|
|
314
|
+
cfg = self._detector_configs["novelty_density"]
|
|
315
|
+
self._density = DensityNoveltyDetector(
|
|
316
|
+
train_X=train_baseline_X, components=cfg.components,
|
|
317
|
+
warn_pct=cfg.warn_pct, reject_pct=cfg.reject_pct,
|
|
318
|
+
method=cfg.method, device=cfg.device,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if "closure_validity" in self._detector_configs and self._resolved_closures:
|
|
322
|
+
cfg = self._detector_configs["closure_validity"]
|
|
323
|
+
self._graded = cfg.graded
|
|
324
|
+
self._status_weighting = cfg.status_weighting
|
|
325
|
+
self._validity_detectors = [
|
|
326
|
+
(cid, ValidityRangeDistanceDetector(
|
|
327
|
+
closure_id=cid, feature_names=self._validity_feature_names))
|
|
328
|
+
for cid in self._resolved_closures
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
self._claims = load_claims()
|
|
332
|
+
self._sources_index = {s.source_id: s for s in load_sources()}
|
|
333
|
+
self._fitted = True
|
|
334
|
+
return self
|
|
335
|
+
|
|
336
|
+
def _governing_per_row(self, test_metas) -> list[tuple[PerBoundMargin | None, str | None]]:
|
|
337
|
+
"""Per row, the governing fired bound across ALL resolved closures
|
|
338
|
+
(most-violated, status-weighted), tagged with its closure_id."""
|
|
339
|
+
if not self._validity_detectors:
|
|
340
|
+
return [(None, None)] * len(test_metas)
|
|
341
|
+
X = extract_features_batch(test_metas, self._validity_feature_names)
|
|
342
|
+
per_det = [(cid, det.evaluate_bounds(X)) for cid, det in self._validity_detectors]
|
|
343
|
+
out: list[tuple[PerBoundMargin | None, str | None]] = []
|
|
344
|
+
for i in range(len(test_metas)):
|
|
345
|
+
best, best_cid, best_key = None, None, 0.0
|
|
346
|
+
for cid, rows in per_det:
|
|
347
|
+
gov = self._validity_detectors[0][1].governing_bound(
|
|
348
|
+
rows[i], status_weighting=self._status_weighting)
|
|
349
|
+
if gov is None:
|
|
350
|
+
continue
|
|
351
|
+
w = _STATUS_WEIGHT.get(gov.bound_status, 0.4) if self._status_weighting else 1.0
|
|
352
|
+
key = w * gov.margin
|
|
353
|
+
if best is None or key > best_key:
|
|
354
|
+
best, best_cid, best_key = gov, cid, key
|
|
355
|
+
out.append((best, best_cid))
|
|
356
|
+
return out
|
|
357
|
+
|
|
358
|
+
def _assess_core(self, test_metas, test_pred, truth_values) -> list[Assessment]:
|
|
359
|
+
if not self._fitted:
|
|
360
|
+
raise RuntimeError("assess() called before fit(); fit the guard first.")
|
|
361
|
+
self._validate_bound_variables(test_metas)
|
|
362
|
+
n = len(test_metas)
|
|
363
|
+
test_baseline_X = extract_features_batch(test_metas, self._baseline_feature_names)
|
|
364
|
+
|
|
365
|
+
dist_results = self._distance.evaluate(test_baseline_X) if self._distance else None
|
|
366
|
+
gp_results = self._gp.evaluate(test_baseline_X) if self._gp else None
|
|
367
|
+
dens_scores = self._density.signal(test_baseline_X) if self._density else None
|
|
368
|
+
conf_results = (self._conformal.evaluate(test_baseline_X, test_pred)
|
|
369
|
+
if self._conformal is not None else None)
|
|
370
|
+
governing = self._governing_per_row(test_metas)
|
|
371
|
+
|
|
372
|
+
assessments: list[Assessment] = []
|
|
373
|
+
for i, meta in enumerate(test_metas):
|
|
374
|
+
decision: dict[str, CoreDetectorResult] = {}
|
|
375
|
+
severities: dict[str, str | None] = {}
|
|
376
|
+
if dist_results is not None:
|
|
377
|
+
decision["distance"] = dist_results[i]
|
|
378
|
+
severities["distance"] = "warn" if dist_results[i].fired else None
|
|
379
|
+
if gp_results is not None:
|
|
380
|
+
decision["gp_variance"] = gp_results[i]
|
|
381
|
+
severities["gp_variance"] = "warn" if gp_results[i].fired else None
|
|
382
|
+
if self._density is not None:
|
|
383
|
+
score = float(dens_scores[i])
|
|
384
|
+
fired = score > self._density.warn_threshold
|
|
385
|
+
sev = ("reject" if score > self._density.reject_threshold
|
|
386
|
+
else ("warn" if fired else None))
|
|
387
|
+
decision["novelty_density"] = CoreDetectorResult(
|
|
388
|
+
"novelty_density", score, fired, self._density.warn_threshold,
|
|
389
|
+
_density_rationale(score, self._density), "decision")
|
|
390
|
+
severities["novelty_density"] = sev
|
|
391
|
+
if conf_results is not None:
|
|
392
|
+
decision[CONFORMAL_NAME] = conf_results[i]
|
|
393
|
+
severities[CONFORMAL_NAME] = "warn" if conf_results[i].fired else None
|
|
394
|
+
|
|
395
|
+
gov, gov_cid = governing[i]
|
|
396
|
+
if self._validity_detectors:
|
|
397
|
+
fired_cv = gov is not None
|
|
398
|
+
score_cv = (gov.margin if self._graded else 1.0) if fired_cv else 0.0
|
|
399
|
+
decision[CORPUS_NAME] = CoreDetectorResult(
|
|
400
|
+
CORPUS_NAME, score_cv, fired_cv, None,
|
|
401
|
+
_cv_rationale(gov_cid, gov), "decision")
|
|
402
|
+
|
|
403
|
+
outcome = self._agg.combine(
|
|
404
|
+
decision_signals=decision, observability=self._observability,
|
|
405
|
+
fired_bound=gov, severities=severities,
|
|
406
|
+
fired_closure_id=gov_cid, regime=self.regime)
|
|
407
|
+
|
|
408
|
+
obs = self._observability.get(gov.coord) if gov is not None else None
|
|
409
|
+
rationale = self._render_rationale(outcome, gov, gov_cid, decision)
|
|
410
|
+
signals = {
|
|
411
|
+
KIND_BY_NAME[name]: DetectorResult(
|
|
412
|
+
KIND_BY_NAME[name], r.score, r.fired, r.threshold, r.rationale)
|
|
413
|
+
for name, r in decision.items()
|
|
414
|
+
}
|
|
415
|
+
pred = float(test_pred[i]) if test_pred is not None and test_pred[i] is not None else None
|
|
416
|
+
truth = (float(truth_values[i])
|
|
417
|
+
if truth_values is not None and truth_values[i] is not None else None)
|
|
418
|
+
region = ", ".join(
|
|
419
|
+
f"{k}={_fmt(meta[k])}" for k in self._region_keys if k in meta)
|
|
420
|
+
op = tuple(meta[k] for k in self._region_keys if k in meta)
|
|
421
|
+
assessments.append(Assessment(
|
|
422
|
+
verdict=outcome.verdict, disposition=outcome.disposition,
|
|
423
|
+
rationale=rationale, signals=signals, observability=obs,
|
|
424
|
+
fired_bound_variable=(gov.coord if gov is not None else None),
|
|
425
|
+
surrogate_prediction=pred, solver_truth=truth,
|
|
426
|
+
operating_point=op, closure_id=gov_cid, region=region))
|
|
427
|
+
return assessments
|
|
428
|
+
|
|
429
|
+
def _render_rationale(self, outcome, gov, gov_cid, decision) -> str:
|
|
430
|
+
baseline_fired_names = [
|
|
431
|
+
n for n, r in decision.items() if n != CORPUS_NAME and r.fired]
|
|
432
|
+
if gov is not None and outcome.rule == "unobservable-corpus-trusted":
|
|
433
|
+
bound = get_validated_range(self._corpus_index, gov_cid, gov.coord)
|
|
434
|
+
interval = (bound.min, bound.max) if bound else (None, None)
|
|
435
|
+
return render_unobservable(
|
|
436
|
+
closure_id=gov_cid, fired_bound=gov, bound_interval=interval,
|
|
437
|
+
baseline_fired=bool(baseline_fired_names),
|
|
438
|
+
claims_for_closure=query_validity_story(self._claims, gov_cid),
|
|
439
|
+
sources_index=self._sources_index)
|
|
440
|
+
if gov is not None and outcome.rule == "partial-defer":
|
|
441
|
+
bound = get_validated_range(self._corpus_index, gov_cid, gov.coord)
|
|
442
|
+
interval = (bound.min, bound.max) if bound else (None, None)
|
|
443
|
+
return render_partial(
|
|
444
|
+
closure_id=gov_cid, fired_bound=gov, bound_interval=interval)
|
|
445
|
+
if gov is not None and outcome.rule in (
|
|
446
|
+
"partial-graded-corpus-trust", "partial-graded-softflag"):
|
|
447
|
+
bound = get_validated_range(self._corpus_index, gov_cid, gov.coord)
|
|
448
|
+
interval = (bound.min, bound.max) if bound else (None, None)
|
|
449
|
+
dec = resolve_partial(gov_cid, gov.coord, regime_value=self.regime.value)
|
|
450
|
+
return render_partial_graded(
|
|
451
|
+
closure_id=gov_cid, fired_bound=gov, bound_interval=interval,
|
|
452
|
+
degree=dec.partial_degree, calibrated_by=dec.calibrated_by,
|
|
453
|
+
soft_flag=(outcome.rule == "partial-graded-softflag"))
|
|
454
|
+
return render_baseline(
|
|
455
|
+
baseline_fired_names=baseline_fired_names,
|
|
456
|
+
all_quiet=not baseline_fired_names)
|
|
457
|
+
|
|
458
|
+
@staticmethod
|
|
459
|
+
def _check_device(device: Device) -> None:
|
|
460
|
+
if device is Device.CUDA:
|
|
461
|
+
raise NotImplementedError(
|
|
462
|
+
"CUDA device is wired but not implemented; only CPU is built "
|
|
463
|
+
"(workloads are small — GPU is deferred)."
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# ── polymorphic fit / assess ──────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
def fit(self, train, train_y=None, train_pred=None, *, columns: ColumnMap | None = None):
|
|
469
|
+
self._raise_if_unresolved()
|
|
470
|
+
if isinstance(train, np.ndarray):
|
|
471
|
+
coord_names = columns.inputs if columns is not None else self.surrogate_inputs
|
|
472
|
+
metas = self._array_to_metas(train, coord_names)
|
|
473
|
+
if train_pred is None and self.surrogate is not None:
|
|
474
|
+
train_pred = self.surrogate(train)
|
|
475
|
+
return self._fit_core(metas, train_y, train_pred)
|
|
476
|
+
if isinstance(train, (str, Path)):
|
|
477
|
+
if train_y is not None or train_pred is not None:
|
|
478
|
+
raise ValueError(
|
|
479
|
+
"predictions/truth come from the file when a path is given; "
|
|
480
|
+
"do not also pass train_y/train_pred.")
|
|
481
|
+
metas = self._load_path(train, columns)
|
|
482
|
+
truth = columns.truth if columns is not None else "truth"
|
|
483
|
+
needs_truth = bool(
|
|
484
|
+
{"gp_variance", "conformal_residual"} & set(self._detector_configs))
|
|
485
|
+
train_y = self._column(metas, truth) if needs_truth else None
|
|
486
|
+
return self._fit_core(metas, train_y, None)
|
|
487
|
+
raise TypeError(f"train must be np.ndarray or Path/str, got {type(train).__name__}")
|
|
488
|
+
|
|
489
|
+
def assess(self, test, test_pred=None, *, columns: ColumnMap | None = None,
|
|
490
|
+
graph: bool = False) -> list[Assessment]:
|
|
491
|
+
if isinstance(test, np.ndarray):
|
|
492
|
+
coord_names = columns.inputs if columns is not None else self.surrogate_inputs
|
|
493
|
+
metas = self._array_to_metas(test, coord_names)
|
|
494
|
+
if test_pred is None and self.surrogate is not None:
|
|
495
|
+
test_pred = self.surrogate(test)
|
|
496
|
+
truth_values = None
|
|
497
|
+
elif isinstance(test, (str, Path)):
|
|
498
|
+
if test_pred is not None:
|
|
499
|
+
raise ValueError(
|
|
500
|
+
"predictions/truth come from the file when a path is given; "
|
|
501
|
+
"do not also pass test_pred.")
|
|
502
|
+
metas = self._load_path(test, columns)
|
|
503
|
+
truth_col = columns.truth if columns is not None else "truth"
|
|
504
|
+
pred_col = columns.prediction if columns is not None else "prediction"
|
|
505
|
+
truth_values = self._column(metas, truth_col, optional=True)
|
|
506
|
+
test_pred = self._column(metas, pred_col, optional=True)
|
|
507
|
+
if test_pred is None and self.surrogate is not None:
|
|
508
|
+
test_pred = self.surrogate(
|
|
509
|
+
extract_features_batch(metas, self._baseline_feature_names))
|
|
510
|
+
else:
|
|
511
|
+
raise TypeError(f"test must be np.ndarray or Path/str, got {type(test).__name__}")
|
|
512
|
+
|
|
513
|
+
if graph and (truth_values is None or any(t is None for t in truth_values)):
|
|
514
|
+
raise ValueError(
|
|
515
|
+
"graph=True requires truth for every row (the v0.6 Discrepancy shape "
|
|
516
|
+
"pins exactly one solverTruth). Provide a truth column via a Path + "
|
|
517
|
+
"ColumnMap.truth, or call assess(..., graph=False) for flat assessments.")
|
|
518
|
+
return self._assess_core(metas, test_pred, truth_values)
|
|
519
|
+
|
|
520
|
+
# ── data loading helpers ──────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
def _array_to_metas(self, X: np.ndarray, coord_names: Sequence[str]) -> list[dict]:
|
|
523
|
+
X = np.asarray(X)
|
|
524
|
+
if X.ndim != 2 or X.shape[1] != len(coord_names):
|
|
525
|
+
raise ValueError(
|
|
526
|
+
f"array has shape {X.shape} but {len(coord_names)} column names "
|
|
527
|
+
f"{list(coord_names)} were given; pass a ColumnMap whose `inputs` "
|
|
528
|
+
f"names every column (a SUPERSET of surrogate_inputs that includes "
|
|
529
|
+
f"the bound variables).")
|
|
530
|
+
return [{coord_names[j]: float(X[i, j]) for j in range(X.shape[1])}
|
|
531
|
+
for i in range(X.shape[0])]
|
|
532
|
+
|
|
533
|
+
@staticmethod
|
|
534
|
+
def _load_path(path, columns: ColumnMap | None) -> list[dict]:
|
|
535
|
+
p = Path(path)
|
|
536
|
+
files = sorted(glob.glob(str(p / "*.csv"))) if p.is_dir() else [str(p)]
|
|
537
|
+
if not files:
|
|
538
|
+
raise FileNotFoundError(f"no CSV files found at {p}")
|
|
539
|
+
metas: list[dict] = []
|
|
540
|
+
for f in files:
|
|
541
|
+
with open(f, newline="") as fh:
|
|
542
|
+
for row in csv.DictReader(r for r in fh if not r.lstrip().startswith("#")):
|
|
543
|
+
metas.append({k: _coerce(v) for k, v in row.items()})
|
|
544
|
+
if columns is not None:
|
|
545
|
+
missing = [c for c in columns.inputs if metas and c not in metas[0]]
|
|
546
|
+
if missing:
|
|
547
|
+
raise ValueError(f"columns {missing} not found in {p} (have {sorted(metas[0])})")
|
|
548
|
+
return metas
|
|
549
|
+
|
|
550
|
+
@staticmethod
|
|
551
|
+
def _column(metas, name, *, optional: bool = False):
|
|
552
|
+
if not metas:
|
|
553
|
+
return None
|
|
554
|
+
if name not in metas[0]:
|
|
555
|
+
if optional:
|
|
556
|
+
return None
|
|
557
|
+
raise ValueError(f"column {name!r} not found (have {sorted(metas[0])})")
|
|
558
|
+
return [m.get(name) for m in metas]
|
|
559
|
+
|
|
560
|
+
# ── persistence ───────────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
def save(self, path) -> None:
|
|
563
|
+
from physmap.guardrail.io import save_guardrail
|
|
564
|
+
save_guardrail(self, path)
|
|
565
|
+
|
|
566
|
+
@classmethod
|
|
567
|
+
def load(cls, path, *, surrogate=None) -> "CredibilityGuardrail":
|
|
568
|
+
from physmap.guardrail.io import load_guardrail
|
|
569
|
+
return load_guardrail(cls, path, surrogate=surrogate)
|
|
570
|
+
|
|
571
|
+
# ── introspection ─────────────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
@property
|
|
574
|
+
def mode(self) -> str:
|
|
575
|
+
return "physics_active" if self._resolved_closures else "statistical_only"
|
|
576
|
+
|
|
577
|
+
@property
|
|
578
|
+
def observability_classification(self) -> dict:
|
|
579
|
+
return dict(self._observability)
|
|
580
|
+
|
|
581
|
+
@property
|
|
582
|
+
def resolved_closures(self) -> list:
|
|
583
|
+
return sorted(self._resolved_closures)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ── module-level rationale builders (deterministic) ───────────────────────────
|
|
587
|
+
|
|
588
|
+
def _fmt(v) -> str:
|
|
589
|
+
return f"{v:.4g}" if isinstance(v, (int, float)) else str(v)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _density_rationale(score: float, det: DensityNoveltyDetector) -> str:
|
|
593
|
+
if score > det.reject_threshold:
|
|
594
|
+
return (f"novelty_density: FIRED (NLL={score:.3g} > reject="
|
|
595
|
+
f"{det.reject_threshold:.3g}; far below training density)")
|
|
596
|
+
if score > det.warn_threshold:
|
|
597
|
+
return (f"novelty_density: FIRED (NLL={score:.3g} > warn="
|
|
598
|
+
f"{det.warn_threshold:.3g})")
|
|
599
|
+
return f"novelty_density: quiet (NLL={score:.3g} <= warn={det.warn_threshold:.3g})"
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _cv_rationale(closure_id: str | None, gov: PerBoundMargin | None) -> str:
|
|
603
|
+
if gov is None or closure_id is None:
|
|
604
|
+
return "closure_validity: quiet (test point INSIDE every validated rectangle)"
|
|
605
|
+
return (f"closure_validity: FIRED (test point OUTSIDE the validated rectangle "
|
|
606
|
+
f"of {closure_id!r} on {gov.coord}; margin={gov.margin:.3g} {gov.side})")
|