physmap 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. physmap/__init__.py +61 -0
  2. physmap/_paths.py +69 -0
  3. physmap/applicability/__init__.py +0 -0
  4. physmap/applicability/fixtures.py +83 -0
  5. physmap/applicability/screen.py +99 -0
  6. physmap/baselines/__init__.py +0 -0
  7. physmap/benchmarks/__init__.py +0 -0
  8. physmap/benchmarks/benchmark_report.py +405 -0
  9. physmap/benchmarks/benchmark_v0_4.py +424 -0
  10. physmap/benchmarks/compare.py +149 -0
  11. physmap/benchmarks/registry.py +217 -0
  12. physmap/benchmarks/report.py +224 -0
  13. physmap/cli.py +301 -0
  14. physmap/closures/__init__.py +48 -0
  15. physmap/closures/data/__init__.py +7 -0
  16. physmap/closures/data/closure_index.json +2997 -0
  17. physmap/closures/formulas.py +213 -0
  18. physmap/closures/geometry_classes.py +109 -0
  19. physmap/closures/index.py +393 -0
  20. physmap/closures/registry.py +313 -0
  21. physmap/compat/__init__.py +0 -0
  22. physmap/core/__init__.py +0 -0
  23. physmap/core/mechanism.py +69 -0
  24. physmap/core/signals.py +50 -0
  25. physmap/corpus/__init__.py +12 -0
  26. physmap/corpus/calibration.py +543 -0
  27. physmap/corpus/data/__init__.py +12 -0
  28. physmap/corpus/data/corpus_seed.jsonl +15 -0
  29. physmap/corpus/data/evidence_claims_seed.jsonl +21 -0
  30. physmap/corpus/data/evidence_sources_seed.jsonl +8 -0
  31. physmap/corpus/data/premium_coverage.json +60 -0
  32. physmap/corpus/evidence.py +871 -0
  33. physmap/explain/__init__.py +0 -0
  34. physmap/explain/benchmark.py +101 -0
  35. physmap/explain/causal.py +82 -0
  36. physmap/guardrail/__init__.py +38 -0
  37. physmap/guardrail/aggregator_observability.py +187 -0
  38. physmap/guardrail/classify.py +147 -0
  39. physmap/guardrail/configs.py +120 -0
  40. physmap/guardrail/corpus_regimes.py +208 -0
  41. physmap/guardrail/detector_conformal.py +129 -0
  42. physmap/guardrail/detector_density.py +74 -0
  43. physmap/guardrail/enums.py +69 -0
  44. physmap/guardrail/graph.py +73 -0
  45. physmap/guardrail/guardrail.py +606 -0
  46. physmap/guardrail/io.py +201 -0
  47. physmap/guardrail/regime_observability.py +519 -0
  48. physmap/guardrail/render.py +159 -0
  49. physmap/guardrail/weighting_heuristic.py +216 -0
  50. physmap/infra/__init__.py +23 -0
  51. physmap/infra/blindspot_oracle.py +356 -0
  52. physmap/infra/corpus_runtime.py +275 -0
  53. physmap/integrations/__init__.py +0 -0
  54. physmap/materiality/__init__.py +0 -0
  55. physmap/materiality/estimator.py +239 -0
  56. physmap/materiality/independence.py +92 -0
  57. physmap/materiality/surrogate_fit.py +293 -0
  58. physmap/observability/__init__.py +0 -0
  59. physmap/pipeline/__init__.py +58 -0
  60. physmap/pipeline/aggregators.py +199 -0
  61. physmap/pipeline/assessment_v06.py +509 -0
  62. physmap/pipeline/core.py +442 -0
  63. physmap/pipeline/defeasible_aggregator.py +324 -0
  64. physmap/pipeline/detectors.py +309 -0
  65. physmap/pipeline/observability.py +430 -0
  66. physmap/pipeline/surrogate.py +251 -0
  67. physmap/pipeline/validity_signal.py +273 -0
  68. physmap/pipeline/vehicle_spec.py +287 -0
  69. physmap/release.py +81 -0
  70. physmap/stress_tests/__init__.py +9 -0
  71. physmap/stress_tests/lewis_reuse.py +517 -0
  72. physmap/substrate/__init__.py +28 -0
  73. physmap/substrate/corpus_real.py +206 -0
  74. physmap/substrate/engine.py +209 -0
  75. physmap/substrate/forrest.py +249 -0
  76. physmap/substrate/loaders.py +2176 -0
  77. physmap/substrate/naca_tn1451.py +379 -0
  78. physmap/substrate/naca_wpd_loader.py +187 -0
  79. physmap/substrate/stage1_ingest.py +187 -0
  80. physmap/substrate/vehicle_config.py +407 -0
  81. physmap-0.2.0.dist-info/METADATA +270 -0
  82. physmap-0.2.0.dist-info/RECORD +88 -0
  83. physmap-0.2.0.dist-info/WHEEL +5 -0
  84. physmap-0.2.0.dist-info/entry_points.txt +2 -0
  85. physmap-0.2.0.dist-info/licenses/LICENSE +21 -0
  86. physmap-0.2.0.dist-info/licenses/LICENSE-CORPUS +469 -0
  87. physmap-0.2.0.dist-info/licenses/NOTICE +77 -0
  88. physmap-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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})")