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