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,871 @@
1
+ """PhysMAP Evidence Corpus — schema, validators, loader, query CLI.
2
+
3
+ The justification-side companion to the calibration corpus. Calibration → decision
4
+ signals (does a bound fire). Evidence → justification signals (which source, what
5
+ measured divergence, what conditions — the auditable "why").
6
+
7
+ Claim-centric, three flat tables, atomic provenance:
8
+ sources.jsonl one row per paper/handbook/data sheet
9
+ claims.jsonl one row per (source asserts something about a closure's validity)
10
+ (closures) joined from calibration_corpus.corpus.jsonl via closure_id
11
+
12
+ A `claims` row IS a v0.6 `CredibilityFactor` (claim_type→factorType, status→
13
+ factorStatus, source's standard→factorStandard) with PROV-DM `wasDerivedFrom` to
14
+ the Source. Same vocabulary as the assessment; the corpus is a reusable library
15
+ of factors-about-closures-in-general.
16
+
17
+ Spec: PhysMAP_Evidence_Corpus_Build_Spec_v0_1.md
18
+ Storage: two JSONL files, line-diffable.
19
+ Mirror of `calibration_corpus.py`'s structural patterns (dataclasses, validators
20
+ named gates, CLI subcommands).
21
+
22
+ Provenance discipline (gate 9): a measured_divergence row MUST cite the primary
23
+ source directly. Lifting a value from a calibration-corpus provenance note is
24
+ provenance laundering and is disallowed. The valid extraction_method values are
25
+ 'primary-source' and 'visual-estimate-rendered-pdf' only.
26
+
27
+ Visual-estimate divergence rows (gate 10) MUST also populate
28
+ magnitude_uncertainty_pct, n_points, and resolvability_ratio so the coarser tier
29
+ is self-documenting downstream.
30
+
31
+ Torch-free; depends only on the standard library and the calibration corpus
32
+ module (for the closure_id join validator).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import sys
40
+ from dataclasses import asdict, dataclass, field
41
+ from pathlib import Path
42
+
43
+ # Lazy import inside the join validator to avoid hard coupling at module load.
44
+
45
+ # ── controlled vocabularies ───────────────────────────────────────────────────
46
+
47
+ SOURCE_TYPES = {
48
+ "originating-paper",
49
+ "validation-study",
50
+ "correction-paper",
51
+ "handbook",
52
+ }
53
+
54
+ SOURCE_STATUSES = {"populated", "stub"}
55
+
56
+ CLAIM_TYPES = {
57
+ "originating-bound",
58
+ "validated-bound",
59
+ "correction",
60
+ "consensus-bound",
61
+ }
62
+
63
+ ROLES = {"originate", "validate", "correct", "synthesize"}
64
+
65
+ # Aligned with calibration_corpus.BOUND_STATUSES plus spec's "contested".
66
+ CLAIM_STATUSES = {
67
+ "confirmed",
68
+ "confirmed-contested",
69
+ "claimed",
70
+ "extrapolated",
71
+ "contested",
72
+ }
73
+
74
+ MEASURES = {"MAE", "median_gap", "resolvability_ratio", "mean_relative_pct"}
75
+
76
+ # Provenance discipline: there is NO 'calibration-provenance-lift' path.
77
+ # A divergence row must trace to its primary source or to a visual digitization
78
+ # of the rendered PDF. Anything else is laundered and fails gate 9.
79
+ EXTRACTION_METHODS = {"primary-source", "visual-estimate-rendered-pdf"}
80
+
81
+ # v0.6 vocab projection (claim_type → CredibilityFactor.factorType).
82
+ _V06_FACTOR_TYPE_BY_CLAIM_TYPE = {
83
+ "originating-bound": "OriginatingBound",
84
+ "validated-bound": "ValidatedBound",
85
+ "correction": "Correction",
86
+ "consensus-bound": "ConsensusBound",
87
+ }
88
+
89
+ # ── open-core evidence resolution (env -> premium -> bundled seed) ────────────
90
+ #
91
+ # The evidence corpus carries the SAME firewall as the calibration corpus. The
92
+ # published files are the seed: a claim ships only if its closure is one of the
93
+ # 15 seed closures, and a source ships only if a surviving claim cites it. The
94
+ # firewall is an allowlist; see dev/tools/split_evidence_corpus.py, which derives
95
+ # these files deterministically and has a --check mode that fails on drift.
96
+ #
97
+ # Resolution order mirrors resolve_corpus_path() in calibration.py so both halves
98
+ # of the corpus move together:
99
+ # 1. $PHYSMAP_EVIDENCE_DIR -- explicit override
100
+ # 2. the optional physmap_corpus_premium package
101
+ # 3. the bundled seed -- always present
102
+ import importlib.resources as _ir_e
103
+ import importlib.util as _iu_e
104
+ import os as _os_e
105
+
106
+ PHYSMAP_EVIDENCE_ENV = "PHYSMAP_EVIDENCE_DIR"
107
+ _EVIDENCE_SEED_PACKAGE = "physmap.corpus.data"
108
+ _PREMIUM_PACKAGE_E = "physmap_corpus_premium"
109
+
110
+
111
+ def _evidence_path(premium_name: str, seed_name: str) -> Path:
112
+ env = _os_e.environ.get(PHYSMAP_EVIDENCE_ENV)
113
+ if env:
114
+ return Path(env).expanduser() / premium_name
115
+ if _iu_e.find_spec(_PREMIUM_PACKAGE_E) is not None:
116
+ try:
117
+ p = Path(str(_ir_e.files(_PREMIUM_PACKAGE_E) / premium_name))
118
+ if p.is_file():
119
+ return p
120
+ except (FileNotFoundError, ModuleNotFoundError, NotADirectoryError):
121
+ pass
122
+ return Path(str(_ir_e.files(_EVIDENCE_SEED_PACKAGE) / seed_name))
123
+
124
+
125
+ DEFAULT_SOURCES_PATH = _evidence_path("sources.jsonl", "evidence_sources_seed.jsonl")
126
+ DEFAULT_CLAIMS_PATH = _evidence_path("claims.jsonl", "evidence_claims_seed.jsonl")
127
+
128
+ # The premium calibration corpus. Absent from this repository by design; see the
129
+ # note above DEFAULT_PATH in calibration.py.
130
+ from physmap.corpus.calibration import DEFAULT_PATH as DEFAULT_CALIBRATION_PATH # noqa: E402
131
+
132
+
133
+ # ── schema dataclasses ────────────────────────────────────────────────────────
134
+
135
+
136
+ @dataclass
137
+ class Source:
138
+ """One paper / handbook / data sheet."""
139
+
140
+ source_id: str # immutable kebab-case slug, e.g. "forrest-2014"
141
+ citation: str # full citation
142
+ doi: str | None # null when no DOI
143
+ year: int
144
+ source_type: str # ∈ SOURCE_TYPES
145
+ source_status: str # ∈ SOURCE_STATUSES — populated vs stub (no claims against stubs)
146
+ notes: str # e.g. "tested 5 closures simultaneously"
147
+
148
+
149
+ @dataclass
150
+ class Range:
151
+ """Numeric range used for re_range / pr_range in Conditions."""
152
+
153
+ min: float | None
154
+ max: float | None
155
+
156
+
157
+ @dataclass
158
+ class Bound:
159
+ """The claim's asserted boundary on one physics coordinate."""
160
+
161
+ variable: str # "reynolds_number" | "prandtl_number" | "x_over_D" | ...
162
+ valid_above: float | None
163
+ valid_below: float | None
164
+ condition: str # human-readable condition string
165
+
166
+
167
+ @dataclass
168
+ class MeasuredDivergence:
169
+ """Quantitative gap between closure prediction and measurement.
170
+
171
+ Required when the parent claim's role == 'validate' (gate 4). Forbidden
172
+ otherwise. extraction_method discipline is enforced by gates 9, 10, 11.
173
+ """
174
+
175
+ magnitude_pct: float # signed; e.g. +82 (over-prediction) or -3.5
176
+ region: str # e.g. "sub-critical laminar (Re~3900, Pr=5.4)"
177
+ measure: str # ∈ MEASURES
178
+ extraction_method: str # ∈ EXTRACTION_METHODS
179
+ page_or_table: str # gate 11 — anchor to a specific table/figure/page
180
+ magnitude_uncertainty_pct: float | None = None # required when visual-estimate
181
+ n_points: int | None = None # required when visual-estimate
182
+ resolvability_ratio: float | None = None # required when visual-estimate
183
+
184
+
185
+ @dataclass
186
+ class Conditions:
187
+ """The (Re, Pr, geometry, fluid) under which the claim's bound applies."""
188
+
189
+ re_range: Range
190
+ pr_range: Range
191
+ geometry_class: str # "circular-pipe" | "narrow-rect-channel-one-sided" | ...
192
+ fluid: str # "air" | "water" | "R12" | ...
193
+
194
+
195
+ @dataclass
196
+ class Claim:
197
+ """One atomic (source, closure, bound) assertion."""
198
+
199
+ claim_id: str # e.g. "gnielinski-1976-originate-re"
200
+ closure_id: str # → calibration_corpus
201
+ source_id: str # → sources.jsonl (atomic provenance; exactly one)
202
+ claim_type: str # ∈ CLAIM_TYPES
203
+ role: str # ∈ ROLES
204
+ bound: Bound
205
+ measured_divergence: MeasuredDivergence | None
206
+ conditions: Conditions
207
+ status: str # ∈ CLAIM_STATUSES
208
+ supersedes_claim_id: str | None # wasRevisionOf edge
209
+ contradicts_claim_id: str | None # sources-disagree edge
210
+ notes: str = "" # free-form context
211
+
212
+
213
+ # ── (de)serialization ─────────────────────────────────────────────────────────
214
+
215
+
216
+ def _range_from_dict(d: dict) -> Range:
217
+ return Range(min=d.get("min"), max=d.get("max"))
218
+
219
+
220
+ def source_from_dict(d: dict) -> Source:
221
+ return Source(**d)
222
+
223
+
224
+ def claim_from_dict(d: dict) -> Claim:
225
+ """Build a Claim from a JSON-loaded dict (handles nested dataclasses)."""
226
+ bound = Bound(**d["bound"])
227
+
228
+ md_raw = d.get("measured_divergence")
229
+ md = MeasuredDivergence(**md_raw) if md_raw is not None else None
230
+
231
+ cond_raw = d["conditions"]
232
+ conditions = Conditions(
233
+ re_range=_range_from_dict(cond_raw["re_range"]),
234
+ pr_range=_range_from_dict(cond_raw["pr_range"]),
235
+ geometry_class=cond_raw["geometry_class"],
236
+ fluid=cond_raw["fluid"],
237
+ )
238
+
239
+ payload = {**d, "bound": bound, "measured_divergence": md, "conditions": conditions}
240
+ return Claim(**payload)
241
+
242
+
243
+ def source_to_dict(s: Source) -> dict:
244
+ return asdict(s)
245
+
246
+
247
+ def claim_to_dict(c: Claim) -> dict:
248
+ return asdict(c)
249
+
250
+
251
+ # ── per-claim validators (gates 1, 4-12) ──────────────────────────────────────
252
+
253
+
254
+ def validate_claim(
255
+ claim: Claim,
256
+ source_index: dict[str, Source],
257
+ calibration_closure_ids: set[str],
258
+ claim_ids_in_corpus: set[str],
259
+ ) -> list[str]:
260
+ """Return human-readable errors for one claim; empty list = valid.
261
+
262
+ The corpus-wide gates (2, 3, 7, claim_id uniqueness) need the surrounding
263
+ indexes; this function accepts them as arguments rather than reaching out
264
+ to the filesystem itself.
265
+ """
266
+ errs: list[str] = []
267
+ cid = claim.claim_id or "<no claim_id>"
268
+
269
+ # Gate 1 — atomic provenance: exactly one source_id (non-empty string).
270
+ if not claim.source_id or not isinstance(claim.source_id, str):
271
+ errs.append(f"{cid}: source_id missing (gate 1 — atomic provenance)")
272
+
273
+ # Gate 2 — source_id resolves in sources.jsonl AND is not a stub.
274
+ if claim.source_id and claim.source_id not in source_index:
275
+ errs.append(
276
+ f"{cid}: source_id '{claim.source_id}' not found in sources.jsonl "
277
+ f"(gate 2)"
278
+ )
279
+ elif claim.source_id:
280
+ src = source_index[claim.source_id]
281
+ if src.source_status == "stub":
282
+ errs.append(
283
+ f"{cid}: source_id '{claim.source_id}' is a stub "
284
+ f"(no claims allowed against stub sources — gate 2)"
285
+ )
286
+
287
+ # Gate 3 — closure_id resolves in calibration corpus (join validator).
288
+ if claim.closure_id not in calibration_closure_ids:
289
+ errs.append(
290
+ f"{cid}: closure_id '{claim.closure_id}' not found in calibration "
291
+ f"corpus (gate 3 — join validator)"
292
+ )
293
+
294
+ # Gate 4 — measured_divergence populated iff role == 'validate'.
295
+ if claim.role == "validate" and claim.measured_divergence is None:
296
+ errs.append(
297
+ f"{cid}: role='validate' but measured_divergence is null "
298
+ f"(gate 4 — validate claims must carry measured divergence)"
299
+ )
300
+ if claim.role != "validate" and claim.measured_divergence is not None:
301
+ errs.append(
302
+ f"{cid}: role='{claim.role}' but measured_divergence is populated "
303
+ f"(gate 4 — non-validate claims must NOT carry measured divergence)"
304
+ )
305
+
306
+ # Gate 5 — role ∈ ROLES.
307
+ if claim.role not in ROLES:
308
+ errs.append(
309
+ f"{cid}: role '{claim.role}' not in {sorted(ROLES)} (gate 5)"
310
+ )
311
+
312
+ # Gate 6 — claim_type ∈ CLAIM_TYPES.
313
+ if claim.claim_type not in CLAIM_TYPES:
314
+ errs.append(
315
+ f"{cid}: claim_type '{claim.claim_type}' not in {sorted(CLAIM_TYPES)} "
316
+ f"(gate 6)"
317
+ )
318
+
319
+ # Gate 7 — supersedes/contradicts resolve in own claims table.
320
+ for fk_name, fk_value in (
321
+ ("supersedes_claim_id", claim.supersedes_claim_id),
322
+ ("contradicts_claim_id", claim.contradicts_claim_id),
323
+ ):
324
+ if fk_value is not None and fk_value not in claim_ids_in_corpus:
325
+ errs.append(
326
+ f"{cid}: {fk_name} '{fk_value}' does not resolve in claims.jsonl "
327
+ f"(gate 7)"
328
+ )
329
+
330
+ # Gate 8 — status ∈ CLAIM_STATUSES.
331
+ if claim.status not in CLAIM_STATUSES:
332
+ errs.append(
333
+ f"{cid}: status '{claim.status}' not in {sorted(CLAIM_STATUSES)} "
334
+ f"(gate 8)"
335
+ )
336
+
337
+ # Gates 9, 10, 11 — measured_divergence sub-checks.
338
+ md = claim.measured_divergence
339
+ if md is not None:
340
+ # Gate 9 — no provenance-laundered divergence.
341
+ if md.extraction_method not in EXTRACTION_METHODS:
342
+ errs.append(
343
+ f"{cid}: measured_divergence.extraction_method "
344
+ f"'{md.extraction_method}' not in {sorted(EXTRACTION_METHODS)} "
345
+ f"(gate 9 — no provenance laundering; 'calibration-provenance-lift' "
346
+ f"is disallowed by design)"
347
+ )
348
+
349
+ # Gate 10 — uncertainty discipline for visual-estimate claims.
350
+ if md.extraction_method == "visual-estimate-rendered-pdf":
351
+ for fname, fval in (
352
+ ("magnitude_uncertainty_pct", md.magnitude_uncertainty_pct),
353
+ ("n_points", md.n_points),
354
+ ("resolvability_ratio", md.resolvability_ratio),
355
+ ):
356
+ if fval is None:
357
+ errs.append(
358
+ f"{cid}: visual-estimate measured_divergence missing "
359
+ f"required field '{fname}' "
360
+ f"(gate 10 — coarser tier must be self-documenting)"
361
+ )
362
+
363
+ # Gate 11 — page_or_table populated on every MeasuredDivergence.
364
+ if not md.page_or_table:
365
+ errs.append(
366
+ f"{cid}: measured_divergence.page_or_table empty "
367
+ f"(gate 11 — must anchor to a specific table/figure/page)"
368
+ )
369
+
370
+ # Shape — measure ∈ MEASURES.
371
+ if md.measure not in MEASURES:
372
+ errs.append(
373
+ f"{cid}: measured_divergence.measure '{md.measure}' not in "
374
+ f"{sorted(MEASURES)}"
375
+ )
376
+
377
+ return errs
378
+
379
+
380
+ def validate_source(source: Source) -> list[str]:
381
+ """Return human-readable errors for one source row."""
382
+ errs: list[str] = []
383
+ sid = source.source_id or "<no source_id>"
384
+
385
+ if not source.source_id or " " in source.source_id or source.source_id != source.source_id.lower():
386
+ errs.append(f"{sid}: source_id must be a lowercase kebab-case slug (no spaces)")
387
+ if not source.citation:
388
+ errs.append(f"{sid}: citation missing")
389
+ if source.source_type not in SOURCE_TYPES:
390
+ errs.append(
391
+ f"{sid}: source_type '{source.source_type}' not in {sorted(SOURCE_TYPES)}"
392
+ )
393
+ if source.source_status not in SOURCE_STATUSES:
394
+ errs.append(
395
+ f"{sid}: source_status '{source.source_status}' not in "
396
+ f"{sorted(SOURCE_STATUSES)}"
397
+ )
398
+ if not isinstance(source.year, int) or source.year < 1800 or source.year > 2100:
399
+ errs.append(f"{sid}: year {source.year!r} not a plausible integer")
400
+ return errs
401
+
402
+
403
+ # ── corpus-wide invariants ────────────────────────────────────────────────────
404
+
405
+
406
+ def validate_corpus(
407
+ sources: list[Source],
408
+ claims: list[Claim],
409
+ calibration_closure_ids: set[str],
410
+ ) -> dict[str, list[str]]:
411
+ """Per-claim/source errors keyed by id; '__corpus__' for cross-row issues.
412
+
413
+ Implements gates 1-11 per claim, plus:
414
+ - Gate 12 (corpus-wide): claim_id is unique across claims.jsonl.
415
+ - Source-level shape checks via validate_source.
416
+ - Source-id uniqueness.
417
+ """
418
+ out: dict[str, list[str]] = {}
419
+
420
+ # Source shape + uniqueness.
421
+ seen_sources: dict[str, int] = {}
422
+ for s in sources:
423
+ errs = validate_source(s)
424
+ if errs:
425
+ out[s.source_id or "<no source_id>"] = errs
426
+ seen_sources[s.source_id] = seen_sources.get(s.source_id, 0) + 1
427
+ dup_sources = [sid for sid, n in seen_sources.items() if n > 1]
428
+ if dup_sources:
429
+ out.setdefault("__corpus__", []).append(
430
+ f"duplicate source_id values in sources.jsonl: {sorted(dup_sources)}"
431
+ )
432
+
433
+ source_index = {s.source_id: s for s in sources}
434
+ claim_ids_in_corpus = {c.claim_id for c in claims}
435
+
436
+ # Gate 12 (corpus-wide) — claim_id uniqueness.
437
+ seen_claims: dict[str, int] = {}
438
+ for c in claims:
439
+ seen_claims[c.claim_id] = seen_claims.get(c.claim_id, 0) + 1
440
+ dup_claims = [cid for cid, n in seen_claims.items() if n > 1]
441
+ if dup_claims:
442
+ out.setdefault("__corpus__", []).append(
443
+ f"duplicate claim_id values in claims.jsonl (gate 12): "
444
+ f"{sorted(dup_claims)}"
445
+ )
446
+
447
+ # Per-claim gates 1-11.
448
+ for c in claims:
449
+ errs = validate_claim(
450
+ c, source_index, calibration_closure_ids, claim_ids_in_corpus
451
+ )
452
+ if errs:
453
+ out[c.claim_id or "<no claim_id>"] = errs
454
+
455
+ return out
456
+
457
+
458
+ # ── loader API ────────────────────────────────────────────────────────────────
459
+
460
+
461
+ def _load_jsonl(path: Path) -> list[dict]:
462
+ if not path.exists():
463
+ raise FileNotFoundError(f"file not found: {path}")
464
+ rows: list[dict] = []
465
+ with open(path, "r", encoding="utf-8") as f:
466
+ for lineno, line in enumerate(f, start=1):
467
+ stripped = line.strip()
468
+ if not stripped:
469
+ continue
470
+ try:
471
+ rows.append(json.loads(stripped))
472
+ except json.JSONDecodeError as e:
473
+ raise ValueError(
474
+ f"{path.name} JSONL parse error at line {lineno}: {e}"
475
+ ) from e
476
+ return rows
477
+
478
+
479
+ def load_sources(path: str | Path = DEFAULT_SOURCES_PATH) -> list[Source]:
480
+ rows = _load_jsonl(Path(path))
481
+ out: list[Source] = []
482
+ for i, d in enumerate(rows, start=1):
483
+ try:
484
+ out.append(source_from_dict(d))
485
+ except TypeError as e:
486
+ raise ValueError(
487
+ f"sources.jsonl schema mismatch at row {i} "
488
+ f"(source_id={d.get('source_id', '?')!r}): {e}"
489
+ ) from e
490
+ return out
491
+
492
+
493
+ def load_claims(path: str | Path = DEFAULT_CLAIMS_PATH) -> list[Claim]:
494
+ rows = _load_jsonl(Path(path))
495
+ out: list[Claim] = []
496
+ for i, d in enumerate(rows, start=1):
497
+ try:
498
+ out.append(claim_from_dict(d))
499
+ except (TypeError, KeyError) as e:
500
+ raise ValueError(
501
+ f"claims.jsonl schema mismatch at row {i} "
502
+ f"(claim_id={d.get('claim_id', '?')!r}): {e}"
503
+ ) from e
504
+ return out
505
+
506
+
507
+ def load_calibration_closure_ids(
508
+ path: str | Path = DEFAULT_CALIBRATION_PATH,
509
+ ) -> set[str]:
510
+ """Read the calibration corpus and return the set of closure_id values
511
+ (the join domain for evidence-corpus gate 3)."""
512
+ p = Path(path)
513
+ if not p.exists():
514
+ raise FileNotFoundError(f"calibration corpus not found: {p}")
515
+ ids: set[str] = set()
516
+ with open(p, "r", encoding="utf-8") as f:
517
+ for line in f:
518
+ stripped = line.strip()
519
+ if not stripped:
520
+ continue
521
+ d = json.loads(stripped)
522
+ cid = d.get("closure_id")
523
+ if cid:
524
+ ids.add(cid)
525
+ return ids
526
+
527
+
528
+ # ── query helpers (the 5 spec value-test queries) ─────────────────────────────
529
+
530
+
531
+ def query_validity_story(claims: list[Claim], closure_id: str) -> list[Claim]:
532
+ """All claims about one closure — the multi-source picture (spec query 1)."""
533
+ return [c for c in claims if c.closure_id == closure_id]
534
+
535
+
536
+ def query_source_contributions(claims: list[Claim], source_id: str) -> list[Claim]:
537
+ """All claims established by one source (spec query 2)."""
538
+ return [c for c in claims if c.source_id == source_id]
539
+
540
+
541
+ def query_disagreements(claims: list[Claim]) -> list[Claim]:
542
+ """Contested claims OR claims that explicitly contradict another (spec query 3)."""
543
+ return [
544
+ c
545
+ for c in claims
546
+ if c.status == "contested" or c.contradicts_claim_id is not None
547
+ ]
548
+
549
+
550
+ def query_unsupported_bounds(
551
+ claims: list[Claim],
552
+ calibration_path: str | Path = DEFAULT_CALIBRATION_PATH,
553
+ ) -> list[tuple[str, str]]:
554
+ """Calibration bounds with no backing evidence claim (spec query 4).
555
+
556
+ Returns (closure_id, coord) tuples for every CoordinateBound in the
557
+ calibration corpus that has zero matching evidence claims on the same
558
+ closure_id + bound.variable. This is the cross-corpus quality check that
559
+ justifies the separation between calibration and evidence.
560
+ """
561
+ p = Path(calibration_path)
562
+ # (closure_id, variable) tuples that DO have a backing claim.
563
+ backed: set[tuple[str, str]] = {(c.closure_id, c.bound.variable) for c in claims}
564
+
565
+ flagged: list[tuple[str, str]] = []
566
+ with open(p, "r", encoding="utf-8") as f:
567
+ for line in f:
568
+ stripped = line.strip()
569
+ if not stripped:
570
+ continue
571
+ d = json.loads(stripped)
572
+ cid = d.get("closure_id")
573
+ for bound in d.get("validated_range", []):
574
+ coord = bound.get("coord")
575
+ if cid and coord and (cid, coord) not in backed:
576
+ flagged.append((cid, coord))
577
+ return flagged
578
+
579
+
580
+ def query_provenance_for_assessment(
581
+ claims: list[Claim],
582
+ closure_id: str,
583
+ re_value: float | None = None,
584
+ pr_value: float | None = None,
585
+ ) -> list[Claim]:
586
+ """Claims for the closure whose Conditions envelope contains the (Re, Pr)
587
+ point of a fired detector (spec query 5).
588
+
589
+ A claim is included if (a) it pertains to closure_id, and (b) each provided
590
+ operating value falls inside the claim's re_range / pr_range (open-side
591
+ None bounds count as 'unbounded on that side')."""
592
+ out: list[Claim] = []
593
+ for c in claims:
594
+ if c.closure_id != closure_id:
595
+ continue
596
+ if re_value is not None and not _in_range(re_value, c.conditions.re_range):
597
+ continue
598
+ if pr_value is not None and not _in_range(pr_value, c.conditions.pr_range):
599
+ continue
600
+ out.append(c)
601
+ return out
602
+
603
+
604
+ def _in_range(value: float, r: Range) -> bool:
605
+ if r.min is not None and value < r.min:
606
+ return False
607
+ if r.max is not None and value > r.max:
608
+ return False
609
+ return True
610
+
611
+
612
+ # ── v0.6 JSON-LD export ───────────────────────────────────────────────────────
613
+
614
+
615
+ def claim_to_v06_jsonld(claim: Claim, source: Source) -> dict:
616
+ """Project a Claim into v0.6 vocab terms (CredibilityFactor + PROV-DM).
617
+
618
+ Spec mapping: claim_type→factorType, status→factorStatus, source→factorStandard
619
+ (via wasDerivedFrom). Emits a single JSON-LD-shaped dict that uses the v0.6
620
+ context at physmap/fixtures/context/v0.6.jsonld.
621
+ """
622
+ out: dict = {
623
+ "@context": "physmap/fixtures/context/v0.6.jsonld",
624
+ "@type": "CredibilityFactor",
625
+ "id": f"evidence-corpus:{claim.claim_id}",
626
+ "factorType": _V06_FACTOR_TYPE_BY_CLAIM_TYPE.get(claim.claim_type, claim.claim_type),
627
+ "factorStatus": claim.status,
628
+ "factorStandard": source.doi or source.citation,
629
+ "wasDerivedFrom": f"source:{source.source_id}",
630
+ "description": (
631
+ f"{claim.role} claim on closure {claim.closure_id}: "
632
+ f"{claim.bound.variable} {claim.bound.condition}"
633
+ ),
634
+ }
635
+ if claim.measured_divergence is not None:
636
+ md = claim.measured_divergence
637
+ out["hasEvidence"] = {
638
+ "@type": "Discrepancy",
639
+ "discrepancyMagnitude": md.magnitude_pct,
640
+ "discrepancyRegion": md.region,
641
+ "measureType": md.measure,
642
+ "sourceReference": f"{source.source_id}#{md.page_or_table}",
643
+ }
644
+ return out
645
+
646
+
647
+ # ── CLI ───────────────────────────────────────────────────────────────────────
648
+
649
+
650
+ def _print_errors(errs_by_id: dict[str, list[str]]) -> int:
651
+ if not errs_by_id:
652
+ return 0
653
+ total = sum(len(v) for v in errs_by_id.values())
654
+ print(
655
+ f"evidence corpus validation FAILED — {total} errors across "
656
+ f"{len(errs_by_id)} ids:"
657
+ )
658
+ for cid in sorted(errs_by_id):
659
+ print(f" [{cid}]")
660
+ for err in errs_by_id[cid]:
661
+ print(f" • {err}")
662
+ return 1
663
+
664
+
665
+ def _cmd_validate(args: argparse.Namespace) -> int:
666
+ try:
667
+ sources = load_sources(args.sources_path)
668
+ claims = load_claims(args.claims_path)
669
+ calibration_ids = load_calibration_closure_ids(args.calibration_path)
670
+ except (FileNotFoundError, ValueError) as e:
671
+ print(f"FAILED to load corpus: {e}", file=sys.stderr)
672
+ return 2
673
+
674
+ errs_by_id = validate_corpus(sources, claims, calibration_ids)
675
+ if not errs_by_id:
676
+ print(
677
+ f"evidence corpus validation PASSED — {len(sources)} sources, "
678
+ f"{len(claims)} claims clean"
679
+ )
680
+ # Status summary by role/status (audit signal).
681
+ role_counts: dict[str, int] = {}
682
+ status_counts: dict[str, int] = {}
683
+ for c in claims:
684
+ role_counts[c.role] = role_counts.get(c.role, 0) + 1
685
+ status_counts[c.status] = status_counts.get(c.status, 0) + 1
686
+ print(f" by role:")
687
+ for r in sorted(role_counts):
688
+ print(f" {r:>12s} : {role_counts[r]}")
689
+ print(f" by status:")
690
+ for s in sorted(status_counts):
691
+ print(f" {s:>20s} : {status_counts[s]}")
692
+ return 0
693
+ return _print_errors(errs_by_id)
694
+
695
+
696
+ def _cmd_summary(args: argparse.Namespace) -> int:
697
+ sources = load_sources(args.sources_path)
698
+ claims = load_claims(args.claims_path)
699
+
700
+ pop = sum(1 for s in sources if s.source_status == "populated")
701
+ stub = sum(1 for s in sources if s.source_status == "stub")
702
+ print(f"sources : {len(sources)} (populated={pop}, stub={stub})")
703
+ print(f"claims : {len(claims)}")
704
+
705
+ # Claims by closure.
706
+ by_closure: dict[str, list[Claim]] = {}
707
+ for c in claims:
708
+ by_closure.setdefault(c.closure_id, []).append(c)
709
+ print(f" by closure:")
710
+ for cid in sorted(by_closure):
711
+ roles = ", ".join(sorted({c.role for c in by_closure[cid]}))
712
+ print(f" {cid:>55s} : {len(by_closure[cid]):>2d} ({roles})")
713
+
714
+ # Claims by source.
715
+ by_source: dict[str, int] = {}
716
+ for c in claims:
717
+ by_source[c.source_id] = by_source.get(c.source_id, 0) + 1
718
+ print(f" by source:")
719
+ for sid in sorted(by_source):
720
+ print(f" {sid:>30s} : {by_source[sid]}")
721
+
722
+ # Extraction-method tally for validate claims.
723
+ em_counts: dict[str, int] = {}
724
+ for c in claims:
725
+ if c.measured_divergence is not None:
726
+ em_counts[c.measured_divergence.extraction_method] = (
727
+ em_counts.get(c.measured_divergence.extraction_method, 0) + 1
728
+ )
729
+ print(f" by extraction_method (validate claims):")
730
+ for em in sorted(em_counts):
731
+ print(f" {em:>40s} : {em_counts[em]}")
732
+ return 0
733
+
734
+
735
+ def _cmd_query(args: argparse.Namespace) -> int:
736
+ claims = load_claims(args.claims_path)
737
+ sub = args.query_name
738
+
739
+ if sub == "validity-story":
740
+ if not args.closure_id:
741
+ print("--closure-id required for validity-story", file=sys.stderr)
742
+ return 2
743
+ rows = query_validity_story(claims, args.closure_id)
744
+ print(f"validity story for closure '{args.closure_id}' — {len(rows)} claims:")
745
+ for c in rows:
746
+ md = c.measured_divergence
747
+ md_str = (
748
+ f" divergence={md.magnitude_pct:+.1f}% [{md.measure}, "
749
+ f"{md.extraction_method}, {md.page_or_table}]"
750
+ if md is not None
751
+ else ""
752
+ )
753
+ print(
754
+ f" [{c.role:>10s}] {c.claim_id} status={c.status} "
755
+ f"source={c.source_id} bound={c.bound.variable}{md_str}"
756
+ )
757
+ return 0
758
+
759
+ if sub == "source-contributions":
760
+ if not args.source_id:
761
+ print("--source-id required for source-contributions", file=sys.stderr)
762
+ return 2
763
+ rows = query_source_contributions(claims, args.source_id)
764
+ print(f"contributions of source '{args.source_id}' — {len(rows)} claims:")
765
+ for c in rows:
766
+ print(
767
+ f" {c.claim_id} closure={c.closure_id} role={c.role} "
768
+ f"status={c.status}"
769
+ )
770
+ return 0
771
+
772
+ if sub == "unsupported-bounds":
773
+ rows = query_unsupported_bounds(claims, args.calibration_path)
774
+ print(
775
+ f"calibration bounds with no backing evidence claim — {len(rows)} flagged:"
776
+ )
777
+ for cid, coord in sorted(rows):
778
+ print(f" {cid} :: {coord}")
779
+ return 0
780
+
781
+ if sub == "disagreements":
782
+ rows = query_disagreements(claims)
783
+ print(f"disagreements — {len(rows)} claims:")
784
+ for c in rows:
785
+ print(
786
+ f" {c.claim_id} status={c.status} "
787
+ f"contradicts={c.contradicts_claim_id}"
788
+ )
789
+ return 0
790
+
791
+ print(f"unknown query: {sub}", file=sys.stderr)
792
+ return 2
793
+
794
+
795
+ def _cmd_export_jsonld(args: argparse.Namespace) -> int:
796
+ sources = load_sources(args.sources_path)
797
+ claims = load_claims(args.claims_path)
798
+ source_index = {s.source_id: s for s in sources}
799
+
800
+ rows = query_validity_story(claims, args.closure_id)
801
+ if args.re is not None or args.pr is not None:
802
+ rows = query_provenance_for_assessment(rows, args.closure_id, args.re, args.pr)
803
+
804
+ out = []
805
+ for c in rows:
806
+ src = source_index.get(c.source_id)
807
+ if src is None:
808
+ continue
809
+ out.append(claim_to_v06_jsonld(c, src))
810
+ print(json.dumps(out, indent=2))
811
+ return 0
812
+
813
+
814
+ def main(argv: list[str] | None = None) -> int:
815
+ parser = argparse.ArgumentParser(
816
+ description="PhysMAP Evidence Corpus — validator, summary, and query CLI"
817
+ )
818
+ parser.add_argument(
819
+ "--sources-path", type=Path, default=DEFAULT_SOURCES_PATH,
820
+ help=f"path to sources.jsonl (default: {DEFAULT_SOURCES_PATH})",
821
+ )
822
+ parser.add_argument(
823
+ "--claims-path", type=Path, default=DEFAULT_CLAIMS_PATH,
824
+ help=f"path to claims.jsonl (default: {DEFAULT_CLAIMS_PATH})",
825
+ )
826
+ parser.add_argument(
827
+ "--calibration-path", type=Path, default=DEFAULT_CALIBRATION_PATH,
828
+ help=f"path to calibration corpus.jsonl (default: {DEFAULT_CALIBRATION_PATH})",
829
+ )
830
+ sub = parser.add_subparsers(dest="cmd", required=True)
831
+
832
+ sub.add_parser("validate", help="run the 12 QC gates + corpus invariants")
833
+ sub.add_parser("summary", help="print a status summary of the corpus")
834
+
835
+ q = sub.add_parser("query", help="run one of the spec value-test queries")
836
+ q.add_argument(
837
+ "query_name",
838
+ choices=[
839
+ "validity-story",
840
+ "source-contributions",
841
+ "unsupported-bounds",
842
+ "disagreements",
843
+ ],
844
+ )
845
+ q.add_argument("--closure-id", type=str, default=None)
846
+ q.add_argument("--source-id", type=str, default=None)
847
+
848
+ e = sub.add_parser(
849
+ "export-jsonld",
850
+ help="export v0.6-shaped JSON-LD for a closure's claims (optionally filtered "
851
+ "to a (Re, Pr) operating point)",
852
+ )
853
+ e.add_argument("--closure-id", type=str, required=True)
854
+ e.add_argument("--re", type=float, default=None)
855
+ e.add_argument("--pr", type=float, default=None)
856
+
857
+ args = parser.parse_args(argv)
858
+
859
+ if args.cmd == "validate":
860
+ return _cmd_validate(args)
861
+ if args.cmd == "summary":
862
+ return _cmd_summary(args)
863
+ if args.cmd == "query":
864
+ return _cmd_query(args)
865
+ if args.cmd == "export-jsonld":
866
+ return _cmd_export_jsonld(args)
867
+ return 0
868
+
869
+
870
+ if __name__ == "__main__":
871
+ raise SystemExit(main())