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,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.")