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