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