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,519 @@
|
|
|
1
|
+
"""PhysMAP Regime → Observability Mapping — schema, generator, validator, loader, CLI.
|
|
2
|
+
|
|
3
|
+
The static knowledge the observability classifier resolves against at `fit`
|
|
4
|
+
(spec: docs/specs/PhysMAP_Regime_Observability_Mapping_Spec_v0_1.md), materialized
|
|
5
|
+
as ONE version-tagged JSONL artifact with three tables (one `table`-tagged record
|
|
6
|
+
per line, line-diffable):
|
|
7
|
+
|
|
8
|
+
* regimes — Regime → closure_ids (spec Layer 1)
|
|
9
|
+
* closure_bounds — closure_id → [{variable, bound, status,…}] (spec Layer 1)
|
|
10
|
+
* variable_observability — variable → observability_class + the graded-partial
|
|
11
|
+
degree slot (spec Layers 2a + 2c)
|
|
12
|
+
|
|
13
|
+
AUTHORITY: this artifact is a GENERATED PROJECTION of the existing authoritative
|
|
14
|
+
sources — it is never a second runtime source of truth. The classifier keeps
|
|
15
|
+
resolving against the code maps directly; this file just makes the mapping
|
|
16
|
+
inspectable, version-tagged, and shippable. The sources:
|
|
17
|
+
|
|
18
|
+
* regimes / closure set ← guardrail.corpus_regimes.REGIME_TO_CLOSURES
|
|
19
|
+
* bounds (Layer 1) ← corpus.calibration validated_range (corpus.jsonl)
|
|
20
|
+
* observability_class (2a) ← guardrail.corpus_regimes.observability_class_for
|
|
21
|
+
* partial_degree slot (2c) ← guardrail.corpus_regimes.partial_degree_for
|
|
22
|
+
|
|
23
|
+
Regenerate with `python -m physmap.guardrail.regime_observability build`. A test
|
|
24
|
+
(tests/test_regime_observability_mapping.py) asserts the committed artifact equals
|
|
25
|
+
a fresh build, so it cannot silently drift from the sources.
|
|
26
|
+
|
|
27
|
+
This is a guardrail-LAYER tool (it reads the guardrail's corpus_regimes maps), not
|
|
28
|
+
a corpus-layer one — so it lives here rather than under corpus/, which stays
|
|
29
|
+
stdlib-only and foundational.
|
|
30
|
+
|
|
31
|
+
Honesty discipline (spec): empty Layer-2c cells stay `uncalibrated` — an
|
|
32
|
+
uncalibrated degree that drove a weight would be a fabricated empirical claim.
|
|
33
|
+
The validator REJECTS any `uncalibrated` slot carrying a degree, and any
|
|
34
|
+
`calibrated` slot missing its degree or its `calibrated_by` provenance.
|
|
35
|
+
|
|
36
|
+
Torch-free and LLM-free (the no-heavy-imports guard stays green); numpy/sklearn
|
|
37
|
+
arrive transitively via the guardrail package, which is expected at this layer.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import hashlib
|
|
44
|
+
import json
|
|
45
|
+
import sys
|
|
46
|
+
from dataclasses import asdict, dataclass, field
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
from physmap.corpus.calibration import ClosureEntry, index_by_id, load_corpus
|
|
50
|
+
from physmap.guardrail.corpus_regimes import (
|
|
51
|
+
KNOWN_PARTIAL,
|
|
52
|
+
REGIME_TO_CLOSURES,
|
|
53
|
+
observability_class_for,
|
|
54
|
+
partial_degree_for,
|
|
55
|
+
)
|
|
56
|
+
from physmap.guardrail.enums import Regime
|
|
57
|
+
|
|
58
|
+
# ── controlled vocabularies ───────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
OBSERVABILITY_CLASSES = {"structural-binary", "known-partial"}
|
|
61
|
+
DEGREE_STATUSES = {"n/a", "uncalibrated", "calibrated"}
|
|
62
|
+
TABLE_NAMES = {"regimes", "closure_bounds", "variable_observability"}
|
|
63
|
+
|
|
64
|
+
# Display alias: the short surrogate-input name for a corpus coord, mirroring the
|
|
65
|
+
# classifier's coord↔input bridge (physmap/guardrail/classify.py). Readability only —
|
|
66
|
+
# the authoritative join key everywhere is the long corpus coord name.
|
|
67
|
+
_COORD_SHORT_ALIAS: dict[str, str] = {
|
|
68
|
+
"reynolds_number": "Re",
|
|
69
|
+
"prandtl_number": "Pr",
|
|
70
|
+
"x_over_D": "x_over_D",
|
|
71
|
+
"richardson_number": "Ri",
|
|
72
|
+
"viscosity_ratio_wall_bulk": "mu_w/mu_b",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
MAPPING_VERSION = "0.2.3" # 0.2.3: +Jin sCO2 vertical-tube buoyancy — MIXED_CONVECTION_VERTICAL_TUBE -> dittus-boelter-buoyancy-sco2 carrying liu_buoyancy_parameter (Liu Bu). FIRST genuinely WIRED closure since 0.2.2 (the buoyancy benchmark cell FIRES), so the projection grows by one regime + one closure (mapping.jsonl regenerated, not byte-identical). Bu classed structural-binary -> UNOBSERVABLE (MEASURED cv_r2_knn~0 from (Re,Pr); Casper-structured direction toggle, NOT a partial middle). 0.2.2: +VDI gnielinski-1976 prandtl_ratio_bulk_wall & temperature_ratio_bulk_wall (UNOBSERVABLE, fire on gnielinski's regimes) + new inert closures (swanson-catton grashof_number; mack/perfect-gas mach_number; blasius-horizontal grouping coords), additive. 0.2.1: +richardson_number on blasius-pohlhausen (Sparrow-Gregg)
|
|
76
|
+
|
|
77
|
+
# Checkout-only artifact: generated by this module, committed, and checked for
|
|
78
|
+
# drift by a test. It is not runtime data and does not ship in the wheel.
|
|
79
|
+
def _default_path() -> Path:
|
|
80
|
+
from physmap._paths import repo_root
|
|
81
|
+
root = repo_root()
|
|
82
|
+
base = root if root is not None else Path.cwd()
|
|
83
|
+
return base / "data" / "regime_observability" / "mapping.jsonl"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
DEFAULT_PATH = _default_path()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── schema dataclasses ──────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class RegimeRow:
|
|
93
|
+
"""regimes table — one Regime member (minus UNLISTED) and its closure set."""
|
|
94
|
+
regime: str # Regime enum value, e.g. "entrance_region_pipe"
|
|
95
|
+
closures: list[str] # closure_ids
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class BoundExpr:
|
|
100
|
+
"""One validity bound of a closure, projected from the calibration corpus."""
|
|
101
|
+
variable: str # corpus coord (long), e.g. "reynolds_number"
|
|
102
|
+
variable_short: str # surrogate-input alias, e.g. "Re" (display only)
|
|
103
|
+
bound: str # human expr, e.g. "3000 <= Re <= 5e+06"
|
|
104
|
+
status: str # corpus bound_status (confirmed/claimed/…)
|
|
105
|
+
min: float | None
|
|
106
|
+
max: float | None
|
|
107
|
+
unit: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class ClosureBoundRow:
|
|
112
|
+
"""closure_bounds table — one row per distinct closure (deduped across regimes)."""
|
|
113
|
+
closure_id: str
|
|
114
|
+
bounds: list[BoundExpr]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class VariableObservabilityRow:
|
|
119
|
+
"""variable_observability table — Layer 2a tag + Layer 2c graded-degree slot."""
|
|
120
|
+
variable: str # corpus coord (long)
|
|
121
|
+
variable_short: str # display alias
|
|
122
|
+
observability_class: str # ∈ OBSERVABILITY_CLASSES
|
|
123
|
+
partial_degree: dict | None # null until calibrated; {regime_value: degree} once
|
|
124
|
+
degree_status: str # ∈ DEGREE_STATUSES
|
|
125
|
+
calibrated_by: list[str] = field(default_factory=list) # middle-vehicle ids (provenance)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class RegimeObservabilityMapping:
|
|
130
|
+
"""The whole artifact: three tables. Dataclass equality drives the drift test."""
|
|
131
|
+
regimes: list[RegimeRow]
|
|
132
|
+
closure_bounds: list[ClosureBoundRow]
|
|
133
|
+
variable_observability: list[VariableObservabilityRow]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── human-readable bound expression ───────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def _fmt(x: float) -> str:
|
|
139
|
+
return f"{x:g}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _bound_expr(name: str, lo: float | None, hi: float | None) -> str:
|
|
143
|
+
if lo is not None and hi is not None:
|
|
144
|
+
return f"{_fmt(lo)} <= {name} <= {_fmt(hi)}"
|
|
145
|
+
if lo is not None:
|
|
146
|
+
return f"{name} >= {_fmt(lo)}"
|
|
147
|
+
if hi is not None:
|
|
148
|
+
return f"{name} <= {_fmt(hi)}"
|
|
149
|
+
return name
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _short_alias(coord: str) -> str:
|
|
153
|
+
return _COORD_SHORT_ALIAS.get(coord, coord)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── generator (the projection from authoritative sources) ─────────────────────
|
|
157
|
+
|
|
158
|
+
def _variable_rows(
|
|
159
|
+
closure_bounds: list[ClosureBoundRow],
|
|
160
|
+
) -> list[VariableObservabilityRow]:
|
|
161
|
+
"""One row per distinct bound variable, with its (per-variable) class + 2c slot.
|
|
162
|
+
|
|
163
|
+
The class is a property of the variable's physics, so it must be consistent across
|
|
164
|
+
every closure the coord appears in; the validator enforces that. The graded degree
|
|
165
|
+
is regime-specific, so partial_degree is a {regime_value: degree} map merged over
|
|
166
|
+
the coord's closures — empty (→ uncalibrated) until a middle vehicle calibrates it.
|
|
167
|
+
"""
|
|
168
|
+
coord_to_closures: dict[str, list[str]] = {}
|
|
169
|
+
for cbr in closure_bounds:
|
|
170
|
+
for b in cbr.bounds:
|
|
171
|
+
seen = coord_to_closures.setdefault(b.variable, [])
|
|
172
|
+
if cbr.closure_id not in seen:
|
|
173
|
+
seen.append(cbr.closure_id)
|
|
174
|
+
|
|
175
|
+
rows: list[VariableObservabilityRow] = []
|
|
176
|
+
for coord in sorted(coord_to_closures):
|
|
177
|
+
cids = coord_to_closures[coord]
|
|
178
|
+
classes = {observability_class_for(cid, coord) for cid in cids}
|
|
179
|
+
klass = KNOWN_PARTIAL if KNOWN_PARTIAL in classes else "structural-binary"
|
|
180
|
+
|
|
181
|
+
if klass != KNOWN_PARTIAL:
|
|
182
|
+
degree, status, calby = None, "n/a", []
|
|
183
|
+
else:
|
|
184
|
+
merged: dict[str, float] = {}
|
|
185
|
+
calby = []
|
|
186
|
+
any_calibrated = False
|
|
187
|
+
for cid in cids:
|
|
188
|
+
d, st, cb = partial_degree_for(cid, coord)
|
|
189
|
+
if st == "calibrated" and d:
|
|
190
|
+
any_calibrated = True
|
|
191
|
+
merged.update(d)
|
|
192
|
+
for x in cb:
|
|
193
|
+
if x not in calby:
|
|
194
|
+
calby.append(x)
|
|
195
|
+
if any_calibrated:
|
|
196
|
+
degree, status = merged, "calibrated"
|
|
197
|
+
else:
|
|
198
|
+
degree, status, calby = None, "uncalibrated", []
|
|
199
|
+
|
|
200
|
+
rows.append(VariableObservabilityRow(
|
|
201
|
+
variable=coord, variable_short=_short_alias(coord),
|
|
202
|
+
observability_class=klass, partial_degree=degree,
|
|
203
|
+
degree_status=status, calibrated_by=calby,
|
|
204
|
+
))
|
|
205
|
+
return rows
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_mapping(
|
|
209
|
+
corpus_index: dict[str, ClosureEntry] | None = None,
|
|
210
|
+
regime_to_closures: dict[Regime, tuple[str, ...]] | None = None,
|
|
211
|
+
) -> RegimeObservabilityMapping:
|
|
212
|
+
"""Compose the three tables from the authoritative code/corpus sources.
|
|
213
|
+
|
|
214
|
+
Deterministic: regimes in enum-declaration order (minus UNLISTED), closures and
|
|
215
|
+
variables sorted by id/name, bounds in corpus order. A dangling closure_id (in a
|
|
216
|
+
regime but not in the corpus) is omitted from closure_bounds and flagged by the
|
|
217
|
+
validator, rather than silently fabricated.
|
|
218
|
+
"""
|
|
219
|
+
if corpus_index is None:
|
|
220
|
+
corpus_index = index_by_id(load_corpus())
|
|
221
|
+
if regime_to_closures is None:
|
|
222
|
+
regime_to_closures = REGIME_TO_CLOSURES
|
|
223
|
+
|
|
224
|
+
listed = [r for r in Regime if r is not Regime.UNLISTED]
|
|
225
|
+
|
|
226
|
+
regimes = [
|
|
227
|
+
RegimeRow(regime=r.value, closures=list(regime_to_closures.get(r, ())))
|
|
228
|
+
for r in listed
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
closure_ids: set[str] = set()
|
|
232
|
+
for r in listed:
|
|
233
|
+
closure_ids.update(regime_to_closures.get(r, ()))
|
|
234
|
+
|
|
235
|
+
closure_bounds: list[ClosureBoundRow] = []
|
|
236
|
+
for cid in sorted(closure_ids):
|
|
237
|
+
entry = corpus_index.get(cid)
|
|
238
|
+
if entry is None:
|
|
239
|
+
continue # dangling — validator flags it
|
|
240
|
+
bounds = [
|
|
241
|
+
BoundExpr(
|
|
242
|
+
variable=b.coord, variable_short=_short_alias(b.coord),
|
|
243
|
+
bound=_bound_expr(_short_alias(b.coord), b.min, b.max),
|
|
244
|
+
status=b.bound_status, min=b.min, max=b.max, unit=b.unit,
|
|
245
|
+
)
|
|
246
|
+
for b in entry.validated_range
|
|
247
|
+
]
|
|
248
|
+
closure_bounds.append(ClosureBoundRow(closure_id=cid, bounds=bounds))
|
|
249
|
+
|
|
250
|
+
variable_observability = _variable_rows(closure_bounds)
|
|
251
|
+
|
|
252
|
+
return RegimeObservabilityMapping(
|
|
253
|
+
regimes=regimes,
|
|
254
|
+
closure_bounds=closure_bounds,
|
|
255
|
+
variable_observability=variable_observability,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ── (de)serialization (one `table`-tagged record per JSONL line) ──────────────
|
|
260
|
+
|
|
261
|
+
def to_records(m: RegimeObservabilityMapping) -> list[dict]:
|
|
262
|
+
recs: list[dict] = []
|
|
263
|
+
for r in m.regimes:
|
|
264
|
+
recs.append({"table": "regimes", **asdict(r)})
|
|
265
|
+
for c in m.closure_bounds:
|
|
266
|
+
recs.append({"table": "closure_bounds", **asdict(c)})
|
|
267
|
+
for v in m.variable_observability:
|
|
268
|
+
recs.append({"table": "variable_observability", **asdict(v)})
|
|
269
|
+
return recs
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def to_jsonl(m: RegimeObservabilityMapping) -> str:
|
|
273
|
+
return "".join(json.dumps(rec, ensure_ascii=False) + "\n" for rec in to_records(m))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def from_records(records: list[dict]) -> RegimeObservabilityMapping:
|
|
277
|
+
regimes: list[RegimeRow] = []
|
|
278
|
+
closure_bounds: list[ClosureBoundRow] = []
|
|
279
|
+
variable_observability: list[VariableObservabilityRow] = []
|
|
280
|
+
for rec in records:
|
|
281
|
+
table = rec.get("table")
|
|
282
|
+
body = {k: v for k, v in rec.items() if k != "table"}
|
|
283
|
+
if table == "regimes":
|
|
284
|
+
regimes.append(RegimeRow(**body))
|
|
285
|
+
elif table == "closure_bounds":
|
|
286
|
+
body["bounds"] = [BoundExpr(**b) for b in body.get("bounds", [])]
|
|
287
|
+
closure_bounds.append(ClosureBoundRow(**body))
|
|
288
|
+
elif table == "variable_observability":
|
|
289
|
+
variable_observability.append(VariableObservabilityRow(**body))
|
|
290
|
+
else:
|
|
291
|
+
raise ValueError(f"unknown table discriminator: {table!r}")
|
|
292
|
+
return RegimeObservabilityMapping(
|
|
293
|
+
regimes=regimes,
|
|
294
|
+
closure_bounds=closure_bounds,
|
|
295
|
+
variable_observability=variable_observability,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def load_mapping(path: str | Path = DEFAULT_PATH) -> RegimeObservabilityMapping:
|
|
300
|
+
"""Parse the JSONL artifact into a RegimeObservabilityMapping. Raises on parse
|
|
301
|
+
errors; consistency must be checked separately via validate_mapping()."""
|
|
302
|
+
p = Path(path)
|
|
303
|
+
if not p.exists():
|
|
304
|
+
raise FileNotFoundError(f"mapping file not found: {p}")
|
|
305
|
+
records: list[dict] = []
|
|
306
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
307
|
+
for lineno, line in enumerate(f, start=1):
|
|
308
|
+
stripped = line.strip()
|
|
309
|
+
if not stripped:
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
records.append(json.loads(stripped))
|
|
313
|
+
except json.JSONDecodeError as e:
|
|
314
|
+
raise ValueError(f"mapping JSONL parse error at line {lineno}: {e}") from e
|
|
315
|
+
try:
|
|
316
|
+
return from_records(records)
|
|
317
|
+
except (TypeError, ValueError) as e:
|
|
318
|
+
raise ValueError(f"mapping schema mismatch: {e}") from e
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def mapping_fingerprint(path: str | Path = DEFAULT_PATH) -> dict:
|
|
322
|
+
"""{'version', 'sha256', 'n_records'} for a save/load manifest (mirrors
|
|
323
|
+
corpus_fingerprint). Hash is over SORTED non-empty lines — order-independent."""
|
|
324
|
+
p = Path(path)
|
|
325
|
+
lines = [ln.strip() for ln in p.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
|
326
|
+
digest = hashlib.sha256("\n".join(sorted(lines)).encode("utf-8")).hexdigest()
|
|
327
|
+
return {"version": MAPPING_VERSION, "sha256": digest, "n_records": len(lines)}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ── consistency validation (the spec's done-gate, as one function) ────────────
|
|
331
|
+
|
|
332
|
+
def validate_mapping(
|
|
333
|
+
m: RegimeObservabilityMapping,
|
|
334
|
+
corpus_index: dict[str, ClosureEntry] | None = None,
|
|
335
|
+
) -> dict[str, list[str]]:
|
|
336
|
+
"""Return errors keyed by table name (empty dict = the mapping is consistent).
|
|
337
|
+
|
|
338
|
+
Implements the spec's done-gate invariants:
|
|
339
|
+
• enum ↔ regimes agreement (every Regime member minus UNLISTED; no strays)
|
|
340
|
+
• every regime resolves to ≥1 closure with ≥1 bound; closures exist in the corpus
|
|
341
|
+
• every Layer-1 bound variable has a Layer-2a row (and no orphan rows)
|
|
342
|
+
• a variable's observability_class is consistent across every closure it appears in
|
|
343
|
+
• known-partial → an uncalibrated|calibrated 2c slot; structural-binary → no degree
|
|
344
|
+
• honesty gate: uncalibrated carries no degree; calibrated carries degree + provenance
|
|
345
|
+
"""
|
|
346
|
+
if corpus_index is None:
|
|
347
|
+
corpus_index = index_by_id(load_corpus())
|
|
348
|
+
out: dict[str, list[str]] = {}
|
|
349
|
+
|
|
350
|
+
def add(key: str, msg: str) -> None:
|
|
351
|
+
out.setdefault(key, []).append(msg)
|
|
352
|
+
|
|
353
|
+
# enum ↔ regimes
|
|
354
|
+
expected = {r.value for r in Regime if r is not Regime.UNLISTED}
|
|
355
|
+
present = [row.regime for row in m.regimes]
|
|
356
|
+
present_set = set(present)
|
|
357
|
+
for miss in sorted(expected - present_set):
|
|
358
|
+
add("regimes", f"missing regime row: {miss}")
|
|
359
|
+
for stray in sorted(present_set - expected):
|
|
360
|
+
add("regimes", f"regime row '{stray}' is not a Regime member (or UNLISTED leaked in)")
|
|
361
|
+
if len(present) != len(present_set):
|
|
362
|
+
add("regimes", "duplicate regime rows")
|
|
363
|
+
for row in m.regimes:
|
|
364
|
+
if not row.closures:
|
|
365
|
+
add("regimes", f"{row.regime}: no closures (every listed regime needs ≥1)")
|
|
366
|
+
|
|
367
|
+
# closure_bounds: dedupe, regime FK, corpus FK, ≥1 bound
|
|
368
|
+
cb_index = {c.closure_id: c for c in m.closure_bounds}
|
|
369
|
+
if len(cb_index) != len(m.closure_bounds):
|
|
370
|
+
add("closure_bounds", "duplicate closure_id rows")
|
|
371
|
+
referenced = {cid for row in m.regimes for cid in row.closures}
|
|
372
|
+
for cid in sorted(referenced):
|
|
373
|
+
if cid not in cb_index:
|
|
374
|
+
add("closure_bounds", f"closure '{cid}' referenced by a regime but has no closure_bounds row")
|
|
375
|
+
for c in m.closure_bounds:
|
|
376
|
+
if c.closure_id not in corpus_index:
|
|
377
|
+
add("closure_bounds", f"closure '{c.closure_id}' not in calibration corpus")
|
|
378
|
+
if not c.bounds:
|
|
379
|
+
add("closure_bounds", f"closure '{c.closure_id}' has no bounds")
|
|
380
|
+
|
|
381
|
+
# variable_observability: coverage, vocab, per-variable consistency, 2c slot + honesty
|
|
382
|
+
vo_index = {v.variable: v for v in m.variable_observability}
|
|
383
|
+
if len(vo_index) != len(m.variable_observability):
|
|
384
|
+
add("variable_observability", "duplicate variable rows")
|
|
385
|
+
bound_vars = {b.variable for c in m.closure_bounds for b in c.bounds}
|
|
386
|
+
for var in sorted(bound_vars):
|
|
387
|
+
if var not in vo_index:
|
|
388
|
+
add("variable_observability", f"bound variable '{var}' has no observability_class row (Layer 2a gap)")
|
|
389
|
+
|
|
390
|
+
for v in m.variable_observability:
|
|
391
|
+
if v.variable not in bound_vars:
|
|
392
|
+
add("variable_observability", f"variable '{v.variable}' has a row but appears in no closure bound (orphan)")
|
|
393
|
+
if v.observability_class not in OBSERVABILITY_CLASSES:
|
|
394
|
+
add("variable_observability",
|
|
395
|
+
f"variable '{v.variable}': observability_class '{v.observability_class}' not in {sorted(OBSERVABILITY_CLASSES)}")
|
|
396
|
+
if v.degree_status not in DEGREE_STATUSES:
|
|
397
|
+
add("variable_observability",
|
|
398
|
+
f"variable '{v.variable}': degree_status '{v.degree_status}' not in {sorted(DEGREE_STATUSES)}")
|
|
399
|
+
|
|
400
|
+
# per-variable class consistency vs the authoritative source
|
|
401
|
+
for c in m.closure_bounds:
|
|
402
|
+
if any(b.variable == v.variable for b in c.bounds):
|
|
403
|
+
src = observability_class_for(c.closure_id, v.variable)
|
|
404
|
+
if src != v.observability_class:
|
|
405
|
+
add("variable_observability",
|
|
406
|
+
f"variable '{v.variable}': class {v.observability_class!r} disagrees with "
|
|
407
|
+
f"source ({c.closure_id} → {src!r}); a variable's class must be consistent "
|
|
408
|
+
f"across every closure it appears in")
|
|
409
|
+
|
|
410
|
+
# 2c slot rules + honesty gate
|
|
411
|
+
if v.observability_class == "structural-binary":
|
|
412
|
+
if v.partial_degree is not None or v.degree_status != "n/a":
|
|
413
|
+
add("variable_observability",
|
|
414
|
+
f"variable '{v.variable}': structural-binary must have partial_degree=null and degree_status='n/a'")
|
|
415
|
+
if v.calibrated_by:
|
|
416
|
+
add("variable_observability",
|
|
417
|
+
f"variable '{v.variable}': structural-binary must not carry calibrated_by")
|
|
418
|
+
elif v.observability_class == "known-partial":
|
|
419
|
+
if v.degree_status not in ("uncalibrated", "calibrated"):
|
|
420
|
+
add("variable_observability",
|
|
421
|
+
f"variable '{v.variable}': known-partial needs degree_status uncalibrated|calibrated, got {v.degree_status!r}")
|
|
422
|
+
if v.degree_status == "uncalibrated":
|
|
423
|
+
if v.partial_degree is not None or v.calibrated_by:
|
|
424
|
+
add("variable_observability",
|
|
425
|
+
f"variable '{v.variable}': uncalibrated must have partial_degree=null and empty "
|
|
426
|
+
f"calibrated_by (no faked degrees — defer is the honest behavior)")
|
|
427
|
+
elif v.degree_status == "calibrated":
|
|
428
|
+
if not v.partial_degree:
|
|
429
|
+
add("variable_observability",
|
|
430
|
+
f"variable '{v.variable}': calibrated requires a non-empty partial_degree")
|
|
431
|
+
if not v.calibrated_by:
|
|
432
|
+
add("variable_observability",
|
|
433
|
+
f"variable '{v.variable}': calibrated requires non-empty calibrated_by (provenance, no laundering)")
|
|
434
|
+
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
def _print_errors(errs: dict[str, list[str]]) -> int:
|
|
441
|
+
if not errs:
|
|
442
|
+
return 0
|
|
443
|
+
total = sum(len(v) for v in errs.values())
|
|
444
|
+
print(f"mapping validation FAILED — {total} errors across {len(errs)} tables:")
|
|
445
|
+
for table in sorted(errs):
|
|
446
|
+
print(f" [{table}]")
|
|
447
|
+
for e in errs[table]:
|
|
448
|
+
print(f" • {e}")
|
|
449
|
+
return 1
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def main(argv: list[str] | None = None) -> int:
|
|
453
|
+
parser = argparse.ArgumentParser(
|
|
454
|
+
description="PhysMAP Regime → Observability Mapping — generator + validator CLI",
|
|
455
|
+
)
|
|
456
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
457
|
+
|
|
458
|
+
p_build = sub.add_parser("build", help="(re)generate mapping.jsonl from the sources")
|
|
459
|
+
p_build.add_argument("--path", type=Path, default=DEFAULT_PATH,
|
|
460
|
+
help=f"output path (default: {DEFAULT_PATH})")
|
|
461
|
+
|
|
462
|
+
p_val = sub.add_parser("validate", help="run the consistency gates (+ drift check for the canonical artifact)")
|
|
463
|
+
p_val.add_argument("--path", type=Path, default=DEFAULT_PATH)
|
|
464
|
+
|
|
465
|
+
p_sum = sub.add_parser("summary", help="print a status summary of the mapping")
|
|
466
|
+
p_sum.add_argument("--path", type=Path, default=DEFAULT_PATH)
|
|
467
|
+
|
|
468
|
+
args = parser.parse_args(argv)
|
|
469
|
+
|
|
470
|
+
if args.cmd == "build":
|
|
471
|
+
m = build_mapping()
|
|
472
|
+
errs = validate_mapping(m)
|
|
473
|
+
if errs:
|
|
474
|
+
print("refusing to write — fresh build does not validate:", file=sys.stderr)
|
|
475
|
+
return _print_errors(errs)
|
|
476
|
+
args.path.parent.mkdir(parents=True, exist_ok=True)
|
|
477
|
+
args.path.write_text(to_jsonl(m), encoding="utf-8")
|
|
478
|
+
print(f"wrote {args.path}")
|
|
479
|
+
print(f" regimes: {len(m.regimes)} closures: {len(m.closure_bounds)} "
|
|
480
|
+
f"variables: {len(m.variable_observability)}")
|
|
481
|
+
print(f" fingerprint: {mapping_fingerprint(args.path)}")
|
|
482
|
+
return 0
|
|
483
|
+
|
|
484
|
+
if args.cmd == "validate":
|
|
485
|
+
try:
|
|
486
|
+
m = load_mapping(args.path)
|
|
487
|
+
except (FileNotFoundError, ValueError) as e:
|
|
488
|
+
print(f"FAILED to load mapping: {e}", file=sys.stderr)
|
|
489
|
+
return 2
|
|
490
|
+
errs = validate_mapping(m)
|
|
491
|
+
# Drift check for the canonical artifact: it must equal a fresh projection.
|
|
492
|
+
if Path(args.path).resolve() == DEFAULT_PATH.resolve() and m != build_mapping():
|
|
493
|
+
errs.setdefault("__drift__", []).append(
|
|
494
|
+
"committed mapping.jsonl != fresh build_mapping(); run `build` to regenerate")
|
|
495
|
+
if not errs:
|
|
496
|
+
print(f"mapping validation PASSED — {len(m.regimes)} regimes, "
|
|
497
|
+
f"{len(m.closure_bounds)} closures, {len(m.variable_observability)} variables "
|
|
498
|
+
f"({args.path})")
|
|
499
|
+
return 0
|
|
500
|
+
return _print_errors(errs)
|
|
501
|
+
|
|
502
|
+
if args.cmd == "summary":
|
|
503
|
+
m = load_mapping(args.path)
|
|
504
|
+
kp = [v for v in m.variable_observability if v.observability_class == "known-partial"]
|
|
505
|
+
calibrated = [v for v in kp if v.degree_status == "calibrated"]
|
|
506
|
+
print(f"mapping: {len(m.regimes)} regimes, {len(m.closure_bounds)} closures, "
|
|
507
|
+
f"{len(m.variable_observability)} variables ({args.path})")
|
|
508
|
+
print(f" known-partial variables: {len(kp)} "
|
|
509
|
+
f"(calibrated: {len(calibrated)}, uncalibrated: {len(kp) - len(calibrated)})")
|
|
510
|
+
for v in kp:
|
|
511
|
+
print(f" {v.variable:>18s} : {v.degree_status}"
|
|
512
|
+
+ (f" calibrated_by={v.calibrated_by}" if v.calibrated_by else ""))
|
|
513
|
+
return 0
|
|
514
|
+
|
|
515
|
+
return 0
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
if __name__ == "__main__":
|
|
519
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Deterministic explanation renderer — no LLM.
|
|
2
|
+
|
|
3
|
+
Each renderer composes a FIXED template over STRUCTURED facts: the closure + the
|
|
4
|
+
bound that fired (calibration corpus), the bound variable + its observability
|
|
5
|
+
class, the evidence-corpus provenance (source citation + measured divergence),
|
|
6
|
+
and the baseline state. Same inputs → same string (regression-testable); no model
|
|
7
|
+
call. Determinism: provenance claims are picked after sorting by claim_id, floats
|
|
8
|
+
use a fixed precision, and no timestamps appear.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Sequence
|
|
14
|
+
|
|
15
|
+
from physmap.corpus.evidence import Claim, Source
|
|
16
|
+
from physmap.guardrail.enums import Observability
|
|
17
|
+
from physmap.pipeline.validity_signal import PerBoundMargin
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_OBS_PHRASE = {
|
|
21
|
+
Observability.UNOBSERVABLE: "is not a surrogate input (unobservable to input-based OOD detectors)",
|
|
22
|
+
Observability.PARTIAL: "is not a surrogate input but is correlated with one (only partially observable)",
|
|
23
|
+
Observability.OBSERVABLE: "is a surrogate input (observable to input-based OOD detectors)",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fmt_interval(lo: float | None, hi: float | None) -> str:
|
|
28
|
+
"""Human-friendly bound text. A huge upper bound reads as a one-sided ≥."""
|
|
29
|
+
if lo is not None and (hi is None or hi >= 1e8):
|
|
30
|
+
return f"≥ {lo:.3g}"
|
|
31
|
+
if hi is not None and (lo is None or lo <= 0.0):
|
|
32
|
+
return f"≤ {hi:.3g}"
|
|
33
|
+
if lo is not None and hi is not None:
|
|
34
|
+
return f"[{lo:.3g}, {hi:.3g}]"
|
|
35
|
+
return "(unbounded)"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pick_provenance(claims_for_closure: Sequence[Claim], variable: str) -> Claim | None:
|
|
39
|
+
"""Deterministically choose the provenance claim FOR THE FIRED VARIABLE.
|
|
40
|
+
|
|
41
|
+
Only ever returns a claim about `variable` — never borrows a divergence
|
|
42
|
+
measured on a different axis (that would be provenance laundering: a Re
|
|
43
|
+
divergence is not evidence about an x/D bound). Prefer a variable-matching
|
|
44
|
+
claim carrying a measured_divergence; else a variable-matching originating
|
|
45
|
+
claim (source citation only, no magnitude). Sorted by claim_id for stability.
|
|
46
|
+
"""
|
|
47
|
+
var_claims = sorted(
|
|
48
|
+
(c for c in claims_for_closure if c.bound and c.bound.variable == variable),
|
|
49
|
+
key=lambda c: c.claim_id,
|
|
50
|
+
)
|
|
51
|
+
for c in var_claims:
|
|
52
|
+
if c.measured_divergence is not None:
|
|
53
|
+
return c
|
|
54
|
+
return var_claims[0] if var_claims else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _provenance_phrase(
|
|
58
|
+
claim: Claim | None, sources_index: dict[str, Source],
|
|
59
|
+
) -> str:
|
|
60
|
+
if claim is None:
|
|
61
|
+
return "no evidence-corpus claim is recorded for this bound"
|
|
62
|
+
src = sources_index.get(claim.source_id)
|
|
63
|
+
cite = src.citation if src is not None else claim.source_id
|
|
64
|
+
md = claim.measured_divergence
|
|
65
|
+
if md is not None and md.magnitude_pct is not None:
|
|
66
|
+
return (f"the literature ({cite}) reports divergence up to "
|
|
67
|
+
f"{md.magnitude_pct:.3g}% past this bound")
|
|
68
|
+
return f"the literature ({cite}) documents this bound"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_unobservable(
|
|
72
|
+
*,
|
|
73
|
+
closure_id: str,
|
|
74
|
+
fired_bound: PerBoundMargin,
|
|
75
|
+
bound_interval: tuple[float | None, float | None],
|
|
76
|
+
baseline_fired: bool,
|
|
77
|
+
claims_for_closure: Sequence[Claim],
|
|
78
|
+
sources_index: dict[str, Source],
|
|
79
|
+
) -> str:
|
|
80
|
+
"""THE product-value explanation: the corpus fired on a bound the baselines
|
|
81
|
+
are structurally blind to, so their silence is expected, not reassuring."""
|
|
82
|
+
var = fired_bound.coord
|
|
83
|
+
obs_phrase = _OBS_PHRASE[Observability.UNOBSERVABLE]
|
|
84
|
+
prov = _provenance_phrase(_pick_provenance(claims_for_closure, var), sources_index)
|
|
85
|
+
bound_txt = _fmt_interval(*bound_interval)
|
|
86
|
+
baseline_txt = (
|
|
87
|
+
"An input-based OOD detector also fired."
|
|
88
|
+
if baseline_fired
|
|
89
|
+
else f"Input-based OOD detectors are silent because they cannot observe {var}."
|
|
90
|
+
)
|
|
91
|
+
return (
|
|
92
|
+
f"Prediction relies on {closure_id} beyond its validated {var} bound "
|
|
93
|
+
f"({var} {bound_txt}); {var} {obs_phrase}, and {prov}. {baseline_txt}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_partial(
|
|
98
|
+
*,
|
|
99
|
+
closure_id: str,
|
|
100
|
+
fired_bound: PerBoundMargin,
|
|
101
|
+
bound_interval: tuple[float | None, float | None],
|
|
102
|
+
) -> str:
|
|
103
|
+
"""PARTIAL deferral — honest interim until the middle-vehicle calibration
|
|
104
|
+
table exists. We do not over-fire; we flag for review."""
|
|
105
|
+
var = fired_bound.coord
|
|
106
|
+
bound_txt = _fmt_interval(*bound_interval)
|
|
107
|
+
return (
|
|
108
|
+
f"Prediction relies on {closure_id} beyond its validated {var} bound "
|
|
109
|
+
f"({var} {bound_txt}); {var} {_OBS_PHRASE[Observability.PARTIAL]}. Set "
|
|
110
|
+
f"membership cannot yet weight this axis against the input-based OOD detectors, so the "
|
|
111
|
+
f"verdict is deferred for review rather than over-fired."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def render_partial_graded(
|
|
116
|
+
*,
|
|
117
|
+
closure_id: str,
|
|
118
|
+
fired_bound: PerBoundMargin,
|
|
119
|
+
bound_interval: tuple[float | None, float | None],
|
|
120
|
+
degree: float,
|
|
121
|
+
calibrated_by: Sequence[str],
|
|
122
|
+
soft_flag: bool,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Graded PARTIAL — a real vehicle has calibrated this (regime, variable)'s Layer-2c
|
|
125
|
+
observability degree, so we weight the corpus fire by (1 - degree) instead of
|
|
126
|
+
deferring. soft_flag → calibrated mid-axis WARN; else → corpus-trust near the
|
|
127
|
+
unobservable pole. The weight traces to `calibrated_by` (provenance, not runtime)."""
|
|
128
|
+
var = fired_bound.coord
|
|
129
|
+
bound_txt = _fmt_interval(*bound_interval)
|
|
130
|
+
by = ", ".join(calibrated_by) or "(unknown)"
|
|
131
|
+
if soft_flag:
|
|
132
|
+
tail = (
|
|
133
|
+
f"its calibrated observability (degree={degree:g}, from {by}) places it "
|
|
134
|
+
f"mid-axis, so the corpus fire is a calibrated soft-flag (corpus weight "
|
|
135
|
+
f"{1.0 - degree:g}) raised for review, scaled against the input-based OOD detectors rather "
|
|
136
|
+
f"than over-fired."
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
tail = (
|
|
140
|
+
f"its calibrated observability (degree={degree:g}, from {by}) places it near "
|
|
141
|
+
f"the unobservable pole, so the corpus fire is trusted — the input-based OOD detectors are "
|
|
142
|
+
f"largely blind on this axis here."
|
|
143
|
+
)
|
|
144
|
+
return (
|
|
145
|
+
f"Prediction relies on {closure_id} beyond its validated {var} bound "
|
|
146
|
+
f"({var} {bound_txt}); {var} {_OBS_PHRASE[Observability.PARTIAL]}. {tail}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render_baseline(*, baseline_fired_names: Sequence[str], all_quiet: bool) -> str:
|
|
151
|
+
"""OBSERVABLE / corpus-quiet narrative: the verdict defers to the statistical
|
|
152
|
+
baselines (the corpus is redundant on observable axes — never double-counted)."""
|
|
153
|
+
if all_quiet:
|
|
154
|
+
return ("All input-based OOD detectors are quiet and no physics bound is "
|
|
155
|
+
"violated on an unobservable axis; prediction is trustworthy.")
|
|
156
|
+
fired = ", ".join(sorted(baseline_fired_names)) or "(none)"
|
|
157
|
+
return (f"Input-based OOD detector(s) fired ({fired}); the physics signal is "
|
|
158
|
+
f"redundant here (observable axis) and is deferred to those detectors, "
|
|
159
|
+
f"not double-counted.")
|