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,393 @@
|
|
|
1
|
+
"""Open closure INDEX — public coverage, no verdicts.
|
|
2
|
+
|
|
3
|
+
The closure *index* is the open-core "coverage is public, bounds are paid" map:
|
|
4
|
+
metadata for every closure PhysMAP knows about, carrying NO ranges, NO provenance,
|
|
5
|
+
NO contested/confirmation/disposition content of any kind. It ships in the core
|
|
6
|
+
wheel so a seed-only install can resolve a regime or a free-text closure name to a
|
|
7
|
+
`closure_id`, report what is covered, and tell the three "no bounds here" cases
|
|
8
|
+
apart — without shipping the moat.
|
|
9
|
+
|
|
10
|
+
This is DISTINCT from `physmap.closures.registry.REGISTRY`, the executable formula
|
|
11
|
+
bridge (carries `fn` + cached `re_range`/`ra_range` that the substrate engine reads
|
|
12
|
+
at runtime). The registry stays internal; the index is the public surface.
|
|
13
|
+
|
|
14
|
+
Data lives in the bundled `physmap/closures/data/closure_index.json` (generated by
|
|
15
|
+
`build_index_json()` from the canonical corpus + registry geometry + the alias map;
|
|
16
|
+
regenerate with `python -m physmap.closures.index --rebuild`). The runtime only
|
|
17
|
+
READS the JSON via importlib.resources — it never needs the premium corpus.
|
|
18
|
+
|
|
19
|
+
Resolution semantics (consumed by the guardrail) distinguish, for a requested
|
|
20
|
+
closure_id:
|
|
21
|
+
* in the active corpus → case 1 (bounds available now)
|
|
22
|
+
* in premium_coverage, not active → case 2a (bounds in premium, not the seed)
|
|
23
|
+
* in the index, no bounds anywhere → case 2b (registered, not yet curated)
|
|
24
|
+
* not in the index → case 3 (nothing registered)
|
|
25
|
+
Cases 2b/3 embed a pre-filled GitHub issue URL — inert text, no telemetry.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import importlib.resources as _ir
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from enum import Enum
|
|
35
|
+
from urllib.parse import quote
|
|
36
|
+
|
|
37
|
+
_INDEX_PACKAGE = "physmap.closures.data"
|
|
38
|
+
_INDEX_FILENAME = "closure_index.json"
|
|
39
|
+
_COVERAGE_PACKAGE = "physmap.corpus.data"
|
|
40
|
+
_COVERAGE_FILENAME = "premium_coverage.json"
|
|
41
|
+
|
|
42
|
+
# Repo that receives closure-request issues (cases 2b/3). The URL is inert text in
|
|
43
|
+
# an error message; the user clicking it is the entire (zero-telemetry) signal.
|
|
44
|
+
ISSUE_REPO = "cloudronin/physmap"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ClosureMeta:
|
|
49
|
+
"""Public, verdict-free metadata for one known closure."""
|
|
50
|
+
closure_id: str
|
|
51
|
+
closure_name: str
|
|
52
|
+
closure_family: str
|
|
53
|
+
geometry_class: str
|
|
54
|
+
physics_coordinates: tuple[str, ...]
|
|
55
|
+
citation: str
|
|
56
|
+
aliases: tuple[str, ...] = field(default_factory=tuple)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── load the bundled index (runtime; no premium corpus required) ──────────────
|
|
60
|
+
def _load_index_json() -> dict:
|
|
61
|
+
# Tolerant of a missing file so the module imports during its own --rebuild
|
|
62
|
+
# bootstrap; at runtime the JSON is always present as bundled package data.
|
|
63
|
+
try:
|
|
64
|
+
return json.loads((_ir.files(_INDEX_PACKAGE) / _INDEX_FILENAME).read_text("utf-8"))
|
|
65
|
+
except (FileNotFoundError, ModuleNotFoundError):
|
|
66
|
+
return {"closures": []}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_runtime_index() -> dict[str, ClosureMeta]:
|
|
70
|
+
out: dict[str, ClosureMeta] = {}
|
|
71
|
+
for d in _load_index_json().get("closures", []):
|
|
72
|
+
out[d["closure_id"]] = ClosureMeta(
|
|
73
|
+
closure_id=d["closure_id"],
|
|
74
|
+
closure_name=d.get("closure_name", ""),
|
|
75
|
+
closure_family=d.get("closure_family", ""),
|
|
76
|
+
geometry_class=d.get("geometry_class", ""),
|
|
77
|
+
physics_coordinates=tuple(d.get("physics_coordinates", [])),
|
|
78
|
+
citation=d.get("citation", ""),
|
|
79
|
+
aliases=tuple(d.get("aliases", [])),
|
|
80
|
+
)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
CLOSURE_INDEX: dict[str, ClosureMeta] = _build_runtime_index()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
#: Size of the committed index. The packaging test asserts this, because an index
|
|
88
|
+
#: that fails to load does not raise -- it comes back empty, and an empty index
|
|
89
|
+
#: reports every closure as unregistered while every import still succeeds.
|
|
90
|
+
EXPECTED_INDEX_SIZE = 201
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def verify_index_loaded() -> int:
|
|
94
|
+
"""Raise unless the bundled closure index actually loaded. Returns its size."""
|
|
95
|
+
n = len(CLOSURE_INDEX)
|
|
96
|
+
if n == 0:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
"the bundled closure index loaded as EMPTY. This almost always means "
|
|
99
|
+
"package data did not ship: check that pyproject.toml still sets "
|
|
100
|
+
"`where = [\"src\"]` under [tool.setuptools.packages.find] and that "
|
|
101
|
+
"closures/data/*.json is listed in [tool.setuptools.package-data]."
|
|
102
|
+
)
|
|
103
|
+
return n
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def premium_coverage_ids() -> set[str]:
|
|
107
|
+
"""closure_ids that have curated bounds in the PREMIUM corpus (ids only — the
|
|
108
|
+
bundled coverage map carries no bound values)."""
|
|
109
|
+
data = json.loads((_ir.files(_COVERAGE_PACKAGE) / _COVERAGE_FILENAME).read_text("utf-8"))
|
|
110
|
+
return set(data.get("closure_ids", []))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── free-text closure matching (direct naming = the high-fidelity demand signal)
|
|
114
|
+
def _norm(s: str) -> str:
|
|
115
|
+
return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def match_closure(text: str) -> str | None:
|
|
119
|
+
"""Resolve a free-text closure name to a closure_id via id/name/alias match.
|
|
120
|
+
|
|
121
|
+
Exact (normalized) match on closure_id, closure_name, or any alias wins; failing
|
|
122
|
+
that, a unique substring match against an alias/name is accepted. Ambiguous or
|
|
123
|
+
no match → None (the caller raises case 3)."""
|
|
124
|
+
if not text:
|
|
125
|
+
return None
|
|
126
|
+
q = _norm(text)
|
|
127
|
+
# 1. exact id
|
|
128
|
+
for cid, meta in CLOSURE_INDEX.items():
|
|
129
|
+
if q == _norm(cid):
|
|
130
|
+
return cid
|
|
131
|
+
# 2. exact name or alias
|
|
132
|
+
for cid, meta in CLOSURE_INDEX.items():
|
|
133
|
+
if q == _norm(meta.closure_name) or any(q == _norm(a) for a in meta.aliases):
|
|
134
|
+
return cid
|
|
135
|
+
# 3. unique substring of an alias/name (e.g. "sst" → menter-sst-1994)
|
|
136
|
+
hits = {cid for cid, meta in CLOSURE_INDEX.items()
|
|
137
|
+
if any(q in _norm(a) or _norm(a) in q for a in (meta.aliases + (meta.closure_name,)))}
|
|
138
|
+
return hits.pop() if len(hits) == 1 else None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ClosureResolutionError(ValueError):
|
|
142
|
+
"""Raised by CredibilityGuardrail.fit when a requested closure resolves but its
|
|
143
|
+
validated bounds are not available in the active corpus (cases 2a/2b/3).
|
|
144
|
+
|
|
145
|
+
Subclasses ValueError so existing `except ValueError` paths still catch it,
|
|
146
|
+
while tests can target the resolution case specifically."""
|
|
147
|
+
def __init__(self, message: str, *, case: "ResolutionCase | None" = None):
|
|
148
|
+
super().__init__(message)
|
|
149
|
+
self.case = case
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ResolutionCase(str, Enum):
|
|
153
|
+
"""How a requested closure resolves against the active corpus / coverage / index."""
|
|
154
|
+
ACTIVE = "1" # bounds present in the ACTIVE corpus → normal operation
|
|
155
|
+
PREMIUM_ONLY = "2a" # bounds in premium coverage but not the active (seed) corpus
|
|
156
|
+
NO_BOUNDS = "2b" # registered in the index, but no bounds curated in any tier
|
|
157
|
+
UNREGISTERED = "3" # not in the index at all
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def classify_closure(closure_id: str, active_ids: set[str]) -> ResolutionCase:
|
|
161
|
+
"""Classify a resolved closure_id into the resolution-semantics case."""
|
|
162
|
+
if closure_id in active_ids:
|
|
163
|
+
return ResolutionCase.ACTIVE
|
|
164
|
+
if closure_id in premium_coverage_ids():
|
|
165
|
+
return ResolutionCase.PREMIUM_ONLY
|
|
166
|
+
if closure_id in CLOSURE_INDEX:
|
|
167
|
+
return ResolutionCase.NO_BOUNDS
|
|
168
|
+
return ResolutionCase.UNREGISTERED
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolution_message(name: str, case: ResolutionCase, *, facets: dict | None = None) -> str:
|
|
172
|
+
"""The factual, one-line error text for a non-active case (no upsell prose).
|
|
173
|
+
|
|
174
|
+
`name` is the closure_id (2a/2b) or the regime/free-text the user asked for (3).
|
|
175
|
+
Cases 2b and 3 embed a pre-filled GitHub issue URL — inert text, no telemetry."""
|
|
176
|
+
if case is ResolutionCase.PREMIUM_ONLY:
|
|
177
|
+
return (f"closure {name} resolved for this regime; validated bounds are not in "
|
|
178
|
+
f"the seed corpus (available in the premium corpus).")
|
|
179
|
+
if case is ResolutionCase.NO_BOUNDS:
|
|
180
|
+
return (f"closure {name} is registered; validated bounds are not yet curated. "
|
|
181
|
+
f"To prioritize, file an issue: {issue_url(name, facets)}")
|
|
182
|
+
if case is ResolutionCase.UNREGISTERED:
|
|
183
|
+
return (f"no closure registered for {name}. If this closure should be covered, "
|
|
184
|
+
f"file an issue: {issue_url(name, facets)}")
|
|
185
|
+
return "" # ACTIVE has no error
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def issue_url(name: str, facets: dict | None = None) -> str:
|
|
189
|
+
"""Pre-filled GitHub closure-request issue URL (inert text; no network)."""
|
|
190
|
+
title = quote(f"closure-request: {name}")
|
|
191
|
+
body_lines = [f"Closure requested: {name}", ""]
|
|
192
|
+
if facets:
|
|
193
|
+
body_lines.append("Regime facets:")
|
|
194
|
+
body_lines += [f"- {k}: {v}" for k, v in facets.items()]
|
|
195
|
+
body = quote("\n".join(body_lines))
|
|
196
|
+
return f"https://github.com/{ISSUE_REPO}/issues/new?title={title}&body={body}"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── dev-only: regenerate the bundled index from corpus + registry ─────────────
|
|
200
|
+
# Common alternate names for free-text matching. Keyed by closure_id; merged with
|
|
201
|
+
# auto-derived tokens at build time. Author-extend as the index grows.
|
|
202
|
+
ALIASES: dict[str, tuple[str, ...]] = {
|
|
203
|
+
"gnielinski-1976": ("Gnielinski", "Gnielinski 1976", "Gnielinski correlation"),
|
|
204
|
+
"dittus-boelter-1930": ("Dittus-Boelter", "Dittus Boelter", "DB correlation"),
|
|
205
|
+
"sieder-tate-1936": ("Sieder-Tate", "Sieder Tate"),
|
|
206
|
+
"petukhov-1970": ("Petukhov", "Petukhov-Kirillov"),
|
|
207
|
+
"blasius-pohlhausen-flat-plate-forced-1921": ("Pohlhausen", "Blasius-Pohlhausen", "flat plate laminar"),
|
|
208
|
+
"mcadams-vertical-plate-natural-1954": ("McAdams", "McAdams vertical plate"),
|
|
209
|
+
"modified-sparrow-cur-asym-narrow-rect-channel-2014": ("Forrest", "Sparrow-Cur", "modified Sparrow-Cur"),
|
|
210
|
+
"menter-sst-1994": ("SST", "Menter SST", "k-omega SST", "SST k-omega"),
|
|
211
|
+
"wilcox-k-omega-2006": ("k-omega", "Wilcox k-omega", "komega"),
|
|
212
|
+
"launder-spalding-k-epsilon-1974": ("k-epsilon", "standard k-epsilon", "Launder-Spalding"),
|
|
213
|
+
"spalart-allmaras-1992": ("Spalart-Allmaras", "SA model", "S-A"),
|
|
214
|
+
"aung-worku-mixed-convection-1986": ("Aung-Worku", "mixed convection tube"),
|
|
215
|
+
"rohsenow-pool-boiling-1952": ("Rohsenow", "Rohsenow pool boiling"),
|
|
216
|
+
"zuber-chf-1959": ("Zuber", "Zuber CHF", "critical heat flux"),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# Approved closure-index expansion (docs/PhysMAP_Registry_Expansion_Candidates_v0_1.md).
|
|
221
|
+
# The markdown table is the human-readable source of truth; build_index_json() parses
|
|
222
|
+
# it at rebuild time and merges the candidates (metadata-only, case-2b) with the
|
|
223
|
+
# corpus-derived entries. Family is taken from the section the row sits under.
|
|
224
|
+
from pathlib import Path as _Path
|
|
225
|
+
|
|
226
|
+
from physmap._paths import checkout_path as _checkout_path
|
|
227
|
+
|
|
228
|
+
_CANDIDATES_MD_PARTS = ("docs", "PhysMAP_Registry_Expansion_Candidates_v0_1.md")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _candidates_md() -> _Path:
|
|
232
|
+
"""The approved candidate table. Checkout-only; raises if absent.
|
|
233
|
+
|
|
234
|
+
Resolved lazily and loudly on purpose. This file supplies 147 of the 201
|
|
235
|
+
indexed closures. An earlier version skipped it when missing, which turned a
|
|
236
|
+
broken path into a quietly three-quarters-empty index that still imported,
|
|
237
|
+
still ran, and answered "unregistered" for closures that are registered.
|
|
238
|
+
"""
|
|
239
|
+
return _checkout_path(*_CANDIDATES_MD_PARTS, what="the approved closure-index candidate table")
|
|
240
|
+
|
|
241
|
+
# Section number/letter -> closure_family. Sections not listed (Summary, Decisions,
|
|
242
|
+
# §15 coords, §16 excluded, the §7 parent) carry no candidates and map to None.
|
|
243
|
+
_SECTION_FAMILY: dict[str, str] = {
|
|
244
|
+
"1": "condensation", "2": "external-convection", "3": "natural-convection",
|
|
245
|
+
"4": "rans-turbulence", "5": "interfacial-force", "6": "radiation",
|
|
246
|
+
"7a": "single-phase-convection", "7b": "hydraulic-friction",
|
|
247
|
+
"7c": "transfer-analogy", "7d": "surface-enhancement", "7e": "packed-porous",
|
|
248
|
+
"8": "cardio", "9": "aero", "10": "combustion-kinetics",
|
|
249
|
+
"11": "species-diffusion", "12": "non-newtonian-rheology", "13": "rarefied-gas",
|
|
250
|
+
}
|
|
251
|
+
# Per-entry family overrides where a section is heterogeneous (§4 wall treatments
|
|
252
|
+
# belong to the corpus `wall-function` family, not the RANS default).
|
|
253
|
+
_FAMILY_OVERRIDE: dict[str, str] = {
|
|
254
|
+
"spalding-law-of-the-wall-1961": "wall-function",
|
|
255
|
+
"van-driest-damping-1956": "wall-function",
|
|
256
|
+
"kader-temperature-wall-1981": "wall-function",
|
|
257
|
+
"jayatilleke-p-function-1969": "wall-function",
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_SECTION_RE = re.compile(r"^#{2,3}\s+(\d+[a-e]?)\.")
|
|
261
|
+
_SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]+$")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def parse_candidate_rows(md_text: str) -> list[dict]:
|
|
265
|
+
"""Parse the approved candidate table into metadata-only index entries.
|
|
266
|
+
|
|
267
|
+
Tracks the current family section; emits one entry per 5-column candidate row
|
|
268
|
+
(id | name | coordinates | citation | aliases). Non-family tables (Summary,
|
|
269
|
+
the §14 corrections table) sit under family=None and are skipped."""
|
|
270
|
+
out: list[dict] = []
|
|
271
|
+
family: str | None = None
|
|
272
|
+
for line in md_text.splitlines():
|
|
273
|
+
m = _SECTION_RE.match(line)
|
|
274
|
+
if m:
|
|
275
|
+
family = _SECTION_FAMILY.get(m.group(1))
|
|
276
|
+
continue
|
|
277
|
+
if not family or not line.lstrip().startswith("|") or line.count("|") < 6:
|
|
278
|
+
continue
|
|
279
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
280
|
+
if len(cells) < 5 or not _SLUG_RE.fullmatch(cells[0]):
|
|
281
|
+
continue # skips the "closure_id" header (underscore) and "---" separators
|
|
282
|
+
cid, name, coords, citation, aliases = cells[:5]
|
|
283
|
+
out.append({
|
|
284
|
+
"closure_id": cid,
|
|
285
|
+
"closure_name": name,
|
|
286
|
+
"closure_family": _FAMILY_OVERRIDE.get(cid, family),
|
|
287
|
+
"geometry_class": "",
|
|
288
|
+
"physics_coordinates": [c.strip() for c in coords.split(",") if c.strip()],
|
|
289
|
+
"citation": citation,
|
|
290
|
+
"aliases": sorted({a.strip() for a in aliases.split(",") if a.strip()}),
|
|
291
|
+
})
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def build_index_json() -> dict:
|
|
296
|
+
"""Build the index payload from the CANONICAL corpus + registry + the approved
|
|
297
|
+
expansion candidates (dev-only; needs the premium tree). Metadata only —
|
|
298
|
+
names/families/coords/citations/aliases. No bounds, no provenance, no disposition."""
|
|
299
|
+
from physmap.corpus import calibration as cc
|
|
300
|
+
from physmap.closures.registry import REGISTRY
|
|
301
|
+
|
|
302
|
+
entries = cc.load_corpus(cc.DEFAULT_PATH)
|
|
303
|
+
by_geom = {cid: e.geometry_class for cid, e in REGISTRY.items()}
|
|
304
|
+
|
|
305
|
+
def primary_citation(entry) -> str:
|
|
306
|
+
for b in entry.validated_range:
|
|
307
|
+
c = (b.provenance or {}).get("citation")
|
|
308
|
+
if c:
|
|
309
|
+
# first sentence/clause — concise, still public bibliographic
|
|
310
|
+
return c.split(". Restated")[0].split(". Modified")[0].strip()
|
|
311
|
+
return ""
|
|
312
|
+
|
|
313
|
+
closures = []
|
|
314
|
+
seen = set()
|
|
315
|
+
for e in entries:
|
|
316
|
+
seen.add(e.closure_id)
|
|
317
|
+
auto = tuple(w for w in _norm(e.closure_id).split() if len(w) > 2)
|
|
318
|
+
closures.append({
|
|
319
|
+
"closure_id": e.closure_id,
|
|
320
|
+
"closure_name": e.closure_name,
|
|
321
|
+
"closure_family": e.closure_family,
|
|
322
|
+
"geometry_class": by_geom.get(e.closure_id, ""),
|
|
323
|
+
"physics_coordinates": list(e.physics_coordinates),
|
|
324
|
+
"citation": primary_citation(e),
|
|
325
|
+
"aliases": sorted(set(ALIASES.get(e.closure_id, ())) | set(auto)),
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
# Registry closures with NO corpus entry (e.g. churchill) — case-2b by
|
|
329
|
+
# construction; include so they are nameable/coverable.
|
|
330
|
+
for cid, reg in REGISTRY.items():
|
|
331
|
+
if cid in seen:
|
|
332
|
+
continue
|
|
333
|
+
name = cid.replace("-", " ").title()
|
|
334
|
+
closures.append({
|
|
335
|
+
"closure_id": cid,
|
|
336
|
+
"closure_name": name,
|
|
337
|
+
"closure_family": "",
|
|
338
|
+
"geometry_class": reg.geometry_class,
|
|
339
|
+
"physics_coordinates": list(reg.required_inputs),
|
|
340
|
+
"citation": (reg.note or "").split(".")[0],
|
|
341
|
+
"aliases": sorted(set(ALIASES.get(cid, ()))),
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
# Approved expansion candidates (metadata-only, case-2b by construction).
|
|
345
|
+
n_corpus = len(closures)
|
|
346
|
+
if True:
|
|
347
|
+
for cand in parse_candidate_rows(_candidates_md().read_text(encoding="utf-8")):
|
|
348
|
+
if cand["closure_id"] in seen:
|
|
349
|
+
continue
|
|
350
|
+
seen.add(cand["closure_id"])
|
|
351
|
+
closures.append(cand)
|
|
352
|
+
n_candidates = len(closures) - n_corpus
|
|
353
|
+
|
|
354
|
+
closures.sort(key=lambda d: d["closure_id"])
|
|
355
|
+
return {
|
|
356
|
+
"_comment": "Open closure INDEX — public coverage metadata ONLY (id, name, "
|
|
357
|
+
"family, geometry, coordinates, citation, aliases). Verdict-free: "
|
|
358
|
+
"carries no bounds and no curation status. Generated by "
|
|
359
|
+
"physmap.closures.index.build_index_json() from the corpus + registry "
|
|
360
|
+
f"+ the approved expansion table ({n_corpus} curated + {n_candidates} "
|
|
361
|
+
"case-2b candidates).",
|
|
362
|
+
"version": cc_version(),
|
|
363
|
+
"n_closures": len(closures),
|
|
364
|
+
"closures": closures,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def cc_version() -> str:
|
|
369
|
+
try:
|
|
370
|
+
from physmap.guardrail.corpus_regimes import CALIBRATION_CORPUS_VERSION
|
|
371
|
+
return CALIBRATION_CORPUS_VERSION
|
|
372
|
+
except Exception:
|
|
373
|
+
return "unknown"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _rebuild_to_disk() -> None:
|
|
377
|
+
from pathlib import Path
|
|
378
|
+
payload = build_index_json()
|
|
379
|
+
out = Path(__file__).resolve().parent / "data" / _INDEX_FILENAME
|
|
380
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
out.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
|
382
|
+
print(f"wrote {out} ({payload['n_closures']} closures)")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if __name__ == "__main__":
|
|
386
|
+
import argparse
|
|
387
|
+
ap = argparse.ArgumentParser(description="closure index — rebuild the bundled JSON")
|
|
388
|
+
ap.add_argument("--rebuild", action="store_true", help="regenerate closure_index.json from the canonical corpus")
|
|
389
|
+
if ap.parse_args().rebuild:
|
|
390
|
+
_rebuild_to_disk()
|
|
391
|
+
else:
|
|
392
|
+
print(f"{len(CLOSURE_INDEX)} closures in the bundled index "
|
|
393
|
+
f"(run with --rebuild to regenerate from the corpus)")
|