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