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,293 @@
1
+ """Fit a forced-convection surrogate and measure how well it generalises.
2
+
3
+ This exists because of one number that was never recorded: the residual of the original
4
+ study's `Nu_forced(Re)` fit against the gravity-off CFD it was fitted to. Without it the
5
+ coupling between the flag and the surrogate error cannot be bounded -- see
6
+ `docs/findings/surrogate-and-truth-provenance.md`.
7
+
8
+ WHY THE INPUT IS A LIST OF CASES, NOT A MAPPING
9
+ -----------------------------------------------
10
+ An earlier version took `{Re: Nu}`. That is wrong for this grid. A 2-D `(Re, Gr*)` grid
11
+ contains **several cases at the same Re with different Gr, heat flux or entrance length**,
12
+ and a dict keyed on Re silently keeps the last one written. Cases would vanish without
13
+ any error, and the fit would be computed over a set nobody chose.
14
+
15
+ So ingestion is by `Case`, each with its own id and its full operating conditions.
16
+ Duplicate ids raise. Repeated `Re` values are legal and expected.
17
+
18
+ HOW A REPEATED `Re` IS HANDLED, EXPLICITLY
19
+ ------------------------------------------
20
+ 1. **All cases are kept and all are fitted.** Nothing is collapsed or averaged.
21
+ 2. **A surrogate whose only input is `Re` cannot separate them.** Two cases at one `Re`
22
+ with different `Nu` must receive the same prediction, so some error is unavoidable
23
+ *for this choice of surrogate inputs*. Two quantities are reported, and they are not
24
+ the same thing:
25
+
26
+ - `same_re_spread` — the **observed** relative spread `(max − min) / mean`. A
27
+ description of the data, not a bound on anything.
28
+ - `minimax_relative_bound` — the **actual floor** on the worst relative error, for
29
+ the minimax measure: `(max − min) / (max + min)`, attained at the harmonic mean.
30
+
31
+ For `Nu = 8.0` and `9.6` these are 18.2% and 9.1%. An earlier version reported the
32
+ spread and called it the floor; it is about twice the floor. A bound is only
33
+ meaningful once the error measure is named — the RMS-relative measure has a different
34
+ optimum and a different value.
35
+
36
+ Neither quantity says the variation is irreducible **in the physics**. It is
37
+ unresolvable **by a surrogate whose only input is `Re`**, which is a statement about
38
+ the chosen inputs and is exactly why `conditions` is carried on every `Case`.
39
+ 3. **The split groups by `Re`.** Every case sharing an `Re` goes to the same side. A case
40
+ at `Re = 800` in the fit and another at `Re = 800` held out would leak, because the
41
+ surrogate sees only `Re` and would already have been shown that abscissa.
42
+
43
+ Nothing here computes a label, a flag or a metric. It fits, predicts, and reports
44
+ residuals.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import math
50
+ from collections import defaultdict
51
+ from dataclasses import dataclass, field
52
+ from typing import Iterable, Mapping, Sequence
53
+
54
+ __all__ = [
55
+ "Case",
56
+ "FitResult",
57
+ "ResidualReport",
58
+ "ingest",
59
+ "fit_power_law",
60
+ "residuals",
61
+ "split_by_re",
62
+ "fit_and_verify",
63
+ "same_re_spread",
64
+ "minimax_relative_bound",
65
+ "minimax_predictor",
66
+ ]
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Case:
71
+ """One operating point. `conditions` carries everything the surrogate does NOT see --
72
+ `Gr`, wall heat flux, `L/D`, `Pr`, flow direction -- so that what was discarded from
73
+ the surrogate's inputs stays visible in the record."""
74
+
75
+ case_id: str
76
+ Re: float
77
+ Nu: float
78
+ conditions: Mapping[str, float | str] = field(default_factory=dict)
79
+
80
+ def __post_init__(self) -> None:
81
+ if not self.case_id:
82
+ raise ValueError("every case needs an id; anonymous cases cannot be traced")
83
+ if self.Re <= 0 or self.Nu <= 0:
84
+ raise ValueError(
85
+ f"{self.case_id}: Re and Nu must be positive for a log-space fit "
86
+ f"(got Re={self.Re}, Nu={self.Nu})"
87
+ )
88
+
89
+
90
+ def ingest(cases: Iterable[Case]) -> tuple[Case, ...]:
91
+ """Validate a set of cases. Raises on duplicate ids."""
92
+ out = tuple(cases)
93
+ seen: dict[str, Case] = {}
94
+ for c in out:
95
+ if c.case_id in seen:
96
+ raise ValueError(
97
+ f"duplicate case id {c.case_id!r}. Ids must be unique, or cases are "
98
+ f"silently lost -- which is the failure this ingestion exists to prevent."
99
+ )
100
+ seen[c.case_id] = c
101
+ if not out:
102
+ raise ValueError("no cases")
103
+ return out
104
+
105
+
106
+ def by_re(cases: Sequence[Case]) -> dict[float, list[Case]]:
107
+ groups: dict[float, list[Case]] = defaultdict(list)
108
+ for c in cases:
109
+ groups[c.Re].append(c)
110
+ return dict(groups)
111
+
112
+
113
+ def same_re_spread(cases: Sequence[Case]) -> dict[float, float]:
114
+ """Observed relative spread in `Nu` at each repeated `Re`: `(max − min) / mean`.
115
+
116
+ **A description of the data, not a bound.** It is roughly twice the minimax floor,
117
+ and it is reported because it is the quantity a reader expects to see, not because
118
+ anything can be concluded from it. For a floor, use `minimax_relative_bound`.
119
+ """
120
+ out: dict[float, float] = {}
121
+ for re, group in by_re(cases).items():
122
+ if len(group) < 2:
123
+ continue
124
+ nus = [c.Nu for c in group]
125
+ out[re] = (max(nus) - min(nus)) / (sum(nus) / len(nus))
126
+ return out
127
+
128
+
129
+ def minimax_predictor(nus: Sequence[float]) -> float:
130
+ """The single value minimising the worst RELATIVE error over `nus`.
131
+
132
+ Setting the two binding errors equal — the smallest and largest value — gives the
133
+ harmonic mean of the extremes. Intermediate values do not affect a minimax optimum.
134
+ """
135
+ lo, hi = min(nus), max(nus)
136
+ return 2.0 * lo * hi / (lo + hi)
137
+
138
+
139
+ def minimax_relative_bound(cases: Sequence[Case]) -> dict[float, float]:
140
+ """For each repeated `Re`, the floor on the worst relative error a `Re`-only
141
+ surrogate can achieve there.
142
+
143
+ E* = (max − min) / (max + min), attained at p* = 2·min·max / (min + max)
144
+
145
+ **The measure is named on purpose.** This bounds the worst relative error. A
146
+ different measure — RMS relative error, say — has a different optimum and a
147
+ different bound, so "the floor" is not well defined until the measure is fixed.
148
+
149
+ This is a floor under *this surrogate's chosen inputs*, not a claim that the
150
+ variation is irreducible physically.
151
+ """
152
+ out: dict[float, float] = {}
153
+ for re, group in by_re(cases).items():
154
+ if len(group) < 2:
155
+ continue
156
+ nus = [c.Nu for c in group]
157
+ lo, hi = min(nus), max(nus)
158
+ out[re] = (hi - lo) / (hi + lo)
159
+ return out
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class FitResult:
164
+ """`Nu = coefficient * Re**exponent`, fitted in log space over all cases."""
165
+
166
+ coefficient: float
167
+ exponent: float
168
+ n_cases: int
169
+ n_distinct_re: int
170
+ fitted_case_ids: tuple[str, ...]
171
+ #: Observed spread at repeated Re -- a description, not a bound.
172
+ same_re_spread: Mapping[float, float] = field(default_factory=dict)
173
+ #: Floor on the WORST RELATIVE error at repeated Re, for the minimax measure.
174
+ minimax_relative_bound: Mapping[float, float] = field(default_factory=dict)
175
+
176
+ def predict(self, re: float) -> float:
177
+ return self.coefficient * re**self.exponent
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class ResidualReport:
182
+ label: str
183
+ n: int
184
+ max_abs_rel: float | None = None
185
+ rms_rel: float | None = None
186
+ mean_signed_rel: float | None = None
187
+ per_case: tuple[tuple[str, float, float], ...] = field(default_factory=tuple)
188
+
189
+ def summary(self) -> str:
190
+ if not self.n:
191
+ return f"{self.label}: no cases"
192
+ return (
193
+ f"{self.label}: n={self.n} max|rel|={self.max_abs_rel:.4%} "
194
+ f"rms={self.rms_rel:.4%} mean signed={self.mean_signed_rel:+.4%}"
195
+ )
196
+
197
+
198
+ def fit_power_law(cases: Sequence[Case]) -> FitResult:
199
+ """Least-squares fit of `Nu = C Re^n` in log space over every case given.
200
+
201
+ Requires at least two DISTINCT `Re` values: many cases at one `Re` determine a
202
+ coefficient but not an exponent.
203
+ """
204
+ cases = ingest(cases)
205
+ groups = by_re(cases)
206
+ if len(groups) < 2:
207
+ raise ValueError(
208
+ f"a power-law fit needs at least 2 distinct Re values, got {len(groups)} "
209
+ f"across {len(cases)} case(s). The exponent is otherwise unidentifiable."
210
+ )
211
+ xs = [math.log(c.Re) for c in cases]
212
+ ys = [math.log(c.Nu) for c in cases]
213
+ n = len(xs)
214
+ mx, my = sum(xs) / n, sum(ys) / n
215
+ sxx = sum((x - mx) ** 2 for x in xs)
216
+ sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
217
+ exponent = sxy / sxx
218
+ return FitResult(
219
+ coefficient=math.exp(my - exponent * mx),
220
+ exponent=exponent,
221
+ n_cases=n,
222
+ n_distinct_re=len(groups),
223
+ fitted_case_ids=tuple(c.case_id for c in cases),
224
+ same_re_spread=same_re_spread(cases),
225
+ minimax_relative_bound=minimax_relative_bound(cases),
226
+ )
227
+
228
+
229
+ def residuals(fit: FitResult, cases: Sequence[Case], label: str) -> ResidualReport:
230
+ if not cases:
231
+ return ResidualReport(label=label, n=0)
232
+ per = tuple(
233
+ (c.case_id, c.Re, (fit.predict(c.Re) - c.Nu) / c.Nu)
234
+ for c in sorted(cases, key=lambda q: (q.Re, q.case_id))
235
+ )
236
+ rels = [r for _, _, r in per]
237
+ return ResidualReport(
238
+ label=label,
239
+ n=len(rels),
240
+ max_abs_rel=max(abs(r) for r in rels),
241
+ rms_rel=math.sqrt(sum(r * r for r in rels) / len(rels)),
242
+ mean_signed_rel=sum(rels) / len(rels),
243
+ per_case=per,
244
+ )
245
+
246
+
247
+ def split_by_re(
248
+ cases: Sequence[Case], holdout_re: Iterable[float]
249
+ ) -> tuple[tuple[Case, ...], tuple[Case, ...]]:
250
+ """Split so that every case sharing an `Re` lands on the same side.
251
+
252
+ The surrogate's only input is `Re`, so splitting within an `Re` would show the fit an
253
+ abscissa it is then scored on.
254
+ """
255
+ cases = ingest(cases)
256
+ hold = set(holdout_re)
257
+ unknown = hold - set(by_re(cases))
258
+ if unknown:
259
+ raise ValueError(f"held-out Re values not present in the cases: {sorted(unknown)}")
260
+ fit_side = tuple(c for c in cases if c.Re not in hold)
261
+ held_side = tuple(c for c in cases if c.Re in hold)
262
+ if not fit_side:
263
+ raise ValueError("every Re was held out; nothing left to fit")
264
+ return fit_side, held_side
265
+
266
+
267
+ def fit_and_verify(
268
+ fit_cases: Sequence[Case], held_out_cases: Sequence[Case]
269
+ ) -> tuple[FitResult, ResidualReport, ResidualReport]:
270
+ """Fit on one set of cases, report residuals on both.
271
+
272
+ Raises if any `Re` appears on both sides -- the leak that makes a held-out residual a
273
+ fitted residual under another name, and that is silent when unchecked.
274
+ """
275
+ fit_cases, held_out_cases = ingest(fit_cases), tuple(held_out_cases)
276
+ if held_out_cases:
277
+ ingest(held_out_cases)
278
+ shared_ids = {c.case_id for c in fit_cases} & {c.case_id for c in held_out_cases}
279
+ if shared_ids:
280
+ raise ValueError(f"case ids appear on both sides: {sorted(shared_ids)}")
281
+ shared_re = set(by_re(fit_cases)) & set(by_re(held_out_cases))
282
+ if shared_re:
283
+ raise ValueError(
284
+ f"Re values appear on both sides: {sorted(shared_re)}. The surrogate sees "
285
+ f"only Re, so the held-out residual would be a fitted residual under another "
286
+ f"name. Use split_by_re() to divide the cases."
287
+ )
288
+ fit = fit_power_law(fit_cases)
289
+ return (
290
+ fit,
291
+ residuals(fit, fit_cases, "fitted"),
292
+ residuals(fit, held_out_cases, "held out"),
293
+ )
File without changes
@@ -0,0 +1,58 @@
1
+ """D3 detection + adjudication pipeline — the architecture refactor's
2
+ detection-side surface.
3
+
4
+ Module layout (renamed during R4 reorg — d3_ prefix dropped):
5
+ core (was d3_pipeline) Pipeline + DetectorAdapter + protocols
6
+ aggregators (was d3_aggregators) Phase-1 aggregators (AnyFired, ...)
7
+ defeasible_aggregator (was d3_defeasible_aggregator) Phase-2 reasoner
8
+ detectors (was d3_detectors) Inner detector classes
9
+ validity_signal (was d3_validity_signal) Literature-distance detector
10
+ surrogate (was d3_surrogate) GP surrogate + thresholds
11
+ phase1_gate (was d3_phase1_gate) Phase-1 NACA gate runner
12
+ phase2_gate (was d3_phase2_gate) Phase-2 NACA gate runner
13
+ assessment_v06 Phase-1 -> v0.6 subgraph mapper
14
+ evidence_stage Claim-centric JustificationStage
15
+
16
+ Common entrypoints re-exported so consumers can write
17
+ `from physmap.pipeline import Pipeline, AnyFired, ...`:
18
+ """
19
+
20
+ from physmap.pipeline.aggregators import AnyFired, CorpusGated, WeightedVote
21
+ from physmap.pipeline.core import (
22
+ Assessment,
23
+ Detector,
24
+ DetectorAdapter,
25
+ DetectorResult,
26
+ JustificationStage,
27
+ Pipeline,
28
+ Verdict,
29
+ make_closure_validity_adapter,
30
+ make_distance_adapter,
31
+ make_ensemble_variance_adapter,
32
+ make_gp_variance_adapter,
33
+ )
34
+ from physmap.pipeline.defeasible_aggregator import (
35
+ AdjudicationResult,
36
+ DefeasibleAdjudicator,
37
+ OffsetRule,
38
+ )
39
+
40
+ __all__ = [
41
+ "Assessment",
42
+ "Detector",
43
+ "DetectorAdapter",
44
+ "DetectorResult",
45
+ "JustificationStage",
46
+ "Pipeline",
47
+ "Verdict",
48
+ "AnyFired",
49
+ "CorpusGated",
50
+ "WeightedVote",
51
+ "AdjudicationResult",
52
+ "DefeasibleAdjudicator",
53
+ "OffsetRule",
54
+ "make_closure_validity_adapter",
55
+ "make_distance_adapter",
56
+ "make_ensemble_variance_adapter",
57
+ "make_gp_variance_adapter",
58
+ ]
@@ -0,0 +1,199 @@
1
+ """Phase-1 aggregators — Aggregator implementations for the D3 pipeline.
2
+
3
+ Per the v0.2 architecture refactor (Part 2): the aggregator consumes ONLY
4
+ decision signals and reduces them to a `Verdict`. Three Phase-1 aggregators:
5
+
6
+ * `AnyFired` — union of detector fires. The locked ensemble metric
7
+ (per [[physmap-ensemble-claim-definition]]):
8
+ ensemble := steelman_baseline ∪ corpus_validity_signal
9
+ success iff Pareto dominance (≥ everywhere, > somewhere).
10
+ * `CorpusGated` — corpus acts on points the baselines pass. Useful for
11
+ diagnosing whether the corpus signal adds catches on
12
+ operating points the baselines incorrectly stay quiet on.
13
+ * `WeightedVote` — sum of (fired × weight) > threshold. Phase-1 stub for
14
+ future calibration experiments; not used in the locked
15
+ ensemble metric.
16
+
17
+ Phase-2 plugs in the DEFEASIBLE-ADJUDICATION reasoner (offset / agreement /
18
+ threshold-distance modulation → Disposition). NOT built — gated on NACA result.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Iterable
25
+
26
+ from physmap.pipeline.core import (
27
+ Aggregator,
28
+ DetectorResult,
29
+ SignalRole,
30
+ Verdict,
31
+ )
32
+
33
+
34
+ # ── AnyFired (the locked ensemble metric) ───────────────────────────────────
35
+
36
+ @dataclass
37
+ class AnyFired:
38
+ """Union of all decision-signal fires.
39
+
40
+ Reduces decision signals to fire iff at least one fired. The rationale
41
+ cites every detector that fired (so audit trails always name names);
42
+ on quiet, the rationale states all detectors were quiet.
43
+
44
+ This is the LOCKED ensemble metric per
45
+ `[[physmap-ensemble-claim-definition]]`:
46
+ ensemble := steelman_baseline ∪ corpus_validity_signal
47
+ The aggregator IS the ensemble — building the union as an Aggregator
48
+ makes the ensemble metric fall out of the pipeline naturally.
49
+ """
50
+ name: str = "any_fired"
51
+
52
+ def combine(self, decision_signals: dict[str, DetectorResult]) -> Verdict:
53
+ if not decision_signals:
54
+ raise ValueError(
55
+ "AnyFired.combine received no decision signals; nothing to "
56
+ "weigh. The pipeline should not call the aggregator on empty "
57
+ "input."
58
+ )
59
+ fired = [r for r in decision_signals.values() if r.fired]
60
+ if fired:
61
+ rationale = "; ".join(r.rationale for r in fired)
62
+ return Verdict(label="fire", rationale=rationale)
63
+ rationale = "all detectors quiet: " + "; ".join(
64
+ r.rationale for r in decision_signals.values()
65
+ )
66
+ return Verdict(label="quiet", rationale=rationale)
67
+
68
+
69
+ # ── CorpusGated ─────────────────────────────────────────────────────────────
70
+
71
+ @dataclass
72
+ class CorpusGated:
73
+ """Fire iff (baselines all quiet) AND (corpus-validity fires).
74
+
75
+ Surfaces the "corpus catches what baselines miss" cell — the diagnostic
76
+ operating point where corpus's structural property (literature-only,
77
+ no training dependence) buys actual lift. If baselines fire on a point,
78
+ this aggregator stays quiet (already covered).
79
+
80
+ `baseline_names` lists which detectors count as "baselines"; the
81
+ remaining detector(s) act as the corpus arm. The corpus arm must
82
+ include AT LEAST one signal with `threshold is None` (the literature-
83
+ only validity detector) — otherwise CorpusGated degenerates into
84
+ "all baselines quiet" with no corpus voice.
85
+ """
86
+ baseline_names: tuple[str, ...]
87
+ corpus_names: tuple[str, ...] = ("closure_validity",)
88
+ name: str = "corpus_gated"
89
+
90
+ def combine(self, decision_signals: dict[str, DetectorResult]) -> Verdict:
91
+ missing_b = [n for n in self.baseline_names if n not in decision_signals]
92
+ missing_c = [n for n in self.corpus_names if n not in decision_signals]
93
+ if missing_b or missing_c:
94
+ raise KeyError(
95
+ f"CorpusGated.combine missing required detectors: "
96
+ f"baselines={missing_b}, corpus={missing_c}. "
97
+ f"Got detectors: {sorted(decision_signals.keys())}."
98
+ )
99
+ baseline_fired = any(decision_signals[n].fired for n in self.baseline_names)
100
+ corpus_fired = any(decision_signals[n].fired for n in self.corpus_names)
101
+
102
+ if (not baseline_fired) and corpus_fired:
103
+ corpus_rationales = "; ".join(
104
+ decision_signals[n].rationale for n in self.corpus_names
105
+ if decision_signals[n].fired
106
+ )
107
+ return Verdict(
108
+ label="fire",
109
+ rationale=(
110
+ f"corpus_gated: baselines all quiet AND corpus fires -> "
111
+ f"corpus catches what baselines missed. {corpus_rationales}"
112
+ ),
113
+ )
114
+ if baseline_fired and corpus_fired:
115
+ return Verdict(
116
+ label="quiet",
117
+ rationale=(
118
+ "corpus_gated: both baselines and corpus fired; "
119
+ "this aggregator surfaces ONLY the baselines-miss / "
120
+ "corpus-catch operating point, so quiet here."
121
+ ),
122
+ )
123
+ if baseline_fired and not corpus_fired:
124
+ return Verdict(
125
+ label="quiet",
126
+ rationale=(
127
+ "corpus_gated: baseline fired, corpus quiet. Not a "
128
+ "corpus-catch point; verdict is quiet."
129
+ ),
130
+ )
131
+ return Verdict(
132
+ label="quiet",
133
+ rationale="corpus_gated: all detectors quiet.",
134
+ )
135
+
136
+
137
+ # ── WeightedVote (Phase-1 stub for calibration experiments) ─────────────────
138
+
139
+ @dataclass
140
+ class WeightedVote:
141
+ """Fire iff sum(weight_i for detector_i in fired) >= vote_threshold.
142
+
143
+ A future calibration knob — not used in the locked ensemble metric.
144
+ `weights` maps detector_name -> weight; missing detectors default to
145
+ 0.0 (silent abstention). `vote_threshold` is the sum-of-weights cutoff
146
+ (default 1.0; with unit weights this degenerates to AnyFired).
147
+
148
+ Phase-1 ships this as a stub so the strategy axis is real (Aggregator
149
+ is a true plug point), but NACA's gate metric is AnyFired.
150
+ """
151
+ weights: dict[str, float]
152
+ vote_threshold: float = 1.0
153
+ name: str = "weighted_vote"
154
+
155
+ def __post_init__(self) -> None:
156
+ if not self.weights:
157
+ raise ValueError(
158
+ "WeightedVote requires a non-empty weights dict; otherwise "
159
+ "no detector can contribute to the vote."
160
+ )
161
+ if any(w < 0 for w in self.weights.values()):
162
+ raise ValueError(
163
+ f"WeightedVote.weights cannot be negative: {self.weights}. "
164
+ f"A negative weight would mean 'fired -> reduce verdict'; "
165
+ f"that's not voting, that's defeasibility (Phase 2)."
166
+ )
167
+ if self.vote_threshold <= 0:
168
+ raise ValueError(
169
+ f"WeightedVote.vote_threshold must be > 0; got "
170
+ f"{self.vote_threshold}."
171
+ )
172
+
173
+ def combine(self, decision_signals: dict[str, DetectorResult]) -> Verdict:
174
+ total = 0.0
175
+ contributors: list[str] = []
176
+ for name, weight in self.weights.items():
177
+ r = decision_signals.get(name)
178
+ if r is not None and r.fired:
179
+ total += weight
180
+ contributors.append(f"{name}(w={weight:g})")
181
+ if total >= self.vote_threshold:
182
+ return Verdict(
183
+ label="fire",
184
+ rationale=(
185
+ f"weighted_vote: total={total:g} >= threshold="
186
+ f"{self.vote_threshold:g} from {contributors}"
187
+ ),
188
+ )
189
+ return Verdict(
190
+ label="quiet",
191
+ rationale=(
192
+ f"weighted_vote: total={total:g} < threshold="
193
+ f"{self.vote_threshold:g} "
194
+ f"(fired contributors: {contributors or '(none)'})"
195
+ ),
196
+ )
197
+
198
+
199
+ __all__ = ["AnyFired", "CorpusGated", "WeightedVote"]