diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61ba903ab..5f2557fb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,12 @@ jobs: run: python tools/closure_pipeline.py check-plan - name: Math status packet (OPEN/HOLD only; flags stay false) run: python tools/math_status_check.py + - name: Scientific-state schema/authority/crosswalk (pointers only; effect NONE) + run: python tools/scientific_state_check.py + - name: Claims→gate #90 tip-health (identity only; not base→head evidence) + run: python tools/claims_gate_adapter.py tip-health + - name: Claims→gate #90 event base→head compare (immutable refs; impact≠illegal) + run: python tools/claims_gate_adapter.py event-compare --write-report /tmp/claims-gate-impact.json - name: Declared navigation links and anchors run: python tools/navigation_check.py - name: Navigation synthetic controls diff --git a/.gitignore b/.gitignore index 83798d72a..384c379fc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ __pycache__/ *.pyc .pytest_cache/ *.b64 +# Adapter reports must be written outside the checkout (PR15 contract); +# never commit a local in-tree spill. +artifacts/ diff --git a/architecture/scientific_state/README.md b/architecture/scientific_state/README.md new file mode 100644 index 000000000..9db33b297 --- /dev/null +++ b/architecture/scientific_state/README.md @@ -0,0 +1,66 @@ +# Scientific state architecture — schema pilot + +**Object:** SCIENTIFIC-STATE-SCHEMA-20260925-v1 +**Issue:** [#95](https://github.com/d6g8k5htny-coder/main/issues/95) +**Scientific effect: NONE.** Installing this architecture package defines a +versioned schema, authority map, ID crosswalk, and verification-level +vocabulary only. It does not discharge premises, promote claims, flip +`lemma_closed`, alter Math- classifications, or accept any theorem. Hash +identity, green CI, and LLM agreement remain non-discharge. +Formal-verification levels are evidence metadata, not acceptance. + +## Why this is not a second status store + +| Authority | Owns | Path | +|---|---|---| +| Claims firewall | grades, premise openness, composition firewalls | `claims/graph.json` | +| Math- downstream gate | integrity classifications, CONTROLLING promotion | Math- `GRAPH.json` ([PR #13](https://github.com/d6g8k5htny-coder/Math-/pull/13) tip `baca69c…`; PR8/PR11 lineage) | +| Human D0 crosswalk | D0/D4/D5 narrative classification | `docs/DOWNSTREAM_RN_CROSSWALK_20260925.md` ([PR #97](https://github.com/d6g8k5htny-coder/main/pull/97), supersedes #92) | +| Math status packet | `lemma_closed` and related display Booleans | `docs/math_status/` | +| **This package** | schema contract, ID pointers, L0–L5 vocab | `architecture/scientific_state/v1/` | + +If a row needs a status, it points at the owning file. It does not duplicate it. + +## Files + +- [`v1/SCHEMA.json`](v1/SCHEMA.json) — field contract; lists forbidden owned fields +- [`v1/AUTHORITY_MAP.json`](v1/AUTHORITY_MAP.json) — explicit ownership +- [`v1/ID_CROSSWALK.json`](v1/ID_CROSSWALK.json) — pilot ID bridge (no copied statuses) +- [`v1/VERIFICATION_LEVELS.json`](v1/VERIFICATION_LEVELS.json) — L0–L5 evidence metadata + +## Checker + +```sh +python3 tools/scientific_state_check.py +python3 -m unittest tests.test_scientific_state -v +python3 tools/claims_gate_adapter.py +python3 -m unittest tests.test_claims_gate_adapter -v +``` + +The schema checker only **refuses** malformed architecture files, missing authorities, +dangling main IDs, or smuggled status/grade/classification/controlling payloads. +It does not compute promotions or reverse-impact closures. + +## D7 claims→gate adapter (thin) + +`tools/claims_gate_adapter.py` projects tip `claims/graph.json` (including +`depends_on` **and** `sub_obligations`) into a gate-shaped working graph and +runs before/after reverse-impact over UNION(old,new) edges. Semantic reference: +Math- [PR #13](https://github.com/d6g8k5htny-coder/Math-/pull/13) (merged) / main #90 +clarification. Outputs are **HOLD / REVALIDATION proposals only** — never +promotion permission. SUPERSEDED_NONBLOCKING does not satisfy a still-required +premise; required REFUTED forces HOLD. This does not vendor Math- code. + +## Deferred (not this PR) + +- Fail-closed promotion engine — stays in Math- (merged PR13 / `baca69c…`); main hosts only the thin claims→gate projection, not a competing status engine +- D0 crosswalk / nav / math_status pages — already on tip via #97; do not rewrite here +- Status-bearing populated pilot graph — later, still as pointers or read-only mirrors +- Formal L2/L4/L5 pilot lemma — later, small algebraic lemma from the downstream queue +- Wiring into a global `run_checks.py` — optional follow-up + +## Non-claims + +- Does not edit `claims/graph.json`, Math-, tip crosswalk/handoff docs from #97, `OPEN_PROBLEMS.md`, or the vault +- Does not flip any scientific Boolean +- Does not award independence credit diff --git a/architecture/scientific_state/v1/AUTHORITY_MAP.json b/architecture/scientific_state/v1/AUTHORITY_MAP.json new file mode 100644 index 000000000..fb0e8c227 --- /dev/null +++ b/architecture/scientific_state/v1/AUTHORITY_MAP.json @@ -0,0 +1,68 @@ +{ + "schema_id": "scientific_state.authority_map.v1", + "schema_version": 1, + "scientific_effect": "NONE", + "purpose": "Explicit ownership of scientific-state fields. This architecture package never writes the owned fields below.", + "authorities": { + "claims_firewall": { + "id": "claims_firewall", + "owns": ["grade", "firewall_status_columns", "premise_openness", "track_composition_rules"], + "canonical_path": "claims/graph.json", + "checker": "tools/claims_check.py", + "notes": "Machine-checkable claim/premise dependency graph and firewalls. Authoritative for transcribed grades and forbidden compositions." + }, + "math_downstream_gate": { + "id": "math_downstream_gate", + "owns": ["classification", "controlling", "terminality", "reverse_impact_revalidation"], + "canonical_path": "Math-/frontiers/downstream_gate_20260925/GRAPH.json", + "surface": "https://github.com/d6g8k5htny-coder/Math-/pull/13", + "merged_tip": "baca69c394ab42130c61771bee74e808703f1ce7", + "lineage": ["https://github.com/d6g8k5htny-coder/Math-/pull/8", "https://github.com/d6g8k5htny-coder/Math-/pull/11", "https://github.com/d6g8k5htny-coder/Math-/pull/13"], + "owner_issues": [86, 90], + "notes": "Fail-closed CONTROLLING promotion and #90 terminal classifications live in the merged Math- downstream_gate package (PR13 tip baca69c…; PR8/PR11 lineage). Main may host only a thin claims→gate projection for before/after inputs; it must not become a competing status/promotion engine." + }, + "human_d0_crosswalk": { + "id": "human_d0_crosswalk", + "owns": ["d0_classification_narrative"], + "canonical_path": "docs/DOWNSTREAM_RN_CROSSWALK_20260925.md", + "surface": "https://github.com/d6g8k5htny-coder/main/pull/97", + "notes": "Human D0/D4/D5 companion narrative only (landed via #97 on hardening tip; #92 superseded). Not a status register; scientific effect NONE." + }, + "math_status_packet": { + "id": "math_status_packet", + "owns": ["lemma_closed", "certified_C_H", "OBL-H5-JETMOD_display", "D3-LEMMA-RN-UNIF_display"], + "canonical_path": "docs/math_status/", + "checker": "tools/math_status_check.py", + "notes": "Closed packet directory with EXPECTED_NAMES. Architecture package must not add files there or flip packet Booleans." + } + }, + "this_package": { + "id": "scientific_state_architecture", + "owns": [ + "schema_contract", + "id_crosswalk_pointers", + "verification_level_vocabulary", + "claims_gate_adapter_projection", + "semantic_digest_derivation" + ], + "canonical_path": "architecture/scientific_state/v1/", + "adapter": "tools/claims_gate_adapter.py", + "never_writes": [ + "status", + "grade", + "classification", + "controlling", + "lemma_closed", + "prizes_solved", + "independence_credit" + ], + "notes": "Pointers, vocabulary, and an allowed thin claims→gate projection (HOLD/REVALIDATION proposals only; promotion_permission always false). Not a competing status engine. Hash identity, green CI, and LLM agreement remain non-discharge." + }, + "required_authority_ids": [ + "claims_firewall", + "math_downstream_gate", + "human_d0_crosswalk", + "math_status_packet", + "scientific_state_architecture" + ] +} diff --git a/architecture/scientific_state/v1/ID_CROSSWALK.json b/architecture/scientific_state/v1/ID_CROSSWALK.json new file mode 100644 index 000000000..5e64fcb7f --- /dev/null +++ b/architecture/scientific_state/v1/ID_CROSSWALK.json @@ -0,0 +1,97 @@ +{ + "schema_id": "scientific_state.id_crosswalk.v1", + "schema_version": 1, + "scientific_effect": "NONE", + "purpose": "Pilot ID bridge between main claims/graph.json keys and Math- downstream-gate node IDs. No statuses, grades, classifications, or controlling flags are stored here.", + "against_math_gate_head": "5887a2f8aaf121760362e6e90f9974f1e0078bc5", + "rows": [ + { + "row_id": "xwalk.obl-h5-jetmod", + "main_claim_or_premise_id": "OBL-H5-JETMOD", + "main_bucket": "premises", + "math_gate_id": "hist.OBL-H5-JETMOD", + "authority": "claims_firewall", + "notes": "Premise openness lives in claims/graph.json; Math- gate carries historical-route classification separately." + }, + { + "row_id": "xwalk.d3-lemma-rn-unif", + "main_claim_or_premise_id": "D3-LEMMA-RN-UNIF", + "main_bucket": "premises", + "math_gate_id": "hist.lemma_closed", + "authority": "claims_firewall", + "notes": "Premise identity on main; lemma_closed Boolean remains false in math_status / Math- hist.lemma_closed. Bridge only." + }, + { + "row_id": "xwalk.side24-coefficient", + "main_claim_or_premise_id": "SIDE24-3D-AO48-OPR-045", + "main_bucket": "claims", + "math_gate_id": "math.side24-coefficient", + "authority": "claims_firewall", + "notes": "3D SIDE24 ratification claim ↔ Math- coefficient candidate node." + }, + { + "row_id": "xwalk.uniform-matrix-cap-lifetime", + "main_claim_or_premise_id": null, + "main_bucket": null, + "math_gate_id": "math.uniform-matrix-cap-lifetime", + "authority": "math_downstream_gate", + "notes": "Active 2026-09-24 lifetime interface; no exact claims/graph.json twin yet. Pointer reserved." + }, + { + "row_id": "xwalk.lifetime-remainder", + "main_claim_or_premise_id": null, + "main_bucket": null, + "math_gate_id": "math.lifetime-remainder", + "authority": "math_downstream_gate", + "notes": "Bounded remainder candidate; Math- gate owns integrity classification." + }, + { + "row_id": "xwalk.rn-fixed-remote-window", + "main_claim_or_premise_id": null, + "main_bucket": null, + "math_gate_id": "math.rn-fixed-remote-window", + "authority": "math_downstream_gate", + "notes": "Fixed-remote RN count candidate; no claims/graph.json twin." + }, + { + "row_id": "xwalk.rn-mesoscopic-reduction", + "main_claim_or_premise_id": null, + "main_bucket": null, + "math_gate_id": "math.rn-mesoscopic-reduction", + "authority": "math_downstream_gate", + "notes": "Math- PR #7 draft reduction; challenge note (landed via #97) refuses R17 credit." + }, + { + "row_id": "xwalk.p15-full-price", + "main_claim_or_premise_id": "P15-A..D", + "main_bucket": "claims", + "math_gate_id": "math.p15-full-price", + "authority": "claims_firewall", + "notes": "Author-side P15 family ↔ Math- full-price candidate node." + }, + { + "row_id": "xwalk.p15-price-boundary", + "main_claim_or_premise_id": "P15-A..D", + "main_bucket": "claims", + "math_gate_id": "math.p15-price-boundary", + "authority": "claims_firewall", + "notes": "Same P15 family also bridges the price-boundary gate node." + }, + { + "row_id": "xwalk.hist-rnu-env", + "main_claim_or_premise_id": null, + "main_bucket": null, + "math_gate_id": "hist.rnu_env.py", + "authority": "human_d0_crosswalk", + "notes": "ABSENT historical carrier; classified in D0 crosswalk / Math- gate, not inventable on main." + }, + { + "row_id": "xwalk.eng-hard-gate", + "main_claim_or_premise_id": null, + "main_bucket": null, + "math_gate_id": "eng.hard-gate", + "authority": "math_downstream_gate", + "notes": "Integrity-control engineering node for the hard gate itself." + } + ] +} diff --git a/architecture/scientific_state/v1/SCHEMA.json b/architecture/scientific_state/v1/SCHEMA.json new file mode 100644 index 000000000..337b09159 --- /dev/null +++ b/architecture/scientific_state/v1/SCHEMA.json @@ -0,0 +1,150 @@ +{ + "schema_id": "scientific_state.v1", + "schema_version": "1.1", + "object": "SCIENTIFIC-STATE-SCHEMA-20260925-v1.1", + "scientific_effect": "NONE", + "purpose": "Versioned field contract for scientific claim/result nodes. This package holds pointers and metadata only; it is not a status database and does not promote mathematics.", + "orthogonal_axes": { + "semantic_digest": "H(canonical scientific content + typed deps). Digest match is never mathematical acceptance.", + "evidence_digest": "H(evidence identities). Separate from semantic content.", + "verification_level": "L0–L5 evidence metadata only; never CONTROLLING / acceptance.", + "scientific_status": "Owned exclusively by external authorities (claims firewall, Math- gate, math_status). Never derived from digests." + }, + "node_fields": { + "claim_id": { + "required": true, + "type": "string", + "role": "stable_id", + "notes": "Stable identifier. Prefer existing claims/graph.json or Math- GRAPH.json IDs when bridging." + }, + "statement": { + "required": false, + "type": "string", + "role": "pointer_or_excerpt", + "notes": "Statement text or pointer to the authoritative statement carrier. Not acceptance." + }, + "version": { + "required": false, + "type": "string", + "role": "metadata" + }, + "scope": { + "required": false, + "type": "string", + "role": "metadata" + }, + "domain": { + "required": false, + "type": "string", + "role": "metadata" + }, + "depends_on": { + "required": false, + "type": "array", + "items": { + "target_id": "string", + "relation": "string", + "required": "boolean (JSON bool exactly; 0/1/string/null fail closed)" + }, + "role": "pointer", + "notes": "Typed dependency edges. Duplicate (target_id, relation) pairs are rejected. Edge presence here does not classify or promote either endpoint." + }, + "sub_obligations": { + "required": false, + "type": "array", + "items": { + "target_id": "string", + "relation": "string", + "required": "boolean" + }, + "role": "pointer" + }, + "source_bindings": { + "required": false, + "type": "array", + "items": { + "owner": "string", + "repo": "string", + "path": "string", + "commit": "string", + "blob": "string", + "hash": "string" + }, + "role": "pointer", + "notes": "Canonical source binding should include owner/repo/path plus immutable commit/blob/hash where available. Legacy string sources are crosswalked, not silently rewritten." + }, + "evidence": { + "required": false, + "type": "array", + "items": { + "repo": "string", + "path": "string", + "commit": "string", + "hash": "string" + }, + "role": "pointer" + }, + "semantic_digest": { + "required": false, + "type": "string", + "role": "derived_content_address", + "notes": "Derived H(claim_id + statement/version + scope/domain + source bindings + typed edges). Must be recomputed from content; a manually copied fingerprint cannot be the sole change detector. Digest match ≠ acceptance." + }, + "evidence_digest": { + "required": false, + "type": "string", + "role": "derived_evidence_address", + "notes": "Derived H(evidence identities). Orthogonal to semantic_digest." + }, + "manual_fingerprint": { + "required": false, + "type": "string", + "role": "provenance_only", + "notes": "Optional legacy/manual fingerprint retained as provenance. Never the sole change detector; never scientific acceptance." + }, + "verification_level": { + "required": false, + "type": "string", + "enum_ref": "VERIFICATION_LEVELS.json", + "role": "evidence_metadata", + "notes": "L0–L5 evidence metadata only. Level is never scientific acceptance or CONTROLLING promotion." + }, + "supersedes": { + "required": false, + "type": "array", + "role": "pointer" + }, + "superseded_by": { + "required": false, + "type": "array", + "role": "pointer" + }, + "source_authority": { + "required": true, + "type": "string", + "role": "authority_pointer", + "notes": "Must name an authority listed in AUTHORITY_MAP.json. This package never owns grades/statuses/classifications." + }, + "timestamps": { + "required": false, + "type": "object", + "role": "provenance" + }, + "provenance": { + "required": false, + "type": "object", + "role": "provenance" + } + }, + "forbidden_owned_fields": [ + "status", + "grade", + "classification", + "controlling", + "lemma_closed", + "prizes_solved", + "independence_credit" + ], + "forbidden_owned_fields_reason": "Those fields belong to external authorities (claims firewall, Math- hard gate, math_status registers). Duplicating them here would create a competing status store. Digests and verification_level never substitute for scientific_status.", + "change_detection_rule": "Before/after comparison must seed impact from node semantic_digest (or complete source snapshot) OR outgoing-edge record changes, and must include the changed controlling node itself. Manual fingerprint alone is insufficient." +} diff --git a/architecture/scientific_state/v1/VERIFICATION_LEVELS.json b/architecture/scientific_state/v1/VERIFICATION_LEVELS.json new file mode 100644 index 000000000..f3d61591f --- /dev/null +++ b/architecture/scientific_state/v1/VERIFICATION_LEVELS.json @@ -0,0 +1,46 @@ +{ + "schema_id": "scientific_state.verification_levels.v1", + "schema_version": 1, + "scientific_effect": "NONE", + "purpose": "Graded formal-verification vocabulary recorded as evidence metadata. Level is never scientific acceptance, CONTROLLING promotion, or lemma closure.", + "rule": "verification_level ≠ acceptance. A node at L5 remains non-controlling until the owning Math- / claims authorities independently allow promotion under #90/#86 rules.", + "levels": { + "L0": { + "id": "L0", + "label": "prose_or_llm_argument", + "description": "Prose or LLM-produced argument without executable checks." + }, + "L1": { + "id": "L1", + "label": "executable_numerical_or_reproduction", + "description": "Executable numerical or reproduction checks." + }, + "L2": { + "id": "L2", + "label": "exact_arithmetic_or_symbolic_cas", + "description": "Exact arithmetic or symbolic CAS checks." + }, + "L3": { + "id": "L3", + "label": "property_mutation_or_falsification_suite", + "description": "Property / mutation / falsification suite." + }, + "L4": { + "id": "L4", + "label": "smt_or_decision_procedure_certificate", + "description": "SMT or decision-procedure certificate where applicable." + }, + "L5": { + "id": "L5", + "label": "proof_assistant_checked", + "description": "Proof-assistant checked theorem (Lean / Isabelle / Coq or selected equivalent)." + } + }, + "non_discharge_reminder": [ + "GREEN_CI", + "HASH_MATCH", + "SAME_AUTHOR_REVIEW", + "NAVIGATION_SUCCESS", + "LLM_AGREEMENT" + ] +} diff --git a/claims/graph.json b/claims/graph.json index 8ef84ad09..784ceef8b 100644 --- a/claims/graph.json +++ b/claims/graph.json @@ -141,7 +141,30 @@ "source": "Q0_MASTER.md Part I line 336 (Q0_C101_QUALITATIVE_RATE_THEOREM.md, sha prefix 8c2ded652973)", "canon_statement": "0 ≤ 1 − q(r,6/5) ≤ C_Q0 r³ for 0 dict: + return json.loads((ROOT / "claims" / "graph.json").read_text(encoding="utf-8")) + + +def _demote_controlling_hints(claims: dict) -> dict: + """Sparse git fixtures omit drive/ mirrors; demote controlling hints so F2 + unresolved-controlling refusal does not mask edge-impact negative controls. + """ + out = copy.deepcopy(claims) + for bucket in ("claims", "premises"): + for _nid, rec in (out.get(bucket) or {}).items(): + if not isinstance(rec, dict): + continue + grade = str(rec.get("grade") or "") + if grade.strip().upper() in CGA.SOURCE_CONTROLLING_HINTS: + rec["grade"] = "OPEN" + for key in ( + "status_frozen_v2_2", + "status_register_note", + "scientific_status", + ): + val = rec.get(key) + if isinstance(val, str) and val.strip().upper() in CGA.SOURCE_CONTROLLING_HINTS: + rec[key] = "OPEN" + if rec.get("controlling") is True: + rec["controlling"] = False + return out + + +def _crosswalk() -> dict: + return json.loads( + (ROOT / "architecture" / "scientific_state" / "v1" / "ID_CROSSWALK.json").read_text( + encoding="utf-8" + ) + ) + + +def _authority() -> dict: + return json.loads( + (ROOT / "architecture" / "scientific_state" / "v1" / "AUTHORITY_MAP.json").read_text( + encoding="utf-8" + ) + ) + + +class ClaimsGateAdapterTests(unittest.TestCase): + def test_tip_projection_includes_sub_obligations(self): + report = CGA.audit_tip(ROOT) + self.assertEqual(report["problems"], []) + self.assertGreaterEqual(report["sub_obligation_edges"], 3) + self.assertFalse(report["promotion_permission"]) + self.assertEqual(report["identity_impacted"], []) + self.assertGreater(report["hold_node_count"], 0) + self.assertEqual(report["hold_node_count"], len(report["hold_nodes"])) + + def test_deleted_depends_on_edge_still_impacts_via_union(self): + claims = _load_tip_claims() + old = CGA.claims_to_gate_graph(claims) + new_claims = copy.deepcopy(claims) + # Delete D1-v2.2(2) → OBL-D1-PROMOTE edge. + deps = list(new_claims["claims"]["D1-v2.2(2)"]["depends_on"]) + deps.remove("OBL-D1-PROMOTE") + new_claims["claims"]["D1-v2.2(2)"]["depends_on"] = deps + new = CGA.claims_to_gate_graph(new_claims) + # Change the premise fingerprint so it is a changed node. + new["nodes"]["OBL-D1-PROMOTE"]["fingerprint"] = "mutated" + impact = CGA.reverse_impact_between(old, new) + self.assertIn("D1-v2.2(2)", impact["impacted"]) + self.assertFalse(impact["promotion_permission"]) + self.assertTrue( + any(p["proposal"] == "REVALIDATION_REQUIRED" for p in impact["proposals"]) + ) + + def test_deleted_sub_obligation_edge_still_impacts_via_union(self): + claims = _load_tip_claims() + old = CGA.claims_to_gate_graph(claims) + new_claims = copy.deepcopy(claims) + subs = list(new_claims["premises"]["OBL-D1-PROMOTE"]["sub_obligations"]) + self.assertIn("OBL-H5-JETMOD", subs) + subs.remove("OBL-H5-JETMOD") + new_claims["premises"]["OBL-D1-PROMOTE"]["sub_obligations"] = subs + new = CGA.claims_to_gate_graph(new_claims) + new["nodes"]["OBL-H5-JETMOD"]["fingerprint"] = "mutated-jetmod" + impact = CGA.reverse_impact_between(old, new) + # UNION edges retain OBL-D1-PROMOTE ← OBL-H5-JETMOD from old graph. + self.assertIn("OBL-D1-PROMOTE", impact["impacted"]) + self.assertFalse(impact["promotion_permission"]) + + def test_refuted_required_premise_forces_hold(self): + claims = _load_tip_claims() + graph = CGA.claims_to_gate_graph(claims) + # K3-THM-001 is REFUTED; synthesize a dependent that still requires it. + graph["nodes"]["synthetic.dependent"] = { + "bucket": "claims", + "classification": "AUTHOR_SIDE_CANDIDATE", + "controlling": False, + "fingerprint": "syn", + "version": claims["as_of"], + } + graph["edges"].append( + { + "from": "synthetic.dependent", + "to": "K3-THM-001", + "required": True, + "relation": "depends_on", + } + ) + hold = CGA.required_holds(graph, "synthetic.dependent") + self.assertIn("K3-THM-001", hold["refuted_required"]) + self.assertIn("HOLD", hold["proposals"]) + self.assertFalse(hold["promotion_permission"]) + + def test_superseded_nonblocking_does_not_satisfy_required_premise(self): + claims = _load_tip_claims() + graph = CGA.claims_to_gate_graph(claims) + graph["nodes"]["prem.superseded"] = { + "bucket": "premises", + "classification": "SUPERSEDED_NONBLOCKING", + "controlling": False, + "fingerprint": "sup", + "version": claims["as_of"], + } + graph["nodes"]["synthetic.dependent"] = { + "bucket": "claims", + "classification": "AUTHOR_SIDE_CANDIDATE", + "controlling": False, + "fingerprint": "syn", + "version": claims["as_of"], + } + graph["edges"].append( + { + "from": "synthetic.dependent", + "to": "prem.superseded", + "required": True, + "relation": "depends_on", + } + ) + hold = CGA.required_holds(graph, "synthetic.dependent") + self.assertTrue( + any(u["id"] == "prem.superseded" for u in hold["unsatisfied_required"]), + hold, + ) + self.assertIn("HOLD", hold["proposals"]) + self.assertFalse(hold["promotion_permission"]) + + def test_unrelated_node_stable_when_lower_lemma_changes(self): + claims = _load_tip_claims() + old = CGA.claims_to_gate_graph(claims) + new = copy.deepcopy(old) + # Mutate a leaf with no dependents in the projected graph where possible. + # Self-hold includes the changed node; unrelated claims must stay clean. + new["nodes"]["Q0-C101-QUALITATIVE-RATE"]["semantic_digest"] = "unrelated-mutation" + new["nodes"]["Q0-C101-QUALITATIVE-RATE"]["source_snapshot"] = "unrelated-mutation" + new["nodes"]["Q0-C101-QUALITATIVE-RATE"]["fingerprint"] = "unrelated-mutation" + impact = CGA.reverse_impact_between(old, new) + self.assertIn("Q0-C101-QUALITATIVE-RATE", impact["changed_nodes"]) + self.assertIn("Q0-C101-QUALITATIVE-RATE", impact["impacted"]) # self-hold + # LIVE_ROOT with empty depends_on should not drag unrelated claims. + self.assertNotIn("SIDE24-3D-AO48-OPR-045", impact["impacted"]) + self.assertNotIn("P15-A..D", impact["impacted"]) + + def test_edge_only_change_is_impact_seed_without_node_fingerprint_change(self): + claims = _load_tip_claims() + old = CGA.claims_to_gate_graph(claims) + new = copy.deepcopy(old) + # Delete one depends_on edge; leave every node fingerprint/snapshot alone. + target = ("D1-v2.2(2)", "OBL-D1-PROMOTE", "depends_on", True) + new["edges"] = [e for e in new["edges"] if CGA._edge_key(e) != target] + self.assertEqual(len(new["edges"]), len(old["edges"]) - 1) + # Node snapshots unchanged. + for nid in ("D1-v2.2(2)", "OBL-D1-PROMOTE"): + self.assertEqual( + old["nodes"][nid]["source_snapshot"], + new["nodes"][nid]["source_snapshot"], + ) + impact = CGA.reverse_impact_between(old, new) + self.assertIn("D1-v2.2(2)", impact["edge_only_seeds"]) + self.assertIn("OBL-D1-PROMOTE", impact["edge_only_seeds"]) + self.assertIn("D1-v2.2(2)", impact["impacted"]) + self.assertFalse(impact["promotion_permission"]) + + def test_required_true_to_false_flip_is_edge_only_impact_seed(self): + """Math- PR13 loss-only: required True→False seeds child without FP drift.""" + claims = _load_tip_claims() + old = CGA.claims_to_gate_graph(claims) + new = copy.deepcopy(old) + flipped = False + for edge in new["edges"]: + if ( + edge["from"] == "D1-v2.2(2)" + and edge["to"] == "OBL-D1-PROMOTE" + and edge["relation"] == "depends_on" + ): + edge["required"] = False + flipped = True + break + self.assertTrue(flipped) + for nid in ("D1-v2.2(2)", "OBL-D1-PROMOTE"): + self.assertEqual( + old["nodes"][nid]["semantic_digest"], + new["nodes"][nid]["semantic_digest"], + ) + impact = CGA.reverse_impact_between(old, new) + self.assertIn("D1-v2.2(2)", impact["edge_only_seeds"]) + self.assertIn("D1-v2.2(2)", impact["changed_nodes"]) + self.assertIn("D1-v2.2(2)", impact["impacted"]) + self.assertFalse(impact["promotion_permission"]) + + def test_statement_change_detected_via_canonical_source_snapshot(self): + claims = _load_tip_claims() + old = CGA.claims_to_gate_graph(claims) + new_claims = copy.deepcopy(claims) + # Mutate statement only; do not touch a manually curated fingerprint field. + record = new_claims["premises"]["OBL-H5-JETMOD"] + record["statement"] = (record.get("statement") or "") + " [loss-only statement drift]" + new = CGA.claims_to_gate_graph(new_claims) + self.assertNotEqual( + old["nodes"]["OBL-H5-JETMOD"]["source_snapshot"], + new["nodes"]["OBL-H5-JETMOD"]["source_snapshot"], + ) + impact = CGA.reverse_impact_between(old, new) + self.assertIn("OBL-H5-JETMOD", impact["changed_nodes"]) + self.assertIn("OBL-H5-JETMOD", impact["impacted"]) # self-hold + self.assertIn("OBL-D1-PROMOTE", impact["impacted"]) # union parent + + def test_changed_controlling_node_included_in_revalidation_proposal(self): + claims = _load_tip_claims() + old = CGA.claims_to_gate_graph(claims) + new = copy.deepcopy(old) + new["nodes"]["D1-v2.2(2)"]["semantic_digest"] = "self-hold-mutation" + new["nodes"]["D1-v2.2(2)"]["source_snapshot"] = "self-hold-mutation" + new["nodes"]["D1-v2.2(2)"]["fingerprint"] = "self-hold-mutation" + impact = CGA.reverse_impact_between(old, new) + self.assertIn("D1-v2.2(2)", impact["impacted"]) + self.assertTrue( + any( + p["node"] == "D1-v2.2(2)" and p["proposal"] == "REVALIDATION_REQUIRED" + for p in impact["proposals"] + ), + impact["proposals"], + ) + self.assertFalse(impact["promotion_permission"]) + + def test_required_flag_rejects_non_boolean_fail_closed(self): + claims = _load_tip_claims() + graph = CGA.claims_to_gate_graph(claims) + for bad in (0, 1, "true", None): + bad_graph = copy.deepcopy(graph) + bad_graph["edges"][0]["required"] = bad + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.validate_graph_fail_closed(bad_graph) + self.assertIn("strict boolean", str(ctx.exception)) + + def test_required_zero_cannot_silently_skip_premise(self): + claims = _load_tip_claims() + graph = CGA.claims_to_gate_graph(claims) + graph["nodes"]["prem.refuted"] = { + "bucket": "premises", + "classification": "REFUTED", + "controlling": False, + "source_snapshot": "r", + "fingerprint": "r", + "version": claims["as_of"], + } + graph["nodes"]["synthetic.dependent"] = { + "bucket": "claims", + "classification": "AUTHOR_SIDE_CANDIDATE", + "controlling": False, + "source_snapshot": "s", + "fingerprint": "s", + "version": claims["as_of"], + } + graph["edges"].append( + { + "from": "synthetic.dependent", + "to": "prem.refuted", + "required": 0, # must not silently skip + "relation": "depends_on", + } + ) + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.required_holds(graph, "synthetic.dependent") + self.assertIn("strict boolean", str(ctx.exception)) + + def test_duplicate_edge_records_fail_closed(self): + claims = _load_tip_claims() + graph = CGA.claims_to_gate_graph(claims) + dup = { + "from": "D1-v2.2(2)", + "to": "OBL-D1-PROMOTE", + "required": True, + "relation": "depends_on", + } + graph["edges"].append(dup) + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.validate_graph_fail_closed(graph) + self.assertIn("duplicate edge", str(ctx.exception)) + + def test_unknown_dependency_id_fails_closed(self): + claims = _load_tip_claims() + claims = copy.deepcopy(claims) + claims["claims"]["D1-v2.2(1)"]["depends_on"].append("NOT-A-REAL-ID") + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.claims_to_gate_graph(claims) + self.assertIn("unknown dependency", str(ctx.exception)) + + def test_ambiguous_canonical_owner_fails_closed(self): + claims = _load_tip_claims() + crosswalk = _crosswalk() + authority = _authority() + # Duplicate OBL-H5-JETMOD under a second authority. + crosswalk = copy.deepcopy(crosswalk) + crosswalk["rows"].append( + { + "row_id": "xwalk.obl-h5-jetmod-dup", + "main_claim_or_premise_id": "OBL-H5-JETMOD", + "main_bucket": "premises", + "math_gate_id": "hist.OBL-H5-JETMOD", + "authority": "math_downstream_gate", + "notes": "ambiguous duplicate", + } + ) + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.claims_to_gate_graph( + claims, crosswalk=crosswalk, authority_map=authority + ) + self.assertIn("ambiguous canonical owner", str(ctx.exception)) + + def test_missing_as_of_fails_closed_as_stale(self): + claims = _load_tip_claims() + claims = copy.deepcopy(claims) + del claims["as_of"] + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.claims_to_gate_graph(claims) + self.assertIn("as_of", str(ctx.exception)) + + def test_required_cycle_fails_closed(self): + claims = _load_tip_claims() + graph = CGA.claims_to_gate_graph(claims) + # Introduce a cycle among synthetic nodes. + graph["nodes"]["cyc.a"] = { + "bucket": "premises", + "classification": "OPEN_ACTIVE", + "controlling": False, + "fingerprint": "a", + "version": "x", + } + graph["nodes"]["cyc.b"] = { + "bucket": "premises", + "classification": "OPEN_ACTIVE", + "controlling": False, + "fingerprint": "b", + "version": "x", + } + graph["edges"].extend( + [ + {"from": "cyc.a", "to": "cyc.b", "required": True, "relation": "depends_on"}, + {"from": "cyc.b", "to": "cyc.a", "required": True, "relation": "depends_on"}, + ] + ) + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.validate_graph_fail_closed(graph) + self.assertIn("cycle", str(ctx.exception)) + + def test_adapter_never_exposes_promotion_permission(self): + claims = _load_tip_claims() + graph = CGA.claims_to_gate_graph(claims) + hold = CGA.required_holds(graph, "D1-v2.2(2)") + impact = CGA.reverse_impact_between(graph, copy.deepcopy(graph)) + self.assertFalse(hold["promotion_permission"]) + self.assertFalse(impact["promotion_permission"]) + self.assertFalse(graph.get("promotion_permission", True) is True and False) + self.assertIs(graph["promotion_permission"], False) + + def test_compare_claims_files_surfaces_unsatisfied_in_hold_proposals(self): + """Regression: aggregate must not drop OPEN_ACTIVE / AUTHOR_SIDE holds.""" + claims = _load_tip_claims() + report = CGA.compare_claims_files(claims, claims) + # D1-v2.2(2) requires OBL-D1-PROMOTE which maps to OPEN_ACTIVE — not REFUTED. + self.assertIn("D1-v2.2(2)", report["hold_proposals"]) + hold = report["hold_proposals"]["D1-v2.2(2)"] + self.assertTrue(hold["unsatisfied_required"]) + self.assertFalse(hold["refuted_required"]) + self.assertIn("HOLD", hold["proposals"]) + self.assertFalse(report["promotion_permission"]) + + def test_aggregate_hold_includes_superseded_nonblocking_required(self): + claims = _load_tip_claims() + graph = CGA.claims_to_gate_graph(claims) + graph["nodes"]["prem.superseded"] = { + "bucket": "premises", + "classification": "SUPERSEDED_NONBLOCKING", + "controlling": False, + "semantic_digest": "s", + "source_snapshot": "s", + "fingerprint": "s", + "version": claims["as_of"], + } + graph["nodes"]["synthetic.dependent"] = { + "bucket": "claims", + "classification": "AUTHOR_SIDE_CANDIDATE", + "controlling": False, + "semantic_digest": "d", + "source_snapshot": "d", + "fingerprint": "d", + "version": claims["as_of"], + } + graph["edges"].append( + { + "from": "synthetic.dependent", + "to": "prem.superseded", + "required": True, + "relation": "depends_on", + } + ) + holds = CGA.aggregate_hold_proposals(graph) + self.assertIn("synthetic.dependent", holds) + self.assertTrue( + any(u["id"] == "prem.superseded" for u in holds["synthetic.dependent"]["unsatisfied_required"]), + holds["synthetic.dependent"], + ) + self.assertIn("HOLD", holds["synthetic.dependent"]["proposals"]) + + def test_audit_tip_reports_hold_inventory(self): + report = CGA.audit_tip(ROOT) + self.assertIn("hold_node_count", report) + self.assertIn("hold_nodes", report) + self.assertEqual(report["hold_node_count"], len(report["hold_nodes"])) + self.assertIsInstance(report["hold_proposals"], dict) + # Tip claims have no PROVED_REVIEWED premises; dependents with required + # edges must show up as HOLD (fail-closed inventory visible to CI). + self.assertGreater(report["hold_node_count"], 0) + self.assertFalse(report["promotion_permission"]) + self.assertEqual(report["mode"], "tip_health") + self.assertIn("NOT evidence", report["meaning"]) + + def test_malformed_depends_on_containers_fail_closed(self): + claims = _load_tip_claims() + for bad in (False, 0, "", {}, None): + mutated = copy.deepcopy(claims) + mutated["claims"]["D1-v2.2(2)"]["depends_on"] = bad + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.claims_to_gate_graph(mutated) + self.assertIn("must be a list", str(ctx.exception)) + + def test_malformed_sub_obligations_containers_fail_closed(self): + claims = _load_tip_claims() + for bad in (False, 0, "", {}, None): + mutated = copy.deepcopy(claims) + mutated["premises"]["OBL-D1-PROMOTE"]["sub_obligations"] = bad + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.claims_to_gate_graph(mutated) + self.assertIn("must be a list", str(ctx.exception)) + + def test_malformed_as_of_values_fail_closed(self): + claims = _load_tip_claims() + for bad in (None, "", False, 0): + mutated = copy.deepcopy(claims) + mutated["as_of"] = bad + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.claims_to_gate_graph(mutated) + self.assertIn("as_of", str(ctx.exception)) + + def test_cli_rejects_unknown_arguments(self): + import subprocess + import sys + + result = subprocess.run( + [sys.executable, str(ROOT / "tools" / "claims_gate_adapter.py"), "--before", "x"], + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + + def test_cli_compare_reports_deleted_depends_on_edge(self): + """Entry-point sentinel: same compare command path CI uses for file mode.""" + import subprocess + import sys + import tempfile + + claims = _load_tip_claims() + before = claims + after = copy.deepcopy(claims) + deps = list(after["claims"]["D1-v2.2(2)"]["depends_on"]) + deps.remove("OBL-D1-PROMOTE") + after["claims"]["D1-v2.2(2)"]["depends_on"] = deps + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + before_path = tmp / "before.json" + after_path = tmp / "after.json" + report_path = tmp / "report.json" + before_path.write_text(json.dumps(before), encoding="utf-8") + after_path.write_text(json.dumps(after), encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(ROOT / "tools" / "claims_gate_adapter.py"), + "compare", + "--before", + str(before_path), + "--after", + str(after_path), + "--write-report", + str(report_path), + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + report = json.loads(report_path.read_text(encoding="utf-8")) + self.assertEqual(report["mode"], "path_compare") + self.assertIn("D1-v2.2(2)", report["reverse_impact"]["impacted"]) + self.assertFalse(report["promotion_permission"]) + self.assertIn("blob_sha256", report["before_identity"]) + self.assertIn("blob_sha256", report["after_identity"]) + + def test_cli_compare_reports_deleted_sub_obligation_edge(self): + import subprocess + import sys + import tempfile + + claims = _load_tip_claims() + after = copy.deepcopy(claims) + subs = list(after["premises"]["OBL-D1-PROMOTE"]["sub_obligations"]) + subs.remove("OBL-H5-JETMOD") + after["premises"]["OBL-D1-PROMOTE"]["sub_obligations"] = subs + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + before_path = tmp / "before.json" + after_path = tmp / "after.json" + before_path.write_text(json.dumps(claims), encoding="utf-8") + after_path.write_text(json.dumps(after), encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(ROOT / "tools" / "claims_gate_adapter.py"), + "compare", + "--before", + str(before_path), + "--after", + str(after_path), + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + report = json.loads(result.stdout) + self.assertIn("OBL-D1-PROMOTE", report["reverse_impact"]["impacted"]) + + def test_event_compare_fails_closed_on_missing_base(self): + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.resolve_event_refs( + environ={"GITHUB_EVENT_NAME": "push"}, + event={"before": CGA.ZERO_SHA, "after": "abc" * 10 + "abcdefab"}, + ) + self.assertIn("all-zero", str(ctx.exception)) + + def test_event_compare_uses_pull_request_base_head(self): + before = "a" * 40 + after = "b" * 40 + b, a, name = CGA.resolve_event_refs( + environ={"GITHUB_EVENT_NAME": "pull_request"}, + event={ + "pull_request": { + "base": {"sha": before}, + "head": {"sha": after}, + } + }, + ) + self.assertEqual((b, a, name), (before, after, "pull_request")) + + def test_cli_compare_refs_with_temporary_commits_reports_edge_delete(self): + """Negative control at git-ref entry point (CI event-compare uses refs).""" + import subprocess + import sys + import tempfile + + claims = _demote_controlling_hints(_load_tip_claims()) + after_claims = copy.deepcopy(claims) + deps = list(after_claims["claims"]["D1-v2.2(2)"]["depends_on"]) + deps.remove("OBL-D1-PROMOTE") + after_claims["claims"]["D1-v2.2(2)"]["depends_on"] = deps + + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "test"], + cwd=repo, + check=True, + capture_output=True, + ) + # Minimal tree with claims + architecture files required by compare-refs. + (repo / "claims").mkdir() + arch = repo / "architecture" / "scientific_state" / "v1" + arch.mkdir(parents=True) + (repo / "claims" / "graph.json").write_text( + json.dumps(claims), encoding="utf-8" + ) + for name in ("ID_CROSSWALK.json", "AUTHORITY_MAP.json"): + src = ROOT / "architecture" / "scientific_state" / "v1" / name + (arch / name).write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "before"], + cwd=repo, + check=True, + capture_output=True, + ) + before_ref = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + (repo / "claims" / "graph.json").write_text( + json.dumps(after_claims), encoding="utf-8" + ) + subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "after-edge-delete"], + cwd=repo, + check=True, + capture_output=True, + ) + after_ref = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + result = subprocess.run( + [ + sys.executable, + str(ROOT / "tools" / "claims_gate_adapter.py"), + "compare-refs", + "--before-ref", + before_ref, + "--after-ref", + after_ref, + "--repo-root", + str(repo), + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + report = json.loads(result.stdout) + self.assertIn("D1-v2.2(2)", report["reverse_impact"]["impacted"]) + self.assertEqual(report["before_ref"], before_ref) + self.assertIn("blob_sha256", report["before_identity"]) + self.assertFalse(report["promotion_permission"]) + + result2 = subprocess.run( + [ + sys.executable, + str(ROOT / "tools" / "claims_gate_adapter.py"), + "event-compare", + "--repo-root", + str(repo), + ], + capture_output=True, + text=True, + env={ + **{k: v for k, v in __import__("os").environ.items()}, + "CLAIMS_GATE_BEFORE_REF": before_ref, + "CLAIMS_GATE_AFTER_REF": after_ref, + }, + ) + self.assertEqual(result2.returncode, 0, result2.stdout + result2.stderr) + report2 = json.loads(result2.stdout) + self.assertEqual(report2["mode"], "event_compare") + self.assertIn("D1-v2.2(2)", report2["reverse_impact"]["impacted"]) + + + def test_refuted_classification_preserved_on_revalidation(self): + claims = _load_tip_claims() + old = CGA.claims_to_gate_graph(claims) + new = copy.deepcopy(old) + # K3-THM-001 is REFUTED in tip projection. + self.assertEqual(new["nodes"]["K3-THM-001"]["classification"], "REFUTED") + new["nodes"]["K3-THM-001"]["semantic_digest"] = "mutated-refuted" + new["nodes"]["K3-THM-001"]["source_snapshot"] = "mutated-refuted" + impact = CGA.reverse_impact_between(old, new) + self.assertIn("K3-THM-001", impact["impacted"]) + preserved = impact["graph"]["nodes"]["K3-THM-001"]["classification"] + self.assertEqual(preserved, "REFUTED") + self.assertEqual( + impact["graph"]["nodes"]["K3-THM-001"]["revalidation_proposal"], + "REVALIDATION_REQUIRED", + ) + + def test_source_file_byte_drift_seeds_impact_without_claims_record_change(self): + claims = _load_tip_claims() + old = CGA.claims_to_gate_graph(claims) + new = copy.deepcopy(old) + old_sources = { + "D1-v2.2(2)": { + "kind": "blob", + "reference": "docs/fake.md", + "path": "docs/fake.md", + "bytes": 3, + "sha256": "aaa", + } + } + new_sources = { + "D1-v2.2(2)": { + "kind": "blob", + "reference": "docs/fake.md", + "path": "docs/fake.md", + "bytes": 4, + "sha256": "bbb", + } + } + impact = CGA.reverse_impact_between( + old, new, old_sources=old_sources, new_sources=new_sources + ) + self.assertIn("D1-v2.2(2)", impact["source_byte_seeds"]) + self.assertIn("D1-v2.2(2)", impact["impacted"]) + + def test_external_source_marked_unresolved_not_invented(self): + bound = CGA.bind_source_at_revision( + ROOT, + "HEAD", + {"source": "https://example.com/theorem.pdf"}, + ) + self.assertEqual(bound["kind"], "external_unresolved") + self.assertNotIn("sha256", bound) + + + def test_write_report_refuses_in_repo_path(self): + """Regression: in-tree artifacts/ must not pollute REPOSITORY_TOP_LEVEL.""" + import subprocess + import sys + import tempfile + + claims = _load_tip_claims() + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + before_path = tmp / "before.json" + after_path = tmp / "after.json" + before_path.write_text(json.dumps(claims), encoding="utf-8") + after_path.write_text(json.dumps(claims), encoding="utf-8") + in_repo = ROOT / "artifacts" / "should-not-be-created.json" + result = subprocess.run( + [ + sys.executable, + str(ROOT / "tools" / "claims_gate_adapter.py"), + "compare", + "--before", + str(before_path), + "--after", + str(after_path), + "--write-report", + str(in_repo), + ], + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("outside the repository", result.stdout) + self.assertFalse(in_repo.exists()) + self.assertFalse((ROOT / "artifacts").exists()) + + def test_cli_tip_health_labeled_not_transition_evidence(self): + import subprocess + import sys + + result = subprocess.run( + [sys.executable, str(ROOT / "tools" / "claims_gate_adapter.py"), "tip-health"], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + self.assertEqual(report["mode"], "tip_health") + self.assertEqual(report["identity_impacted"], []) + self.assertIn("NOT evidence", report["meaning"]) + + +class FiveBoundaryEnforcementTests(unittest.TestCase): + """OpenAI trial PR124 probe families — repaired behavior, not defect replay.""" + + def _mini_graph(self): + return { + "as_of": "2026-09-25", + "premises": { + "P": { + "status_register_note": "OPEN", + "source": {"path": "proof.md"}, + } + }, + "claims": { + "T": { + "grade": "OPEN", + "controlling": False, + "depends_on": ["P"], + "source": {"path": "theorem.md"}, + }, + "U": {"grade": "OPEN", "source": {"path": "unrelated.md"}}, + }, + } + + def _init_fixture(self, repo: Path, graph: dict, *, crosswalk=None, authority=None): + import subprocess + + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "test"], + cwd=repo, + check=True, + capture_output=True, + ) + (repo / "claims").mkdir() + arch = repo / "architecture" / "scientific_state" / "v1" + arch.mkdir(parents=True) + (repo / "claims" / "graph.json").write_text( + json.dumps(graph, indent=2) + "\n", encoding="utf-8" + ) + cw = crosswalk or { + "rows": [{"main_claim_or_premise_id": "P", "authority": "a"}] + } + au = authority or { + "authorities": {"a": {}, "b": {}}, + "this_package": {"id": "adapter"}, + } + (arch / "ID_CROSSWALK.json").write_text( + json.dumps(cw, indent=2) + "\n", encoding="utf-8" + ) + (arch / "AUTHORITY_MAP.json").write_text( + json.dumps(au, indent=2) + "\n", encoding="utf-8" + ) + for name in ("proof.md", "theorem.md", "unrelated.md", "mirror.md"): + (repo / name).write_text(f"Synthetic {name} version 1\n", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "before"], + cwd=repo, + check=True, + capture_output=True, + ) + return subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + def _commit_after(self, repo: Path, label: str = "after") -> str: + import subprocess + + subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", label, "--allow-empty"], + cwd=repo, + check=True, + capture_output=True, + ) + return subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + def _event_compare(self, repo: Path, before: str, after: str): + import os + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + str(ROOT / "tools" / "claims_gate_adapter.py"), + "event-compare", + "--repo-root", + str(repo), + ], + capture_output=True, + text=True, + env={ + **os.environ, + "CLAIMS_GATE_BEFORE_REF": before, + "CLAIMS_GATE_AFTER_REF": after, + }, + ) + try: + report = json.loads(result.stdout) + except json.JSONDecodeError: + report = None + return result.returncode, report + + def test_strict_json_rejects_duplicate_keys_and_nonfinite(self): + with self.assertRaises(CGA.AdapterError) as ctx: + CGA.load_json_strict( + '{"depends_on": ["P"], "depends_on": []}', where="dup" + ) + self.assertIn("duplicate JSON key", str(ctx.exception)) + with self.assertRaises(CGA.AdapterError) as ctx2: + CGA.load_json_strict('{"n": NaN}', where="nan") + self.assertIn("nonfinite", str(ctx2.exception)) + + def test_bind_all_source_bindings_and_mirror_does_not_shadow(self): + import tempfile + + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + g["premises"]["P"].pop("source") + g["premises"]["P"]["source_bindings"] = [ + {"repo": "d6g8k5htny-coder/main", "path": "proof.md"} + ] + before = self._init_fixture(repo, g) + (repo / "proof.md").write_text("Changed canonical source\n", encoding="utf-8") + after = self._commit_after(repo, "bindings-edit") + rc, report = self._event_compare(repo, before, after) + self.assertEqual(rc, 0, report) + self.assertTrue(report["transition_ok"]) + self.assertIn("P", report["reverse_impact"]["impacted"]) + self.assertIn("T", report["reverse_impact"]["impacted"]) + self.assertIn( + report["new_sources"]["P"]["kind"], {"blob", "tree", "multi"} + ) + fields = {b.get("field") for b in report["new_sources"]["P"]["bindings"]} + self.assertTrue(any("source_bindings" in f for f in fields)) + + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + g["premises"]["P"]["mirror_path"] = "mirror.md" + before = self._init_fixture(repo, g) + (repo / "proof.md").write_text( + "Changed second declared source\n", encoding="utf-8" + ) + after = self._commit_after(repo, "shadow-edit") + rc, report = self._event_compare(repo, before, after) + self.assertEqual(rc, 0, report) + self.assertIn("P", report["reverse_impact"]["impacted"]) + paths = { + b.get("path") + for b in report["new_sources"]["P"]["bindings"] + if b.get("kind") in {"blob", "tree"} + } + self.assertIn("proof.md", paths) + self.assertIn("mirror.md", paths) + + def test_illegal_controlling_over_refuted_fails_transition(self): + import tempfile + + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + g["premises"]["P"]["status_register_note"] = "REFUTED" + before = self._init_fixture(repo, g) + g2 = json.loads((repo / "claims" / "graph.json").read_text(encoding="utf-8")) + g2["claims"]["T"].update(grade="LIVE_ROOT_THEOREM", controlling=True) + (repo / "claims" / "graph.json").write_text( + json.dumps(g2, indent=2) + "\n", encoding="utf-8" + ) + after = self._commit_after(repo, "illegal-promote") + rc, report = self._event_compare(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertIsInstance(report, dict) + self.assertFalse(report["transition_ok"]) + self.assertFalse(report["promotion_permission"]) + self.assertIn("T", report["controlling_impacted"]) + self.assertEqual( + report["hold_proposals"]["T"]["refuted_required"], ["P"] + ) + self.assertTrue(report["illegal_controlling_transitions"]) + + def test_retained_controlling_with_changed_premise_fails(self): + import tempfile + + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + g["claims"]["T"].update(grade="LIVE_ROOT_THEOREM", controlling=True) + before = self._init_fixture(repo, g) + (repo / "proof.md").write_text( + "Changed load-bearing lemma\n", encoding="utf-8" + ) + after = self._commit_after(repo, "premise-under-controlling") + rc, report = self._event_compare(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertFalse(report["transition_ok"]) + self.assertIn("T", report["controlling_impacted"]) + # Safe corrective counterpart: same premise edit under OPEN stays OK. + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + before = self._init_fixture(repo, g) + (repo / "proof.md").write_text("Synthetic correction\n", encoding="utf-8") + after = self._commit_after(repo, "safe-edit") + rc, report = self._event_compare(repo, before, after) + self.assertEqual(rc, 0, report) + self.assertTrue(report["transition_ok"]) + self.assertEqual(set(report["reverse_impact"]["impacted"]), {"P", "T"}) + + def test_crosswalk_owner_drift_seeds_impact(self): + import tempfile + + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + before = self._init_fixture(repo, g) + arch = repo / "architecture" / "scientific_state" / "v1" + cw = json.loads((arch / "ID_CROSSWALK.json").read_text(encoding="utf-8")) + cw["rows"][0]["authority"] = "b" + (arch / "ID_CROSSWALK.json").write_text( + json.dumps(cw, indent=2) + "\n", encoding="utf-8" + ) + after = self._commit_after(repo, "owner-drift") + rc, report = self._event_compare(repo, before, after) + self.assertEqual(rc, 0, report) + # Owner seed on P must reverse-propagate to dependent T. + self.assertEqual(set(report["reverse_impact"]["impacted"]), {"P", "T"}) + self.assertIn("P", report["authority_owner_seeds"]) + self.assertIn("old_crosswalk_identity", report) + self.assertIn("blob_sha256", report["old_crosswalk_identity"]) + + def test_owner_drift_with_controlling_consumer_refused(self): + """A: controlling T depending on P must fail when owner of P drifts.""" + import tempfile + + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + g["claims"]["T"].update(grade="LIVE_ROOT_THEOREM", controlling=True) + before = self._init_fixture(repo, g) + arch = repo / "architecture" / "scientific_state" / "v1" + cw = json.loads((arch / "ID_CROSSWALK.json").read_text(encoding="utf-8")) + cw["rows"][0]["authority"] = "b" + (arch / "ID_CROSSWALK.json").write_text( + json.dumps(cw, indent=2) + "\n", encoding="utf-8" + ) + after = self._commit_after(repo, "owner-drift-controlling") + rc, report = self._event_compare(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertFalse(report["transition_ok"]) + self.assertIn("T", report["reverse_impact"]["impacted"]) + self.assertIn("T", report["controlling_impacted"]) + + def test_malformed_old_crosswalk_fails_closed_not_absent_migration(self): + """B: present-but-malformed historical schema must not use absent fallback.""" + import tempfile + + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + before = self._init_fixture(repo, g) + arch = repo / "architecture" / "scientific_state" / "v1" + # Duplicate key — present object, invalid JSON under strict parse. + (arch / "ID_CROSSWALK.json").write_text( + '{"rows": [], "rows": [{"main_claim_or_premise_id": "P", "authority": "a"}]}\n', + encoding="utf-8", + ) + after = self._commit_after(repo, "malformed-old-crosswalk") + # Compare after (good tip claims) as before? We need BEFORE to have + # the malformed file. Rebuild: commit good, then malformed as "before" + # by comparing malformed→good? Review case is OLD=malformed. + # Re-init: first commit malformed, second commit repair. + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + # Write malformed crosswalk before first commit. + import subprocess + + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "test"], + cwd=repo, + check=True, + capture_output=True, + ) + (repo / "claims").mkdir() + arch = repo / "architecture" / "scientific_state" / "v1" + arch.mkdir(parents=True) + (repo / "claims" / "graph.json").write_text( + json.dumps(g, indent=2) + "\n", encoding="utf-8" + ) + (arch / "ID_CROSSWALK.json").write_text( + '{"rows": [], "rows": [{"main_claim_or_premise_id": "P", "authority": "a"}]}\n', + encoding="utf-8", + ) + (arch / "AUTHORITY_MAP.json").write_text( + json.dumps({"authorities": {"a": {}, "b": {}}, "this_package": {"id": "adapter"}}) + + "\n", + encoding="utf-8", + ) + for name in ("proof.md", "theorem.md", "unrelated.md", "mirror.md"): + (repo / name).write_text(f"Synthetic {name} version 1\n", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "malformed-before"], + cwd=repo, + check=True, + capture_output=True, + ) + before = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + (arch / "ID_CROSSWALK.json").write_text( + json.dumps( + {"rows": [{"main_claim_or_premise_id": "P", "authority": "a"}]}, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + after = self._commit_after(repo, "repaired-crosswalk") + rc, report = self._event_compare(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertIn("error", report) + self.assertIn("duplicate JSON key", report["error"]) + self.assertNotIn("absent_old_schema", str(report.get("old_crosswalk_identity"))) + + def test_mutable_refs_resolve_to_full_commit_ids(self): + import subprocess + import tempfile + + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + before = self._init_fixture(repo, g) + after = self._commit_after(repo, "noop-after") + subprocess.run( + ["git", "branch", "review-before", before], + cwd=repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "branch", "review-after", after], + cwd=repo, + check=True, + capture_output=True, + ) + rc, report = self._event_compare( + repo, "review-before", "review-after" + ) + self.assertEqual(rc, 0, report) + self.assertEqual(report["base_commit"], before) + self.assertEqual(report["head_commit"], after) + self.assertRegex(report["base_commit"], r"^[0-9a-f]{40}$") + self.assertRegex(report["head_commit"], r"^[0-9a-f]{40}$") + + def test_duplicate_json_dependency_key_rejected_at_event_boundary(self): + import tempfile + + with tempfile.TemporaryDirectory() as raw: + repo = Path(raw) / "repo" + repo.mkdir() + g = self._mini_graph() + before = self._init_fixture(repo, g) + path = repo / "claims" / "graph.json" + text = json.dumps(g) + mutated = text.replace( + '"depends_on": ["P"]', '"depends_on": ["P"], "depends_on": []' + ) + self.assertNotEqual(mutated, text) + path.write_text(mutated + "\n", encoding="utf-8") + after = self._commit_after(repo, "dup-key") + rc, report = self._event_compare(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertIsInstance(report, dict) + self.assertIn("error", report) + self.assertIn("duplicate JSON key", report["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_claims_gate_enforcement.py b/tests/test_claims_gate_enforcement.py new file mode 100644 index 000000000..3110c2bd1 --- /dev/null +++ b/tests/test_claims_gate_enforcement.py @@ -0,0 +1,233 @@ +"""F1 loss-only contract: real status changes must affect CLI exit status. + +Uses synthetic data and temporary Git commits. No scientific status is written. +""" +from __future__ import annotations +import copy +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("f1_adapter_under_test", ROOT / "tools/claims_gate_adapter.py") +if SPEC is None or SPEC.loader is None: + raise RuntimeError("Cannot load subject adapter") +CGA = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CGA) + + +def fixture(): + return {"as_of": "2026-09-25", "premises": { + "P": {"status_register_note": "OPEN", "source": {"path": "proof.md"}}}, + "claims": {"T": {"grade": "OPEN", "controlling": False, "depends_on": ["P"], + "source": {"path": "theorem.md"}}, + "U": {"grade": "OPEN", "source": {"path": "unrelated.md"}}}} + + +def promote(g, node="T"): + g["claims"][node].update(grade="LIVE_ROOT_THEOREM", controlling=True) + + +def demote(g, node="T"): + g["claims"][node].update(grade="RETRACTED_TO_CANDIDATE", controlling=False) + + +def changed_pair(): + old = fixture() + promote(old) + new = copy.deepcopy(old) + new["premises"]["P"]["statement"] = "Corrected lower lemma" + return old, new + + +class EnforcementLibraryTests(unittest.TestCase): + def check_blocked(self, old, new, node="T"): + frozen_old, frozen_new = copy.deepcopy(old), copy.deepcopy(new) + report = CGA.compare_claims_files(old, new) + self.assertIs(report.get("transition_ok"), False, report) + self.assertIn(node, [row.get("node") for row in report.get("transition_errors", [])]) + self.assertFalse(report["promotion_permission"]) + self.assertEqual(old, frozen_old) + self.assertEqual(new, frozen_new) + return report + + def test_promotion_over_refuted_required_premise_is_refused(self): + old=fixture(); old["premises"]["P"]["status_register_note"]="REFUTED" + new=copy.deepcopy(old); promote(new) + report=self.check_blocked(old,new) + self.assertEqual(report["hold_proposals"]["T"]["refuted_required"],["P"]) + + def test_promotion_over_open_required_premise_is_refused(self): + old=fixture();new=copy.deepcopy(old);promote(new) + self.check_blocked(old,new) + + def test_new_controlling_leaf_does_not_invent_acceptance(self): + old=fixture();new=copy.deepcopy(old) + new["claims"]["N"]={"grade":"LIVE_ROOT_THEOREM","controlling":True,"depends_on":[]} + self.check_blocked(old,new,"N") + + def test_changed_premise_cannot_leave_consumer_controlling(self): + self.check_blocked(*changed_pair()) + + def test_required_edge_deletion_does_not_erase_controlling_impact(self): + old=fixture();promote(old);new=copy.deepcopy(old) + new["claims"]["T"]["depends_on"]=[] + self.check_blocked(old,new) + + def test_grade_alone_still_exposes_controlling_use(self): + old,new=changed_pair() + old["claims"]["T"].pop("controlling");new["claims"]["T"].pop("controlling") + self.check_blocked(old,new) + + def test_false_flag_cannot_hide_controlling_grade(self): + old,new=changed_pair();new["claims"]["T"]["controlling"]=False + self.check_blocked(old,new) + + def test_status_note_cannot_hide_behind_other_status_field(self): + old=fixture();old["premises"]["P"]["status_frozen_v2_2"]="OPEN" + new=copy.deepcopy(old);new["premises"]["P"]["status_register_note"]="CONTROLLING" + self.check_blocked(old,new,"P") + + def test_spelling_normalization_cannot_hide_controlling_grade(self): + old=fixture();new=copy.deepcopy(old) + new["claims"]["T"]["grade"]=" live_root_theorem " + self.check_blocked(old,new) + + def test_review_metadata_is_not_automatic_promotion_authority(self): + old=fixture();new=copy.deepcopy(old);promote(new) + new["claims"]["T"]["review_records"]=[{"disposition":"ACCEPTED","independent":True}] + self.check_blocked(old,new) + + def test_explicit_consistent_demotion_permits_correction(self): + old,new=changed_pair();demote(new) + result=CGA.compare_claims_files(old,new) + self.assertIs(result.get("transition_ok"),True,result) + self.assertEqual(result.get("transition_errors"),[]) + self.assertIn("T",result["hold_proposals"]) + self.assertFalse(result["promotion_permission"]) + + def test_all_transitive_consumers_must_be_demoted(self): + old,new=changed_pair() + for g in (old,new): + g["claims"]["V"]={"grade":"LIVE_ROOT_THEOREM","controlling":True,"depends_on":["T"]} + demote(new) + self.check_blocked(old,new,"V") + demote(new,"V") + self.assertIs(CGA.compare_claims_files(old,new).get("transition_ok"),True) + + def test_unchanged_legacy_status_is_not_retroactive_transition(self): + old=fixture();promote(old);new=copy.deepcopy(old) + new["claims"]["U"]["statement"]="Unrelated documentation correction" + report=CGA.compare_claims_files(old,new) + self.assertIs(report.get("transition_ok"),True,report) + self.assertIn("T",report["hold_proposals"]) + self.assertFalse(report["promotion_permission"]) + + def test_noncontrolling_changed_source_still_produces_impact(self): + old=fixture();new=copy.deepcopy(old);new["premises"]["P"]["statement"]="Amended" + report=CGA.compare_claims_files(old,new) + self.assertIs(report.get("transition_ok"),True,report) + self.assertEqual(set(report["reverse_impact"]["impacted"]),{"P","T"}) + + def test_refuted_classification_is_not_rewritten_as_acceptance(self): + old=fixture();new=copy.deepcopy(old);new["premises"]["P"]["status_register_note"]="REFUTED" + report=CGA.compare_claims_files(old,new) + self.assertIs(report.get("transition_ok"),True,report) + self.assertEqual(report["reverse_impact"]["graph"]["nodes"]["P"]["classification"],"REFUTED") + + +class EnforcementCLITests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory(prefix="f1-status-test-") + self.addCleanup(self.temp.cleanup) + self.root=Path(self.temp.name)/"repo";self.root.mkdir() + self.git("init","-q");self.git("config","user.email","review@example.invalid") + self.git("config","user.name","Synthetic source fixture") + self.write("architecture/scientific_state/v1/ID_CROSSWALK.json",{"rows":[]}) + self.write("architecture/scientific_state/v1/AUTHORITY_MAP.json",{"authorities":{}}) + for name in ("proof.md","theorem.md","unrelated.md"): + (self.root/name).write_text("Synthetic initial source\n") + + def git(self,*args): + p=subprocess.run(["git","-C",str(self.root),*args],capture_output=True,text=True,timeout=20) + self.assertEqual(p.returncode,0,p.stderr) + return p.stdout.strip() + + def write(self,name,obj): + p=self.root/name;p.parent.mkdir(parents=True,exist_ok=True) + p.write_text(json.dumps(obj)+"\n") + + def commit(self,obj): + self.write("claims/graph.json",obj);self.git("add",".") + self.git("commit","--allow-empty","-qm","synthetic snapshot") + return self.git("rev-parse","HEAD") + + def cli(self,command,old,new,source_change=False): + before=self.commit(old) + if source_change:(self.root/"proof.md").write_text("Changed load-bearing source\n") + after=self.commit(new) + env=os.environ.copy();env["CLAIMS_GATE_BEFORE_REF"]=before;env["CLAIMS_GATE_AFTER_REF"]=after + argv=[sys.executable,*(["-O"] if sys.flags.optimize else []),"-B","-S", + str(ROOT/"tools/claims_gate_adapter.py"),command] + report_path=Path(self.temp.name)/"impact.json" + if command=="event-compare": + argv.extend(["--repo-root",str(self.root)]) + elif command=="compare-refs": + argv.extend(["--repo-root",str(self.root),"--before-ref",before,"--after-ref",after]) + else: + op=Path(self.temp.name)/"before.json";np=Path(self.temp.name)/"after.json" + op.write_text(json.dumps(old));np.write_text(json.dumps(new)) + argv.extend(["--before",str(op),"--after",str(np)]) + argv.extend(["--write-report",str(report_path)]) + p=subprocess.run(argv,env=env,capture_output=True,text=True,timeout=30) + self.assertTrue(p.stdout.strip(),p.stderr) + result=json.loads(p.stdout) + self.assertTrue(report_path.is_file(),p.stderr) + self.assertEqual(json.loads(report_path.read_text()),result) + self.assertFalse(result["promotion_permission"]) + return p.returncode,result + + def test_real_event_promotion_exits_nonzero_and_persists_hold(self): + old=fixture();old["premises"]["P"]["status_register_note"]="REFUTED" + new=copy.deepcopy(old);promote(new) + rc,r=self.cli("event-compare",old,new) + self.assertNotEqual(rc,0,r) + self.assertIs(r.get("transition_ok"),False) + self.assertIn("T",r["hold_proposals"]) + + def test_real_event_source_only_edit_refuses_controlling_consumer(self): + old=fixture();promote(old);new=copy.deepcopy(old) + rc,r=self.cli("event-compare",old,new,source_change=True) + self.assertNotEqual(rc,0,r) + self.assertIn("P",r["reverse_impact"]["source_byte_seeds"]) + + def test_real_event_source_change_and_demotion_succeeds(self): + old=fixture();promote(old);new=copy.deepcopy(old);demote(new) + rc,r=self.cli("event-compare",old,new,source_change=True) + self.assertEqual(rc,0,r) + self.assertIs(r.get("transition_ok"),True) + + def test_compare_refs_cannot_bypass_enforcement(self): + old=fixture();new=copy.deepcopy(old);promote(new) + rc,r=self.cli("compare-refs",old,new) + self.assertNotEqual(rc,0,r) + + def test_path_compare_cannot_bypass_enforcement(self): + old=fixture();new=copy.deepcopy(old);promote(new) + rc,r=self.cli("compare",old,new) + self.assertNotEqual(rc,0,r) + + def test_refuted_premise_edit_is_allowed_when_consumer_noncontrolling(self): + old=fixture();new=copy.deepcopy(old) + new["premises"]["P"]["status_register_note"]="REFUTED" + rc,r=self.cli("event-compare",old,new) + self.assertEqual(rc,0,r) + self.assertEqual(r["hold_proposals"]["T"]["refuted_required"],["P"]) + + +if __name__=="__main__":unittest.main() diff --git a/tests/test_claims_gate_f2_coverage.py b/tests/test_claims_gate_f2_coverage.py new file mode 100644 index 000000000..a6a5bd0e3 --- /dev/null +++ b/tests/test_claims_gate_f2_coverage.py @@ -0,0 +1,612 @@ +"""F2 source-coverage: controlling nodes must be byte-monitorable. + +OpenAI combined-tip re-review AMEND on 6e3f774. Scientific effect: NONE. +""" +from __future__ import annotations + +import copy +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "claims_gate_adapter", ROOT / "tools" / "claims_gate_adapter.py" +) +assert SPEC and SPEC.loader +CGA = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CGA) + + +def _fixture(): + return { + "as_of": "2026-09-25", + "premises": { + "P": {"status_register_note": "OPEN", "source": {"path": "proof.md"}} + }, + "claims": { + "T": { + "grade": "OPEN", + "controlling": False, + "depends_on": ["P"], + "source": {"path": "theorem.md"}, + }, + "U": {"grade": "OPEN", "source": {"path": "unrelated.md"}}, + }, + } + + +class F2CoverageTests(unittest.TestCase): + def _repo(self): + raw = tempfile.TemporaryDirectory(prefix="f2-cov-") + self.addCleanup(raw.cleanup) + repo = Path(raw.name) / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "t@example.invalid"], + cwd=repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "t"], + cwd=repo, + check=True, + capture_output=True, + ) + arch = repo / "architecture" / "scientific_state" / "v1" + arch.mkdir(parents=True) + (arch / "ID_CROSSWALK.json").write_text('{"rows":[]}\n', encoding="utf-8") + (arch / "AUTHORITY_MAP.json").write_text( + '{"authorities":{}}\n', encoding="utf-8" + ) + for name in ("proof.md", "theorem.md", "unrelated.md", "second.md"): + (repo / name).write_text(f"Synthetic {name} v1\n", encoding="utf-8") + return repo + + def _commit(self, repo: Path, claims: dict, label: str = "snap") -> str: + (repo / "claims").mkdir(exist_ok=True) + (repo / "claims" / "graph.json").write_text( + json.dumps(claims, indent=2) + "\n", encoding="utf-8" + ) + subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-qm", label], + cwd=repo, + check=True, + capture_output=True, + ) + return subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + def _event(self, repo: Path, before: str, after: str): + env = os.environ.copy() + env["CLAIMS_GATE_BEFORE_REF"] = before + env["CLAIMS_GATE_AFTER_REF"] = after + p = subprocess.run( + [ + sys.executable, + "-B", + "-S", + str(ROOT / "tools" / "claims_gate_adapter.py"), + "event-compare", + "--repo-root", + str(repo), + ], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + report = json.loads(p.stdout) + return p.returncode, report + + def test_tip_controlling_nodes_are_byte_monitorable(self): + claims = json.loads((ROOT / "claims" / "graph.json").read_text(encoding="utf-8")) + for nid in ("Q0-C101-QUALITATIVE-RATE", "D1-v2.2(1)"): + rec = claims["claims"][nid] + self.assertTrue(rec.get("source_bindings"), nid) + bound = CGA.bind_source_at_revision(ROOT, "HEAD", rec) + self.assertTrue( + CGA._source_binding_monitorable(bound), + f"{nid} kind={bound.get('kind')}", + ) + + def test_controlling_with_unresolved_prose_refused_at_event_cli(self): + repo = self._repo() + g = _fixture() + g["claims"]["T"].update( + grade="LIVE_ROOT_THEOREM", + controlling=True, + source="Some Prose Carrier.md with spaces", + ) + before = self._commit(repo, g, "before") + after = self._commit(repo, g, "after-same") + rc, report = self._event(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertFalse(report["transition_ok"]) + self.assertIn("T", report["unresolved_controlling_sources"]) + self.assertTrue( + any( + "UNRESOLVED_CONTROLLING_SOURCE" in (e.get("reasons") or []) + for e in report["transition_errors"] + if e.get("node") == "T" + ), + report["transition_errors"], + ) + + def test_controlling_bound_source_byte_change_refuses_until_demoted(self): + repo = self._repo() + g = _fixture() + g["claims"]["T"].update(grade="LIVE_ROOT_THEOREM", controlling=True) + before = self._commit(repo, g, "before") + (repo / "proof.md").write_text("Changed load-bearing lemma\n", encoding="utf-8") + after = self._commit(repo, copy.deepcopy(g), "byte-change") + rc, report = self._event(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertIn("T", report["controlling_impacted"]) + self.assertIn("P", report["reverse_impact"]["source_byte_seeds"]) + + demoted = copy.deepcopy(g) + demoted["claims"]["T"].update(grade="RETRACTED_TO_CANDIDATE", controlling=False) + (repo / "proof.md").write_text("Changed load-bearing lemma\n", encoding="utf-8") + # rebuild: before controlling, after demoted with same byte change + repo2 = self._repo() + before2 = self._commit(repo2, g, "before") + (repo2 / "proof.md").write_text("Changed load-bearing lemma\n", encoding="utf-8") + after2 = self._commit(repo2, demoted, "demoted") + rc2, report2 = self._event(repo2, before2, after2) + self.assertEqual(rc2, 0, report2) + self.assertTrue(report2["transition_ok"]) + + def test_multi_binding_second_source_change_seeds_impact(self): + repo = self._repo() + g = _fixture() + g["premises"]["P"].pop("source", None) + g["premises"]["P"]["source_bindings"] = [ + {"repo": CGA.CURRENT_REPO, "path": "proof.md"}, + {"repo": CGA.CURRENT_REPO, "path": "second.md"}, + ] + g["claims"]["T"].update(grade="LIVE_ROOT_THEOREM", controlling=True) + before = self._commit(repo, g, "before") + (repo / "second.md").write_text("Only second carrier changed\n", encoding="utf-8") + after = self._commit(repo, copy.deepcopy(g), "second-only") + rc, report = self._event(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertIn("P", report["reverse_impact"]["source_byte_seeds"]) + self.assertIn("T", report["controlling_impacted"]) + kinds = {b.get("kind") for b in report["new_sources"]["P"]["bindings"]} + self.assertIn("blob", kinds) + + def test_cross_repo_binding_not_silently_bound_locally(self): + bound = CGA.bind_source_at_revision( + ROOT, + "HEAD", + { + "source_bindings": [ + { + "repo": "other-org/other-repo", + "path": "claims/graph.json", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + } + ] + }, + ) + self.assertEqual(bound["kind"], "unsupported_cross_repo") + self.assertFalse(CGA._source_binding_monitorable(bound)) + cross = bound["bindings"][0] + self.assertEqual(cross["declared_repo"], "other-org/other-repo") + self.assertEqual(cross["declared_path"], "claims/graph.json") + self.assertEqual( + cross["declared_commit"], "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + self.assertEqual( + cross["declared_hash"], + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + self.assertNotIn("sha256", cross) # no local byte invention + + def test_tip_scientific_object_hashes_match_expected(self): + claims = json.loads((ROOT / "claims" / "graph.json").read_text(encoding="utf-8")) + q0 = CGA.bind_source_at_revision( + ROOT, "HEAD", claims["claims"]["Q0-C101-QUALITATIVE-RATE"] + ) + self.assertTrue(CGA._source_binding_monitorable(q0), q0) + sci = [ + b + for b in q0["bindings"] + if b.get("role") == "scientific_object" and b.get("kind") == "blob" + ] + self.assertEqual(len(sci), 1) + self.assertEqual( + sci[0]["object_sha256"], + "8c2ded652973f0e6232e76854e2505d8ed532afef1815e0ebc88966dab0706bc", + ) + self.assertEqual(sci[0]["object_bytes"], 3957) + self.assertTrue(sci[0]["object_hash_ok"]) + info = [b for b in q0["bindings"] if b.get("role") == "informational_carrier"] + self.assertEqual(len(info), 1) + self.assertFalse(CGA._binding_is_scientific_monitorable(info[0])) + + d1 = CGA.bind_source_at_revision( + ROOT, "HEAD", claims["claims"]["D1-v2.2(1)"] + ) + self.assertTrue(CGA._source_binding_monitorable(d1), d1) + d1b = d1["bindings"][0] + self.assertEqual(d1b["extraction_rule"], "frozen_body") + self.assertEqual( + d1b["object_sha256"], + "490ad6b2f14176fe8cf5af363fb94dc73a8bc5523f608e5ab2a42ff749b235f6", + ) + self.assertEqual(d1b["object_bytes"], 18311) + self.assertTrue(d1b["object_hash_ok"]) + self.assertEqual( + d1b["carrier_sha256"], + "7ca114f0b38680d8bb987c097de10f3faf884ae3b05c3ca47215af5df081c174", + ) + + def _d1_wrapper(self, frozen_body: str, wrapper_note: str = "wrapper v1") -> str: + return ( + f"# D1 wrapper\n{wrapper_note}\n" + f"BEGIN_FROZEN_BODY\n{frozen_body.rstrip(chr(10))}\nEND_FROZEN_BODY\n" + f"trailer {wrapper_note}\n" + ) + + def test_e_altered_extracted_body_refuses_controlling(self): + repo = self._repo() + body_v1 = "theorem statement v1\n" + body_v2 = "theorem statement CHANGED\n" + (repo / "d1.md").write_text(self._d1_wrapper(body_v1), encoding="utf-8") + expected = CGA._sha256_bytes( + CGA.extract_scientific_bytes( + (repo / "d1.md").read_bytes(), "frozen_body" + ) + ) + g = _fixture() + g["claims"]["T"].update( + grade="LIVE_ROOT_THEOREM", + controlling=True, + source_bindings=[ + { + "repo": CGA.CURRENT_REPO, + "path": "d1.md", + "role": "scientific_object", + "extraction_rule": "frozen_body", + "expected_sha256": expected, + "mirror_freshness": "external_sync_obligation", + } + ], + ) + g["claims"]["T"].pop("source", None) + before = self._commit(repo, g, "before") + (repo / "d1.md").write_text(self._d1_wrapper(body_v2), encoding="utf-8") + after = self._commit(repo, copy.deepcopy(g), "body-change") + rc, report = self._event(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertIn("T", report["controlling_impacted"]) + # After state also has object_hash_mismatch vs declared expected. + after_bound = report["new_sources"]["T"] + self.assertFalse(CGA._source_binding_monitorable(after_bound), after_bound) + + def test_e_wrapper_only_change_does_not_seed_scientific_impact(self): + repo = self._repo() + body = "stable frozen theorem\n" + (repo / "d1.md").write_text( + self._d1_wrapper(body, "wrapper v1"), encoding="utf-8" + ) + expected = CGA._sha256_bytes( + CGA.extract_scientific_bytes( + (repo / "d1.md").read_bytes(), "frozen_body" + ) + ) + g = _fixture() + g["claims"]["T"].update( + grade="LIVE_ROOT_THEOREM", + controlling=True, + source_bindings=[ + { + "repo": CGA.CURRENT_REPO, + "path": "d1.md", + "role": "scientific_object", + "extraction_rule": "frozen_body", + "expected_sha256": expected, + "mirror_freshness": "external_sync_obligation", + } + ], + ) + g["claims"]["T"].pop("source", None) + before = self._commit(repo, g, "before") + (repo / "d1.md").write_text( + self._d1_wrapper(body, "wrapper ONLY changed"), encoding="utf-8" + ) + after = self._commit(repo, copy.deepcopy(g), "wrapper-only") + rc, report = self._event(repo, before, after) + self.assertEqual(rc, 0, report) + self.assertTrue(report["transition_ok"], report) + self.assertNotIn("T", report["reverse_impact"].get("source_byte_seeds", [])) + self.assertEqual( + report["old_sources"]["T"]["coverage_sha256"], + report["new_sources"]["T"]["coverage_sha256"], + ) + + def test_e_informational_master_edit_does_not_seed_impact(self): + repo = self._repo() + (repo / "theorem.md").write_text("exact theorem object\n", encoding="utf-8") + (repo / "master.md").write_text("master carrier v1\n", encoding="utf-8") + expected = CGA._sha256_bytes(b"exact theorem object\n") + g = _fixture() + g["claims"]["T"].update( + grade="LIVE_ROOT_THEOREM", + controlling=True, + source_bindings=[ + { + "repo": CGA.CURRENT_REPO, + "path": "theorem.md", + "role": "scientific_object", + "extraction_rule": "whole_file", + "expected_sha256": expected, + "mirror_freshness": "external_sync_obligation", + }, + { + "repo": CGA.CURRENT_REPO, + "path": "master.md", + "role": "informational_carrier", + "mirror_freshness": "external_sync_obligation", + }, + ], + ) + g["claims"]["T"].pop("source", None) + before = self._commit(repo, g, "before") + (repo / "master.md").write_text("master carrier UNRELATED edit\n", encoding="utf-8") + after = self._commit(repo, copy.deepcopy(g), "master-only") + rc, report = self._event(repo, before, after) + self.assertEqual(rc, 0, report) + self.assertTrue(report["transition_ok"], report) + self.assertNotIn("T", report["reverse_impact"].get("source_byte_seeds", [])) + + def test_e_stale_or_unverified_freshness_refuses_controlling(self): + repo = self._repo() + (repo / "theorem.md").write_text("exact theorem object\n", encoding="utf-8") + expected = CGA._sha256_bytes(b"exact theorem object\n") + for freshness in ("unverified", "stale", "absent"): + with self.subTest(freshness=freshness): + g = _fixture() + g["claims"]["T"].update( + grade="LIVE_ROOT_THEOREM", + controlling=True, + source_bindings=[ + { + "repo": CGA.CURRENT_REPO, + "path": "theorem.md", + "role": "scientific_object", + "extraction_rule": "whole_file", + "expected_sha256": expected, + "mirror_freshness": freshness, + } + ], + ) + g["claims"]["T"].pop("source", None) + before = self._commit(repo, g, f"before-{freshness}") + after = self._commit(repo, copy.deepcopy(g), f"after-{freshness}") + rc, report = self._event(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertFalse(report["transition_ok"]) + self.assertIn("T", report["unresolved_controlling_sources"]) + + def test_e_same_carrier_precision_upgrade_is_coverage_repair(self): + """Legacy path binding → frozen_body+expected on unchanged carrier is OK.""" + repo = self._repo() + body = "stable frozen theorem\n" + (repo / "d1.md").write_text(self._d1_wrapper(body), encoding="utf-8") + expected = CGA._sha256_bytes( + CGA.extract_scientific_bytes( + (repo / "d1.md").read_bytes(), "frozen_body" + ) + ) + before_g = _fixture() + before_g["claims"]["T"].update( + grade="LIVE_ROOT_THEOREM", + controlling=True, + source_bindings=[ + {"repo": CGA.CURRENT_REPO, "path": "d1.md", "role": "assembly_mirror"} + ], + ) + before_g["claims"]["T"].pop("source", None) + before = self._commit(repo, before_g, "legacy-bind") + after_g = copy.deepcopy(before_g) + after_g["claims"]["T"]["source_bindings"] = [ + { + "repo": CGA.CURRENT_REPO, + "path": "d1.md", + "role": "scientific_object", + "extraction_rule": "frozen_body", + "expected_sha256": expected, + "mirror_freshness": "external_sync_obligation", + } + ] + after = self._commit(repo, after_g, "precision-upgrade") + rc, report = self._event(repo, before, after) + self.assertEqual(rc, 0, report) + self.assertTrue(report["transition_ok"], report) + self.assertIn("T", report.get("coverage_repairs") or []) + # Actual body drift after precision still refuses. + (repo / "d1.md").write_text( + self._d1_wrapper("CHANGED frozen theorem\n"), encoding="utf-8" + ) + after2 = self._commit(repo, copy.deepcopy(after_g), "body-drift") + rc2, report2 = self._event(repo, after, after2) + self.assertNotEqual(rc2, 0, report2) + self.assertIn("T", report2["controlling_impacted"]) + + def test_e6_precision_upgrade_with_semantic_change_still_refuses(self): + """E6: same-carrier precision upgrade must not mask statement/semantic drift.""" + repo = self._repo() + body = "stable frozen theorem\n" + (repo / "d1.md").write_text(self._d1_wrapper(body), encoding="utf-8") + expected = CGA._sha256_bytes( + CGA.extract_scientific_bytes( + (repo / "d1.md").read_bytes(), "frozen_body" + ) + ) + before_g = _fixture() + before_g["claims"]["T"].update( + grade="LIVE_ROOT_THEOREM", + controlling=True, + statement="original controlling statement", + source_bindings=[ + {"repo": CGA.CURRENT_REPO, "path": "d1.md", "role": "assembly_mirror"} + ], + ) + before_g["claims"]["T"].pop("source", None) + before = self._commit(repo, before_g, "legacy-bind") + after_g = copy.deepcopy(before_g) + after_g["claims"]["T"]["statement"] = "CHANGED controlling statement" + after_g["claims"]["T"]["source_bindings"] = [ + { + "repo": CGA.CURRENT_REPO, + "path": "d1.md", + "role": "scientific_object", + "extraction_rule": "frozen_body", + "expected_sha256": expected, + "mirror_freshness": "external_sync_obligation", + } + ] + after = self._commit(repo, after_g, "precision-plus-statement") + rc, report = self._event(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertFalse(report["transition_ok"], report) + self.assertIn("T", report["controlling_impacted"]) + self.assertNotIn("T", report.get("coverage_repairs") or []) + + def test_e6_cc6_successor_preserves_q0_binding_order(self): + """cc6a578-order Q0 bindings must stay theorem-first; no false Q0 impact. + + OpenAI root-cause on 36202702251: reversing scientific/informational + list order changes semantic_digest + coverage_sha256. Tip successors + must keep exact predecessor order (theorem extract, then master). + """ + tip = json.loads((ROOT / "claims" / "graph.json").read_text(encoding="utf-8")) + q0 = tip["claims"]["Q0-C101-QUALITATIVE-RATE"] + bindings = q0["source_bindings"] + self.assertGreaterEqual(len(bindings), 2) + self.assertEqual(bindings[0].get("role"), "scientific_object") + self.assertIn("Q0_C101_QUALITATIVE_RATE_THEOREM.md", bindings[0]["path"]) + self.assertEqual(bindings[1].get("role"), "informational_carrier") + self.assertIn("Q0_MASTER.md", bindings[1]["path"]) + + # Digests/coverage stable under identical tip record (no-op successor). + g1 = CGA.claims_to_gate_graph(tip) + g2 = CGA.claims_to_gate_graph(copy.deepcopy(tip)) + nid = "Q0-C101-QUALITATIVE-RATE" + self.assertEqual( + g1["nodes"][nid]["semantic_digest"], g2["nodes"][nid]["semantic_digest"] + ) + b1 = CGA.bind_source_at_revision(ROOT, "HEAD", q0) + b2 = CGA.bind_source_at_revision(ROOT, "HEAD", copy.deepcopy(q0)) + self.assertEqual(b1["coverage_sha256"], b2["coverage_sha256"]) + + # Reversing order alone must change semantic_digest (E6 not weakened). + reversed_rec = copy.deepcopy(q0) + reversed_rec["source_bindings"] = list(reversed(bindings)) + g_rev = CGA.claims_to_gate_graph( + { + **{k: v for k, v in tip.items() if k != "claims"}, + "claims": {**tip["claims"], nid: reversed_rec}, + } + ) + self.assertNotEqual( + g1["nodes"][nid]["semantic_digest"], + g_rev["nodes"][nid]["semantic_digest"], + ) + + # Event: same tip claims twice → no Q0 controlling_impacted. + repo = self._repo() + # Minimal controlling graph using tip Q0 binding shape (theorem first). + (repo / "theorem.md").write_text("exact theorem object\n", encoding="utf-8") + (repo / "master.md").write_text("master carrier v1\n", encoding="utf-8") + expected = CGA._sha256_bytes(b"exact theorem object\n") + g = _fixture() + g["claims"]["T"].update( + grade="LIVE_ROOT_THEOREM", + controlling=True, + statement="stable statement", + source_bindings=[ + { + "repo": CGA.CURRENT_REPO, + "path": "theorem.md", + "role": "scientific_object", + "extraction_rule": "whole_file", + "expected_sha256": expected, + "mirror_freshness": "external_sync_obligation", + }, + { + "repo": CGA.CURRENT_REPO, + "path": "master.md", + "role": "informational_carrier", + "mirror_freshness": "external_sync_obligation", + }, + ], + ) + g["claims"]["T"].pop("source", None) + before = self._commit(repo, g, "cc6-order") + after = self._commit(repo, copy.deepcopy(g), "successor-same-order") + rc, report = self._event(repo, before, after) + self.assertEqual(rc, 0, report) + self.assertTrue(report["transition_ok"], report) + self.assertNotIn("T", report.get("controlling_impacted") or []) + self.assertEqual( + report["old_sources"]["T"]["coverage_sha256"], + report["new_sources"]["T"]["coverage_sha256"], + ) + self.assertEqual( + CGA.claims_to_gate_graph(g)["nodes"]["T"]["semantic_digest"], + CGA.claims_to_gate_graph(copy.deepcopy(g))["nodes"]["T"]["semantic_digest"], + ) + + def test_e6_unresolved_to_monitorable_with_statement_change_refuses(self): + """Attaching monitorable bindings must not mask a simultaneous statement edit.""" + repo = self._repo() + (repo / "theorem.md").write_text("exact theorem object\n", encoding="utf-8") + expected = CGA._sha256_bytes(b"exact theorem object\n") + before_g = _fixture() + before_g["claims"]["T"].update( + grade="LIVE_ROOT_THEOREM", + controlling=True, + statement="original statement", + source="Prose Only Carrier With Spaces.md", + ) + before = self._commit(repo, before_g, "unresolved") + after_g = copy.deepcopy(before_g) + after_g["claims"]["T"]["statement"] = "CHANGED statement" + after_g["claims"]["T"].pop("source", None) + after_g["claims"]["T"]["source_bindings"] = [ + { + "repo": CGA.CURRENT_REPO, + "path": "theorem.md", + "role": "scientific_object", + "extraction_rule": "whole_file", + "expected_sha256": expected, + "mirror_freshness": "external_sync_obligation", + } + ] + after = self._commit(repo, after_g, "bind-plus-statement") + rc, report = self._event(repo, before, after) + self.assertNotEqual(rc, 0, report) + self.assertIn("T", report["controlling_impacted"]) + self.assertNotIn("T", report.get("coverage_repairs") or []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scientific_state.py b/tests/test_scientific_state.py new file mode 100644 index 000000000..54f31ac43 --- /dev/null +++ b/tests/test_scientific_state.py @@ -0,0 +1,206 @@ +"""Negative controls for architecture/scientific_state (#95 schema pilot). + +Scientific effect: NONE. These tests only refuse malformed architecture payloads. +They do not promote mathematics or alter claims/Math- authorities. +""" +from __future__ import annotations + +import copy +import importlib.util +import json +import shutil +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "scientific_state_check", ROOT / "tools" / "scientific_state_check.py" +) +assert SPEC and SPEC.loader +SSC = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SSC) + + +def _clone_pkg(tmp: Path) -> Path: + src = ROOT / "architecture" / "scientific_state" + dst = tmp / "architecture" / "scientific_state" + shutil.copytree(src, dst) + claims_src = ROOT / "claims" / "graph.json" + claims_dst = tmp / "claims" + claims_dst.mkdir(parents=True) + shutil.copy2(claims_src, claims_dst / "graph.json") + return tmp + + +def _write(path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +class ScientificStatePilotTests(unittest.TestCase): + def test_repository_architecture_package_passes(self): + report = SSC.audit(ROOT) + self.assertEqual(report["problems"], [], report) + self.assertEqual(report["scientific_effect"], "NONE") + self.assertGreaterEqual(report["crosswalk_rows"], 8) + + def test_unknown_main_id_fails_closed(self): + with tempfile.TemporaryDirectory() as raw: + root = _clone_pkg(Path(raw)) + path = root / "architecture" / "scientific_state" / "v1" / "ID_CROSSWALK.json" + doc = json.loads(path.read_text(encoding="utf-8")) + doc["rows"].append( + { + "row_id": "xwalk.invented", + "main_claim_or_premise_id": "NOT-A-REAL-CLAIM-ID", + "main_bucket": "claims", + "math_gate_id": "math.invented", + "authority": "claims_firewall", + "notes": "should fail", + } + ) + _write(path, doc) + report = SSC.audit(root) + self.assertTrue( + any("unknown claim" in p for p in report["problems"]), + report["problems"], + ) + + def test_smuggled_status_in_crosswalk_fails_closed(self): + with tempfile.TemporaryDirectory() as raw: + root = _clone_pkg(Path(raw)) + path = root / "architecture" / "scientific_state" / "v1" / "ID_CROSSWALK.json" + doc = json.loads(path.read_text(encoding="utf-8")) + doc["rows"][0]["status"] = "PROVED_REVIEWED" + _write(path, doc) + report = SSC.audit(root) + self.assertTrue( + any("smuggles owned field 'status'" in p for p in report["problems"]), + report["problems"], + ) + + def test_smuggled_controlling_in_crosswalk_fails_closed(self): + with tempfile.TemporaryDirectory() as raw: + root = _clone_pkg(Path(raw)) + path = root / "architecture" / "scientific_state" / "v1" / "ID_CROSSWALK.json" + doc = json.loads(path.read_text(encoding="utf-8")) + doc["rows"][0]["controlling"] = True + _write(path, doc) + report = SSC.audit(root) + self.assertTrue( + any("controlling" in p for p in report["problems"]), + report["problems"], + ) + + def test_missing_authority_owner_fails_closed(self): + with tempfile.TemporaryDirectory() as raw: + root = _clone_pkg(Path(raw)) + path = root / "architecture" / "scientific_state" / "v1" / "AUTHORITY_MAP.json" + doc = json.loads(path.read_text(encoding="utf-8")) + del doc["authorities"]["math_downstream_gate"] + _write(path, doc) + report = SSC.audit(root) + self.assertTrue( + any("math_downstream_gate" in p for p in report["problems"]), + report["problems"], + ) + + def test_verification_level_as_acceptance_fails_closed(self): + with tempfile.TemporaryDirectory() as raw: + root = _clone_pkg(Path(raw)) + path = root / "architecture" / "scientific_state" / "v1" / "SCHEMA.json" + doc = json.loads(path.read_text(encoding="utf-8")) + doc["node_fields"]["verification_level"]["role"] = "acceptance" + _write(path, doc) + report = SSC.audit(root) + self.assertTrue( + any("evidence_metadata" in p for p in report["problems"]), + report["problems"], + ) + + def test_unknown_authority_on_row_fails_closed(self): + with tempfile.TemporaryDirectory() as raw: + root = _clone_pkg(Path(raw)) + path = root / "architecture" / "scientific_state" / "v1" / "ID_CROSSWALK.json" + doc = json.loads(path.read_text(encoding="utf-8")) + doc["rows"][0]["authority"] = "invented_second_status_db" + _write(path, doc) + report = SSC.audit(root) + self.assertTrue( + any("unknown authority" in p for p in report["problems"]), + report["problems"], + ) + + def test_self_contradicting_never_writes_fails_closed(self): + with tempfile.TemporaryDirectory() as raw: + root = _clone_pkg(Path(raw)) + path = root / "architecture" / "scientific_state" / "v1" / "AUTHORITY_MAP.json" + doc = json.loads(path.read_text(encoding="utf-8")) + doc["this_package"]["never_writes"] = ["status"] # incomplete + _write(path, doc) + report = SSC.audit(root) + self.assertTrue( + any("never_writes" in p for p in report["problems"]), + report["problems"], + ) + + def test_scientific_effect_must_remain_none(self): + with tempfile.TemporaryDirectory() as raw: + root = _clone_pkg(Path(raw)) + path = root / "architecture" / "scientific_state" / "v1" / "SCHEMA.json" + doc = json.loads(path.read_text(encoding="utf-8")) + doc["scientific_effect"] = "PROMOTES" + _write(path, doc) + report = SSC.audit(root) + self.assertTrue( + any("scientific_effect must be 'NONE'" in p for p in report["problems"]), + report["problems"], + ) + + def test_math_gate_authority_points_at_merged_pr13_lineage(self): + auth = json.loads( + (ROOT / "architecture" / "scientific_state" / "v1" / "AUTHORITY_MAP.json").read_text( + encoding="utf-8" + ) + ) + gate = auth["authorities"]["math_downstream_gate"] + self.assertIn("/pull/13", gate["surface"]) + self.assertTrue( + str(gate.get("merged_tip", "")).startswith("baca69c394ab"), + gate.get("merged_tip"), + ) + notes = gate.get("notes", "").lower() + self.assertIn("thin", notes) + self.assertIn("not become a competing", notes) + # Stale sole-PR8 surface must not be the current surface. + self.assertNotEqual(gate["surface"], "https://github.com/d6g8k5htny-coder/Math-/pull/8") + this_pkg = auth["this_package"] + self.assertIn("claims_gate_adapter_projection", this_pkg["owns"]) + self.assertIn("semantic_digest_derivation", this_pkg["owns"]) + self.assertIn("claims_gate_adapter.py", this_pkg.get("adapter", "")) + self.assertIn("not a competing status engine", this_pkg["notes"].lower()) + + def test_schema_v1_1_declares_orthogonal_axes(self): + schema = json.loads( + (ROOT / "architecture" / "scientific_state" / "v1" / "SCHEMA.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(str(schema["schema_version"]), "1.1") + axes = schema["orthogonal_axes"] + for key in ( + "semantic_digest", + "evidence_digest", + "verification_level", + "scientific_status", + ): + self.assertIn(key, axes) + self.assertIn("semantic_digest", schema["node_fields"]) + self.assertEqual( + schema["node_fields"]["semantic_digest"]["role"], "derived_content_address" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_semantic_digest.py b/tests/test_semantic_digest.py new file mode 100644 index 000000000..d20737691 --- /dev/null +++ b/tests/test_semantic_digest.py @@ -0,0 +1,107 @@ +"""Tests for #95 v1.1 semantic_digest / evidence_digest (scientific effect NONE).""" +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import json +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _load(name: str, rel: str): + spec = importlib.util.spec_from_file_location(name, ROOT / rel) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +SD = _load("semantic_digest", "tools/semantic_digest.py") +CGA = _load("claims_gate_adapter", "tools/claims_gate_adapter.py") + + +class SemanticDigestTests(unittest.TestCase): + def test_statement_change_changes_semantic_digest(self): + claims = json.loads((ROOT / "claims" / "graph.json").read_text(encoding="utf-8")) + nid = "OBL-H5-JETMOD" + record = claims["premises"][nid] + before = SD.semantic_digest(nid, record) + mutated = copy.deepcopy(record) + mutated["statement"] = (mutated.get("statement") or "") + " [digest drift]" + after = SD.semantic_digest(nid, mutated) + self.assertNotEqual(before, after) + self.assertEqual(len(before), 64) + int(before, 16) # hex + + def test_manual_fingerprint_alone_is_not_sole_detector_in_adapter(self): + claims = json.loads((ROOT / "claims" / "graph.json").read_text(encoding="utf-8")) + old = CGA.claims_to_gate_graph(claims) + new = copy.deepcopy(old) + # Leave semantic_digest + source_snapshot identical; only touch a + # provenance-style fingerprint. Must NOT seed impact by itself. + new["nodes"]["OBL-H5-JETMOD"]["fingerprint"] = "manual-only-drift" + impact = CGA.reverse_impact_between(old, new) + self.assertNotIn("OBL-H5-JETMOD", impact["changed_nodes"]) + self.assertNotIn("OBL-H5-JETMOD", impact["impacted"]) + + def test_migration_crosswalks_legacy_fingerprint_without_rewriting_source(self): + claims_path = ROOT / "claims" / "graph.json" + before_bytes = claims_path.read_bytes() + claims = json.loads(before_bytes.decode("utf-8")) + nid = "D1-v2.2(2)" + record = claims["claims"][nid] + migrated = SD.migrate_legacy_fingerprint( + nid, record, legacy_fingerprint="legacy-manual-fp" + ) + self.assertEqual(migrated["legacy_fingerprint"], "legacy-manual-fp") + self.assertIs(migrated["legacy_fingerprint_is_sole_detector"], False) + self.assertEqual(migrated["semantic_digest"], SD.semantic_digest(nid, record)) + self.assertEqual(migrated["scientific_effect"], "NONE") + # Source claims file must remain byte-identical (crosswalk, not rewrite). + self.assertEqual(claims_path.read_bytes(), before_bytes) + + def test_duplicate_typed_edge_fails_closed(self): + with self.assertRaises(SD.DigestError) as ctx: + SD.semantic_digest( + "x", + {"depends_on": ["A", "A"], "statement": "s"}, + ) + self.assertIn("duplicate", str(ctx.exception)) + + def test_required_non_boolean_in_typed_edge_fails_closed(self): + with self.assertRaises(SD.DigestError) as ctx: + SD.normalize_typed_edge( + {"target_id": "A", "required": 0}, default_relation="depends_on" + ) + self.assertIn("strict boolean", str(ctx.exception)) + + def test_evidence_digest_orthogonal_to_semantic(self): + record = { + "statement": "same statement", + "depends_on": ["P"], + "evidence": [{"repo": "main", "path": "a.py", "commit": "abc"}], + } + other = copy.deepcopy(record) + other["evidence"] = [{"repo": "main", "path": "b.py", "commit": "abc"}] + self.assertEqual( + SD.semantic_digest("n", record), + SD.semantic_digest("n", other), + ) + self.assertNotEqual(SD.evidence_digest(record), SD.evidence_digest(other)) + + def test_digest_is_sha256_of_canonical_payload(self): + record = {"statement": "s", "depends_on": ["P"], "domain": "d"} + payload = SD.canonical_semantic_payload("n", record) + expected = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + ).hexdigest() + self.assertEqual(SD.semantic_digest("n", record), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/claims_gate_adapter.py b/tools/claims_gate_adapter.py new file mode 100644 index 000000000..8a30241ca --- /dev/null +++ b/tools/claims_gate_adapter.py @@ -0,0 +1,2064 @@ +#!/usr/bin/env python3 +"""Thin hardening adapter: claims/graph.json → #90 gate semantics (D7). + +Semantic reference: Math- PR13 (required REFUTED blocks; reverse impact over +UNION(old,new) edges; REQUIRED_SATISFIED = {PROVED_REVIEWED} only). This module +does **not** vendor Math- code and never grants promotion permission. + +Outputs are HOLD / REVALIDATION proposals only. Scientific effect: NONE. +""" +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import json +import re +import subprocess +import sys +from pathlib import Path, PurePosixPath +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +CLAIMS_PATH = ROOT / "claims" / "graph.json" +CROSSWALK_PATH = ROOT / "architecture" / "scientific_state" / "v1" / "ID_CROSSWALK.json" +AUTHORITY_PATH = ROOT / "architecture" / "scientific_state" / "v1" / "AUTHORITY_MAP.json" + +_DIGEST_SPEC = importlib.util.spec_from_file_location( + "semantic_digest", ROOT / "tools" / "semantic_digest.py" +) +assert _DIGEST_SPEC and _DIGEST_SPEC.loader +_SD = importlib.util.module_from_spec(_DIGEST_SPEC) +_DIGEST_SPEC.loader.exec_module(_SD) + +# #90 clarified: disposition vocabulary is broader than premise satisfaction. +REQUIRED_SATISFIED = frozenset({"PROVED_REVIEWED"}) +# SUPERSEDED_NONBLOCKING is terminal disposition but NOT premise satisfaction +# unless the reviewed edge was actually replaced/removed (handled by absence +# from the new graph's required edges, not by treating it as satisfied here). + +REFUTED_CLASSIFICATIONS = frozenset({"REFUTED", "REFUTED_AS_WRITTEN"}) +BLOCKED_CLASSIFICATIONS = frozenset({"BLOCKED_ABSENT"}) + + +class AdapterError(ValueError): + """Fail-closed adapter refusal.""" + + +def map_classification(record: dict[str, Any], *, bucket: str) -> str: + """Map claims/premise vocabulary onto gate classifications. + + Never invents PROVED_REVIEWED from firewall grades alone — transcribed + CERTIFIED_RUNG / LIVE_ROOT_THEOREM remain author-side for integrity purposes + until an external authority independently grants PROVED_REVIEWED. + """ + if bucket == "premises": + status = str( + record.get("status_frozen_v2_2") + or record.get("status_register_note") + or "OPEN" + ).upper() + if status in REFUTED_CLASSIFICATIONS or status == "REFUTED": + return "REFUTED" + if status in {"OPEN", "NOT_CLOSED", "RESTATED", "REFINEMENT", "NAMED_HYPOTHESIS"}: + return "OPEN_ACTIVE" + if status == "CLOSED": + # Closed display is not PROVED_REVIEWED for #90 premise satisfaction. + return "AUTHOR_SIDE_CANDIDATE" + return "OPEN_ACTIVE" + + grade = str(record.get("grade") or "OPEN") + if grade in REFUTED_CLASSIFICATIONS: + return "REFUTED" + if grade == "OPEN": + return "OPEN_ACTIVE" + if grade == "RETRACTED_TO_CANDIDATE": + return "AUTHOR_SIDE_CANDIDATE" + # All other firewall grades are non-PROVED_REVIEWED for this adapter. + return "AUTHOR_SIDE_CANDIDATE" + + +def _source_snapshot(record: dict[str, Any]) -> str: + """Complete canonical JSON of the source record (supplementary detector).""" + if not isinstance(record, dict): + raise AdapterError("source snapshot requires an object record") + return json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _digests_for(nid: str, record: dict[str, Any]) -> tuple[str, str]: + try: + return _SD.semantic_digest(nid, record), _SD.evidence_digest(record) + except _SD.DigestError as exc: + raise AdapterError(str(exc)) from exc + + +def _semantic_digest_core(nid: str, record: dict[str, Any]) -> str: + """semantic_digest with explicit source_bindings list removed. + + Scalar source/canon_source remain. Used so unresolved→monitorable binding + migration (adding path bindings) is not treated as an independent statement + change under E6, while statement/edge/grade drift still refuses. + """ + normalized = copy.deepcopy(record) + normalized.pop("source_bindings", None) + try: + return _SD.semantic_digest(nid, normalized) + except _SD.DigestError as exc: + raise AdapterError(str(exc)) from exc + + +def _fingerprint(record: dict[str, Any]) -> str: + """Deprecated alias: full-record snapshot (not the sole change detector).""" + return _source_snapshot(record) + + +def _require_strict_bool(value: Any, *, field: str) -> bool: + """Fail closed unless `value` is a Python bool (reject 0/1/str/null).""" + if type(value) is not bool: + raise AdapterError( + f"{field} must be a strict boolean, got {type(value).__name__}: {value!r}" + ) + return value + + +def _edge_key(edge: dict[str, Any]) -> tuple[Any, ...]: + return (edge["from"], edge["to"], edge["relation"], edge["required"]) + + +def _edge(frm: str, to: str, relation: str, *, required: bool = True) -> dict[str, Any]: + _require_strict_bool(required, field="required") + return { + "from": frm, + "to": to, + "required": required, + "relation": relation, + } + + +def _require_as_of(claims: dict[str, Any]) -> str: + """Fail closed unless as_of is a nonempty string (reject null/''/False/0).""" + if "as_of" not in claims: + raise AdapterError("stale or incomplete claims document: missing as_of") + as_of = claims["as_of"] + if type(as_of) is not str or not as_of.strip(): + raise AdapterError( + f"as_of must be a nonempty string, got {type(as_of).__name__}: {as_of!r}" + ) + return as_of + + +def _dependency_container( + record: dict[str, Any], field: str, *, node_id: str +) -> list[Any]: + """Return depends_on / sub_obligations list; reject False/0/''/{}/null. + + Valid omission of the key → empty list. Genuine empty array → empty list. + """ + if field not in record: + return [] + value = record[field] + if type(value) is not list: + raise AdapterError( + f"{node_id}.{field} must be a list, got {type(value).__name__}: {value!r}" + ) + return value + + +def claims_to_gate_graph( + claims: dict[str, Any], + *, + crosswalk: dict[str, Any] | None = None, + authority_map: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Project claims/graph.json into a gate-shaped working graph. + + Includes depends_on and sub_obligations as required typed edges. + Fail-closed on unknown IDs, cycles, malformed records, ambiguous owners, + and missing/invalid as_of / schema identity. + """ + if not isinstance(claims, dict): + raise AdapterError("malformed claims document") + as_of = _require_as_of(claims) + premises = claims.get("premises") + claim_nodes = claims.get("claims") + if not isinstance(premises, dict) or not isinstance(claim_nodes, dict): + raise AdapterError("malformed claims: premises/claims must be objects") + + # Ambiguous owner: crosswalk rows must cite known authorities when present. + if crosswalk is not None and authority_map is not None: + known = set(authority_map.get("authorities") or {}) + this_pkg = authority_map.get("this_package") or {} + if isinstance(this_pkg, dict) and this_pkg.get("id"): + known.add(this_pkg["id"]) + owners_for_main: dict[str, set[str]] = {} + for row in crosswalk.get("rows") or []: + if not isinstance(row, dict): + raise AdapterError("malformed crosswalk row") + auth = row.get("authority") + if auth not in known: + raise AdapterError(f"unknown crosswalk authority: {auth!r}") + main_id = row.get("main_claim_or_premise_id") + if main_id: + owners_for_main.setdefault(main_id, set()).add(auth) + for main_id, owners in owners_for_main.items(): + if len(owners) > 1: + raise AdapterError( + f"ambiguous canonical owner for {main_id!r}: {sorted(owners)}" + ) + + nodes: dict[str, Any] = {} + edges: list[dict[str, Any]] = [] + + def add_node(nid: str, bucket: str, record: dict[str, Any]) -> None: + if not isinstance(record, dict): + raise AdapterError(f"malformed record for {nid!r}") + if nid in nodes: + raise AdapterError(f"duplicate node id {nid!r}") + # Validate dep containers before digest so False/0/''/{}/null fail closed. + _dependency_container(record, "depends_on", node_id=nid) + _dependency_container(record, "sub_obligations", node_id=nid) + snapshot = _source_snapshot(record) + sem, evid = _digests_for(nid, record) + sem_core = _semantic_digest_core(nid, record) + # Surface source grade/status; never invent controlling=True. + source_controlling = record.get("controlling") + if source_controlling is not None: + source_controlling = _require_strict_bool( + source_controlling, field=f"{nid}.controlling" + ) + nodes[nid] = { + "bucket": bucket, + "classification": map_classification(record, bucket=bucket), + "controlling": False, # projection never grants controlling + "source_controlling": source_controlling, + "source_grade": record.get("grade"), + "source_status": record.get("status_frozen_v2_2") + or record.get("status_register_note"), + # Keep every source-status channel: `or` must not hide a claim + # of controlling use behind another, noncontrolling field. + "source_statuses": [ + record.get(key) + for key in ( + "status_frozen_v2_2", + "status_register_note", + "scientific_status", + ) + ], + "source_reference": record.get("source") or record.get("canon_source"), + "semantic_digest": sem, + "semantic_digest_core": sem_core, + "evidence_digest": evid, + "source_snapshot": snapshot, + "fingerprint": sem, # derived digest; never a manual sole detector + "version": as_of, + } + + for nid, record in premises.items(): + add_node(nid, "premises", record) + for nid, record in claim_nodes.items(): + add_node(nid, "claims", record) + + def add_deps(nid: str, record: dict[str, Any]) -> None: + for dep in _dependency_container(record, "depends_on", node_id=nid): + if not isinstance(dep, str) or not dep: + raise AdapterError(f"malformed depends_on entry on {nid!r}: {dep!r}") + if dep not in nodes: + raise AdapterError(f"unknown dependency id {dep!r} from {nid!r}") + edges.append(_edge(nid, dep, "depends_on")) + for dep in _dependency_container(record, "sub_obligations", node_id=nid): + if not isinstance(dep, str) or not dep: + raise AdapterError(f"malformed sub_obligation entry on {nid!r}: {dep!r}") + if dep not in nodes: + raise AdapterError(f"unknown sub_obligation id {dep!r} from {nid!r}") + edges.append(_edge(nid, dep, "sub_obligation")) + + for nid, record in premises.items(): + add_deps(nid, record) + for nid, record in claim_nodes.items(): + add_deps(nid, record) + + graph = { + "schema_version": 1, + "object": "CLAIMS-GATE-ADAPTER-20260925-v1", + "source": "claims/graph.json", + "as_of": as_of, + "nodes": nodes, + "edges": edges, + "scientific_effect": "NONE", + "promotion_permission": False, + "meaning": "hardening adapter projection; never promotion permission", + } + validate_graph_fail_closed(graph) + return graph + + +def required_dependencies(graph: dict[str, Any], node_id: str) -> list[str]: + if node_id not in graph["nodes"]: + raise AdapterError(f"unknown node: {node_id}") + deps: list[str] = [] + for edge in graph["edges"]: + if edge["from"] != node_id: + continue + if _require_strict_bool(edge["required"], field="required"): + deps.append(edge["to"]) + return deps + + +def transitive_required(graph: dict[str, Any], node_id: str) -> list[str]: + seen: set[str] = set() + order: list[str] = [] + + def walk(nid: str) -> None: + for dep in required_dependencies(graph, nid): + if dep in seen: + continue + seen.add(dep) + order.append(dep) + walk(dep) + + walk(node_id) + return order + + +def _required_cycle(graph: dict[str, Any]) -> list[str] | None: + visiting: set[str] = set() + done: set[str] = set() + stack: list[str] = [] + + def visit(nid: str) -> list[str] | None: + if nid in visiting: + i = stack.index(nid) + return stack[i:] + [nid] + if nid in done: + return None + visiting.add(nid) + stack.append(nid) + for dep in required_dependencies(graph, nid): + cycle = visit(dep) + if cycle: + return cycle + stack.pop() + visiting.remove(nid) + done.add(nid) + return None + + for nid in sorted(graph["nodes"]): + cycle = visit(nid) + if cycle: + return cycle + return None + + +def validate_graph_fail_closed(graph: dict[str, Any]) -> None: + nodes = graph.get("nodes") + edges = graph.get("edges") + if not isinstance(nodes, dict) or not isinstance(edges, list): + raise AdapterError("malformed graph") + seen_edges: set[tuple[Any, ...]] = set() + for edge in edges: + if not isinstance(edge, dict) or not {"from", "to", "required", "relation"} <= set(edge): + raise AdapterError("malformed edge record") + _require_strict_bool(edge["required"], field="required") + if edge["from"] not in nodes or edge["to"] not in nodes: + raise AdapterError("edge references missing node") + # Duplicate identity: same from/to/relation regardless of required flag. + identity = (edge["from"], edge["to"], edge["relation"]) + if identity in seen_edges: + raise AdapterError( + "duplicate edge record: " + f"{edge['from']!r} -> {edge['to']!r} ({edge['relation']})" + ) + seen_edges.add(identity) + cycle = _required_cycle(graph) + if cycle: + raise AdapterError("required dependency cycle: " + " -> ".join(cycle)) + + +def required_holds(graph: dict[str, Any], node_id: str) -> dict[str, Any]: + """HOLD proposals from still-required REFUTED / BLOCKED_ABSENT premises. + + SUPERSEDED_NONBLOCKING does not satisfy a required premise here. + Never returns promotion permission. + """ + if node_id not in graph["nodes"]: + raise AdapterError(f"unknown node: {node_id}") + required = transitive_required(graph, node_id) + refuted = [] + blocked = [] + unsatisfied = [] + for dep in required: + cls = graph["nodes"][dep]["classification"] + if cls in REFUTED_CLASSIFICATIONS: + refuted.append(dep) + elif cls in BLOCKED_CLASSIFICATIONS: + blocked.append(dep) + elif cls not in REQUIRED_SATISFIED: + # Includes SUPERSEDED_NONBLOCKING, OPEN_*, AUTHOR_SIDE_*, etc. + unsatisfied.append({"id": dep, "classification": cls}) + + proposals: list[str] = [] + reasons: list[str] = [] + if refuted: + proposals.append("HOLD") + reasons.append("required REFUTED dependency forces HOLD") + if blocked: + proposals.append("HOLD") + reasons.append("required BLOCKED_ABSENT dependency forces HOLD") + if unsatisfied: + proposals.append("HOLD") + reasons.append("required transitive dependency is not satisfied") + + return { + "node": node_id, + "proposals": sorted(set(proposals)) or ["HOLD"], + "promotion_permission": False, + "required_dependencies": required, + "refuted_required": refuted, + "blocked_absent": blocked, + "unsatisfied_required": unsatisfied, + "reasons": reasons, + "scientific_effect": "NONE", + "meaning": "HOLD proposal only; never promotion permission", + } + + +def _node_identity_changed(old_node: dict[str, Any], new_node: dict[str, Any]) -> bool: + """True when semantic digest / source snapshot / classification / version diverge. + + Manual fingerprint alone is never the sole detector when digests exist. + """ + old_sem = old_node.get("semantic_digest") + new_sem = new_node.get("semantic_digest") + if old_sem is not None or new_sem is not None: + if old_sem != new_sem: + return True + old_snap = old_node.get("source_snapshot") + new_snap = new_node.get("source_snapshot") + if old_snap is not None or new_snap is not None: + if old_snap != new_snap: + return True + elif old_node.get("fingerprint") != new_node.get("fingerprint"): + # Legacy fixtures without digests/snapshots — fallback only. + return True + return ( + old_node.get("classification") != new_node.get("classification") + or old_node.get("version") != new_node.get("version") + ) + + +def reverse_impact_between( + old_graph: dict[str, Any], + new_graph: dict[str, Any], + *, + old_sources: dict[str, Any] | None = None, + new_sources: dict[str, Any] | None = None, + extra_seeds: list[str] | tuple[str, ...] | set[str] | None = None, +) -> dict[str, Any]: + """Reverse impact over UNION(old,new) edges; deleted edges cannot erase impact. + + Seeds include: + - nodes whose canonical source snapshot / classification / version change; + - endpoints of edge-only changes (add/remove/mutate) even when fingerprints + and classifications are unchanged; + - nodes whose bound repository source-file bytes change (PR15 contract); + - explicit extra_seeds (e.g. crosswalk authority-owner drift) BEFORE closure; + - the changed node itself (self-hold / self-revalidation). + + Attaches REVALIDATION_REQUIRED proposals without erasing REFUTED. + Never sets controlling=True. Never grants promotion permission. + """ + validate_graph_fail_closed(old_graph) + validate_graph_fail_closed(new_graph) + old_nodes, new_nodes = old_graph["nodes"], new_graph["nodes"] + all_ids = set(old_nodes) | set(new_nodes) + changed: set[str] = set() + source_byte_seeds: set[str] = set() + authority_owner_seeds: set[str] = set() + for nid in all_ids: + if nid not in old_nodes or nid not in new_nodes: + changed.add(nid) + continue + if _node_identity_changed(old_nodes[nid], new_nodes[nid]): + changed.add(nid) + + # Edge-only seeds: compare complete edge records; endpoints are impact seeds + # even when node fingerprints/status are unchanged. + old_edge_keys = {_edge_key(e) for e in old_graph["edges"]} + new_edge_keys = {_edge_key(e) for e in new_graph["edges"]} + edge_only_seeds: set[str] = set() + for key in old_edge_keys.symmetric_difference(new_edge_keys): + frm, to, _relation, _required = key + edge_only_seeds.add(frm) + edge_only_seeds.add(to) + changed.add(frm) + changed.add(to) + + # Source FILE byte drift (distinct from claims-record JSON snapshot). + if old_sources is not None and new_sources is not None: + for nid in set(old_sources) | set(new_sources): + old_s = old_sources.get(nid) or {"kind": "absent", "coverage_sha256": ""} + new_s = new_sources.get(nid) or {"kind": "absent", "coverage_sha256": ""} + old_cov = old_s.get("coverage_sha256") or old_s.get("sha256") + new_cov = new_s.get("coverage_sha256") or new_s.get("sha256") + if old_cov != new_cov or old_s.get("kind") != new_s.get("kind"): + if ( + old_s.get("kind") in {"blob", "tree", "multi"} + or new_s.get("kind") in {"blob", "tree", "multi"} + or old_cov + or new_cov + ): + source_byte_seeds.add(nid) + changed.add(nid) + elif old_s.get("kind") != new_s.get("kind"): + source_byte_seeds.add(nid) + changed.add(nid) + + # Authority-owner / other explicit seeds must enter BEFORE reverse closure + # so transitive dependents (e.g. controlling consumers of P) are impacted. + for nid in extra_seeds or (): + if nid in all_ids: + authority_owner_seeds.add(nid) + changed.add(nid) + + union_edges = { + (e["from"], e["to"]) for g in (old_graph, new_graph) for e in g["edges"] + } + reverse: dict[str, set[str]] = {} + for child, dep in union_edges: + reverse.setdefault(dep, set()).add(child) + + # Self-hold: the changed node itself is included when present in new_graph. + impacted: set[str] = {nid for nid in changed if nid in new_nodes} + queue = list(changed) + seen = set(queue) + while queue: + dep = queue.pop() + for child in reverse.get(dep, ()): + if child not in seen: + seen.add(child) + queue.append(child) + if child in new_nodes: + impacted.add(child) + + clone = copy.deepcopy(new_graph) + proposals: list[dict[str, Any]] = [] + for nid in sorted(impacted): + node = clone["nodes"][nid] + # Preserve REFUTED (and other terminal dispositions) while attaching proposal. + if node.get("classification") not in REFUTED_CLASSIFICATIONS: + node["classification"] = "REVALIDATION_REQUIRED" + node["revalidation_proposal"] = "REVALIDATION_REQUIRED" + node["controlling"] = False + proposal: dict[str, Any] = { + "node": nid, + "proposal": "REVALIDATION_REQUIRED", + "source_grade": node.get("source_grade"), + "source_status": node.get("source_status"), + "source_controlling": node.get("source_controlling"), + "preserved_classification": node.get("classification"), + "promotion_permission": False, + } + if nid in authority_owner_seeds: + proposal["reason"] = "crosswalk_authority_owner_change" + proposals.append(proposal) + + return { + "changed_nodes": sorted(changed), + "edge_only_seeds": sorted(edge_only_seeds), + "authority_owner_seeds": sorted(authority_owner_seeds), + "source_byte_seeds": sorted(source_byte_seeds), + "impacted": sorted(impacted), + "proposals": proposals, + "graph": clone, + "promotion_permission": False, + "scientific_effect": "NONE", + "meaning": "union-edge reverse-impact HOLD/REVALIDATION proposal; never promotion permission", + } + + +def node_has_hold(hold: dict[str, Any]) -> bool: + """True when required_holds found unresolved still-required premises. + + Includes unsatisfied_required (AUTHOR_SIDE_CANDIDATE / OPEN_ACTIVE / + SUPERSEDED_NONBLOCKING / etc.), not only REFUTED / BLOCKED_ABSENT. + Does not treat the default proposals=["HOLD"] padding as a real hold. + """ + return bool( + hold.get("refuted_required") + or hold.get("blocked_absent") + or hold.get("unsatisfied_required") + ) + + +def aggregate_hold_proposals(graph: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Fail-closed HOLD map over every node with unresolved required premises.""" + holds: dict[str, dict[str, Any]] = {} + for nid in sorted(graph["nodes"]): + hold = required_holds(graph, nid) + if node_has_hold(hold): + holds[nid] = hold + return holds + + +def compare_claims_files( + old_claims: dict[str, Any], + new_claims: dict[str, Any], + *, + crosswalk: dict[str, Any] | None = None, + authority_map: dict[str, Any] | None = None, +) -> dict[str, Any]: + old_g = claims_to_gate_graph(old_claims, crosswalk=crosswalk, authority_map=authority_map) + new_g = claims_to_gate_graph(new_claims, crosswalk=crosswalk, authority_map=authority_map) + impact = reverse_impact_between(old_g, new_g) + holds = aggregate_hold_proposals(new_g) + enforcement = evaluate_transition_enforcement( + old_graph=old_g, new_graph=new_g, impact=impact, holds=holds + ) + return { + **enforcement, + "reverse_impact": impact, + "hold_proposals": holds, + "promotion_permission": False, + "scientific_effect": "NONE", + "semantic_reference": "Math- PR13 tip baca69c394ab42130c61771bee74e808703f1ce7 / main #90 clarification", + "meaning": "before/after claims adapter report; never promotion permission", + } + + +def audit_tip(root: Path | None = None) -> dict[str, Any]: + """Tip-health only: identity self-compare + HOLD inventory. Not base→head evidence.""" + root = root or ROOT + claims = load_json(root / "claims" / "graph.json") + crosswalk = load_json(root / "architecture" / "scientific_state" / "v1" / "ID_CROSSWALK.json") + authority = load_json(root / "architecture" / "scientific_state" / "v1" / "AUTHORITY_MAP.json") + graph = claims_to_gate_graph(claims, crosswalk=crosswalk, authority_map=authority) + # Self-compare: identity before/after should yield empty impact. + impact = reverse_impact_between(graph, copy.deepcopy(graph)) + holds = aggregate_hold_proposals(graph) + hold_nodes = sorted(holds) + return { + "mode": "tip_health", + "nodes": len(graph["nodes"]), + "edges": len(graph["edges"]), + "sub_obligation_edges": sum(1 for e in graph["edges"] if e["relation"] == "sub_obligation"), + "depends_on_edges": sum(1 for e in graph["edges"] if e["relation"] == "depends_on"), + "identity_impacted": impact["impacted"], + "hold_node_count": len(hold_nodes), + "hold_nodes": hold_nodes, + "hold_proposals": holds, + "promotion_permission": False, + "scientific_effect": "NONE", + "problems": [], + "meaning": ( + "tip-health only (identity self-compare + HOLD inventory); " + "NOT evidence that a PR/push base→head transition is clean" + ), + } + + +ZERO_SHA = "0" * 40 +CLAIMS_REL = "claims/graph.json" +CROSSWALK_REL = "architecture/scientific_state/v1/ID_CROSSWALK.json" +AUTHORITY_REL = "architecture/scientific_state/v1/AUTHORITY_MAP.json" + +# Grades/statuses that indicate the *source* record is treated as load-bearing. +# Projection never sets controlling=True; these only flag report attention / +# loss-only transition refusal (never positive acceptance). +SOURCE_CONTROLLING_HINTS = frozenset( + { + "LIVE_ROOT_THEOREM", + "CERTIFIED_RUNG", + "CONTROLLING", + "PROVED_REVIEWED", + } +) + + +def _source_is_controlling(node: dict[str, Any]) -> bool: + """Interpret all observed source channels without awarding acceptance. + + Explicit false does not override a simultaneously controlling grade/status. + A demotion must consistently remove the source's claims of controlling use. + Ported from OpenAI trial PR128 F1 candidate (ACCEPT'd nonauthor review). + """ + if node.get("source_controlling") is True: + return True + statuses = [node.get("source_grade"), node.get("source_status")] + statuses.extend(node.get("source_statuses") or []) + return any( + isinstance(value, str) and value.strip().upper() in SOURCE_CONTROLLING_HINTS + for value in statuses + ) + + +# Expected owner/name for in-repo source_bindings. Cross-repo bindings fail closed. +CURRENT_REPO = "d6g8k5htny-coder/main" + +# Binding kinds that do NOT monitor repository object bytes. +UNRESOLVED_SOURCE_KINDS = frozenset( + { + "unresolved_prose", + "missing", + "record_only", + "external_unresolved", + "unsupported_cross_repo", + "path_alias", # alias alone never monitors bytes + "object_hash_mismatch", + "freshness_unverified", + "freshness_stale", + "freshness_absent", + } +) + +# Roles that never alone establish controlling byte-monitorability. +NON_SCIENTIFIC_ROLES = frozenset({"informational_carrier", "raw_carrier"}) + +# Freshness values that refuse controlling use (fail closed). +REFUSED_FRESHNESS = frozenset({"unverified", "stale", "absent"}) + +# Freshness that documents Drive sync as outside this gate while allowing +# GitHub to monitor the in-repo scientific-object bytes after sync. +OK_FRESHNESS = frozenset({"external_sync_obligation", "verified_at_bind"}) + + +def extract_scientific_bytes(raw: bytes, extraction_rule: str | None) -> bytes: + """Return the scientific-object bytes for a carrier under an extraction rule. + + Rules: + - None / \"whole_file\": entire carrier blob + - \"frozen_body\": bytes strictly between BEGIN_FROZEN_BODY / END_FROZEN_BODY, + LF-normalized, boundary blanks trimmed, exactly one terminal LF + """ + rule = (extraction_rule or "whole_file").strip() + if rule in {"", "whole_file"}: + return raw + if rule == "frozen_body": + text = raw.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") + begin = "BEGIN_FROZEN_BODY" + end = "END_FROZEN_BODY" + bi = text.find(begin) + ei = text.find(end) + if bi < 0 or ei < 0 or ei <= bi: + raise AdapterError( + "frozen_body extraction: BEGIN_FROZEN_BODY / END_FROZEN_BODY markers missing" + ) + start = text.find("\n", bi) + if start < 0: + raise AdapterError("frozen_body extraction: BEGIN marker has no trailing newline") + start += 1 + inner = text[start:ei].strip("\n") + "\n" + return inner.encode("utf-8") + raise AdapterError(f"unsupported extraction_rule: {extraction_rule!r}") + + +def _binding_is_scientific_monitorable(binding: dict[str, Any]) -> bool: + """True when one binding monitors exact scientific-object bytes for controlling use.""" + if binding.get("kind") not in {"blob", "tree"}: + return False + role = binding.get("role") or "scientific_object" + if role in NON_SCIENTIFIC_ROLES: + return False + freshness = binding.get("mirror_freshness") + rule = binding.get("extraction_rule") + # Precise scientific bindings declare expected digest and/or a non-default + # extraction rule; they require explicit freshness + hash match. Legacy + # path-only bindings (no expected_sha256, no special extraction) stay + # monitorable so existing fixtures keep working. + precise = bool(binding.get("expected_sha256")) or ( + isinstance(rule, str) and rule.strip() not in {"", "whole_file"} + ) + if precise: + if freshness is None or freshness == "": + return False + if freshness in REFUSED_FRESHNESS or freshness not in OK_FRESHNESS: + return False + if not binding.get("object_hash_ok"): + return False + elif freshness in REFUSED_FRESHNESS: + return False + return True + + +def _is_binding_precision_repair( + old_bound: dict[str, Any] | None, new_bound: dict[str, Any] | None +) -> bool: + """True when coverage drift is only identity-precision (E), not object loss. + + Same repo path + unchanged carrier bytes, with new scientific-object + validation succeeding, may change coverage_sha256 (role / extraction_rule / + expected_sha256 / object digest narrowing) without meaning the load-bearing + file content drifted. Exempt that from F1 retained-impact refusal — same + class as unresolved→monitorable coverage repair — only when non-binding + semantics are also unchanged (see `_coverage_repair_allowed`). + """ + if not _source_binding_monitorable(new_bound): + return False + if old_bound is None: + return False + new_sci = [ + b + for b in (new_bound.get("bindings") or []) + if _binding_is_scientific_monitorable(b) + ] + if not new_sci: + return False + old_by_path: dict[str, dict[str, Any]] = {} + for b in old_bound.get("bindings") or []: + if b.get("kind") not in {"blob", "tree"}: + continue + path = b.get("path") + if isinstance(path, str) and path: + old_by_path[path] = b + # Legacy single-binding view without bindings list. + if not old_by_path and old_bound.get("kind") in {"blob", "tree"}: + path = old_bound.get("path") + if isinstance(path, str) and path: + old_by_path[path] = old_bound + for b in new_sci: + path = b.get("path") + if not isinstance(path, str) or path not in old_by_path: + return False + old_b = old_by_path[path] + old_carrier = old_b.get("carrier_sha256") or old_b.get("sha256") + new_carrier = b.get("carrier_sha256") or b.get("sha256") + if not old_carrier or old_carrier != new_carrier: + return False + if b.get("expected_sha256") and not b.get("object_hash_ok"): + return False + return True + + +def _non_binding_identity_changed( + old_node: dict[str, Any], new_node: dict[str, Any] +) -> bool: + """True when scientific identity changed aside from binding metadata. + + `semantic_digest` already normalizes source_bindings to path-level fields + (owner/repo/path/commit/blob/hash) and excludes role / extraction_rule / + expected_sha256 / freshness. Full `source_snapshot` is intentionally NOT + used here — it always moves when binding precision metadata is added. + Binding *list order* remains part of semantic_digest (do not weaken E6 by + ignoring order); tip data must preserve predecessor binding order. + """ + return ( + old_node.get("semantic_digest") != new_node.get("semantic_digest") + or old_node.get("classification") != new_node.get("classification") + or old_node.get("version") != new_node.get("version") + or old_node.get("source_controlling") != new_node.get("source_controlling") + or old_node.get("source_grade") != new_node.get("source_grade") + ) + + +def _non_binding_core_changed( + old_node: dict[str, Any], new_node: dict[str, Any] +) -> bool: + """True when statement/edges/grade changed, ignoring explicit source_bindings. + + Used only for unresolved→monitorable coverage repair so first-time binding + attachment is not blocked by the path list appearing in semantic_digest. + """ + old_core = old_node.get("semantic_digest_core") or old_node.get("semantic_digest") + new_core = new_node.get("semantic_digest_core") or new_node.get("semantic_digest") + return ( + old_core != new_core + or old_node.get("classification") != new_node.get("classification") + or old_node.get("version") != new_node.get("version") + or old_node.get("source_controlling") != new_node.get("source_controlling") + or old_node.get("source_grade") != new_node.get("source_grade") + ) + + +def _reverse_reachable_from_other_seeds( + nid: str, + impact: dict[str, Any], + old_graph: dict[str, Any], + new_graph: dict[str, Any], +) -> bool: + """True when another changed seed reverse-reaches nid (independent impact).""" + other_seeds = set(impact.get("changed_nodes") or ()) - {nid} + if not other_seeds: + return False + union_edges = { + (e["from"], e["to"]) for g in (old_graph, new_graph) for e in g["edges"] + } + reverse: dict[str, set[str]] = {} + for child, dep in union_edges: + reverse.setdefault(dep, set()).add(child) + queue = list(other_seeds) + seen = set(other_seeds) + while queue: + dep = queue.pop() + for child in reverse.get(dep, ()): + if child == nid: + return True + if child not in seen: + seen.add(child) + queue.append(child) + return False + + +def _coverage_repair_allowed( + nid: str, + *, + old_graph: dict[str, Any], + new_graph: dict[str, Any], + impact: dict[str, Any], + require_full_semantic: bool = True, +) -> bool: + """E6: coverage repair must not mask independent semantic/edge/authority impact. + + When require_full_semantic is True (precision upgrades), binding list order + remains part of the identity check. When False (unresolved→monitorable), + only semantic_digest_core is consulted so attaching path bindings is allowed + if statement/edges/grade are unchanged. + """ + old_node = old_graph["nodes"].get(nid) + new_node = new_graph["nodes"].get(nid) + if not isinstance(old_node, dict) or not isinstance(new_node, dict): + return False + if require_full_semantic: + if _non_binding_identity_changed(old_node, new_node): + return False + elif _non_binding_core_changed(old_node, new_node): + return False + if nid in set(impact.get("edge_only_seeds") or ()): + return False + if nid in set(impact.get("authority_owner_seeds") or ()): + return False + if _reverse_reachable_from_other_seeds(nid, impact, old_graph, new_graph): + return False + return True + + +def _source_binding_monitorable(bound: dict[str, Any] | None) -> bool: + """True when at least one binding monitors validated scientific-object bytes.""" + if not isinstance(bound, dict): + return False + if bound.get("kind") in UNRESOLVED_SOURCE_KINDS: + return False + bindings = bound.get("bindings") or [] + if bindings: + return any(_binding_is_scientific_monitorable(b) for b in bindings) + # Backward-compatible single-binding view without a bindings list. + if bound.get("kind") in {"blob", "tree"} and bound.get("sha256"): + return _binding_is_scientific_monitorable(bound) + return False + + +def evaluate_transition_enforcement( + *, + old_graph: dict[str, Any], + new_graph: dict[str, Any], + impact: dict[str, Any], + holds: dict[str, Any], + new_sources: dict[str, dict[str, Any]] | None = None, + old_sources: dict[str, dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Loss-only refusal, not a positive mathematical admission mechanism. + + No newly controlling source, or impacted source still used as controlling, + can be admitted here merely by attaching a proposed HOLD. Unchanged legacy + statuses remain visible; they are not retroactively accepted. Corrective + edits with consistent demotion are allowed. Controlling nodes whose declared + sources are not byte-monitorable fail closed (F2). Migrating a previously + unresolved controlling source onto monitorable bindings is a coverage repair, + not an illegal retention. F3–F5 remain separate. + """ + old_controls = { + nid for nid, node in old_graph["nodes"].items() if _source_is_controlling(node) + } + new_controls = { + nid for nid, node in new_graph["nodes"].items() if _source_is_controlling(node) + } + newly_controlling = new_controls - old_controls + retained_impacted = new_controls.intersection(impact["impacted"]) + coverage_repairs: set[str] = set() + if old_sources is not None and new_sources is not None: + for nid in list(retained_impacted): + old_b = old_sources.get(nid) + new_b = new_sources.get(nid) + candidate = False + require_full_semantic = True + if not _source_binding_monitorable(old_b) and _source_binding_monitorable( + new_b + ): + candidate = True + # First-time monitorable binding attachment changes path-level + # semantic_digest; gate on core content instead. + require_full_semantic = False + elif ( + (old_b or {}).get("coverage_sha256") + != (new_b or {}).get("coverage_sha256") + and _is_binding_precision_repair(old_b, new_b) + ): + # Own coverage digest moved due to binding precision — not when + # reverse-impacted via another node's real byte drift alone. + candidate = True + require_full_semantic = True + if candidate and _coverage_repair_allowed( + nid, + old_graph=old_graph, + new_graph=new_graph, + impact=impact, + require_full_semantic=require_full_semantic, + ): + coverage_repairs.add(nid) + retained_impacted = retained_impacted - coverage_repairs + errors: list[dict[str, Any]] = [] + for nid in sorted(newly_controlling | retained_impacted): + reasons: list[str] = [] + if nid in newly_controlling: + reasons.append("UNSUPPORTED_CONTROLLING_PROMOTION") + if nid in retained_impacted: + reasons.append("CONTROLLING_SOURCE_REQUIRES_REVALIDATION") + if nid in holds: + reasons.append("UNSATISFIED_REQUIRED_PREMISE") + errors.append( + { + "node": nid, + "reasons": reasons, + "required_hold": holds.get(nid), + "promotion_permission": False, + } + ) + unresolved_controlling: list[str] = [] + if new_sources is not None: + for nid in sorted(new_controls): + bound = new_sources.get(nid) + if _source_binding_monitorable(bound): + continue + unresolved_controlling.append(nid) + reasons = ["UNRESOLVED_CONTROLLING_SOURCE"] + kind = (bound or {}).get("kind") or "absent" + reasons.append(f"binding_kind:{kind}") + existing = next((e for e in errors if e.get("node") == nid), None) + if existing: + for reason in reasons: + if reason not in existing["reasons"]: + existing["reasons"].append(reason) + existing["source_binding"] = bound + else: + errors.append( + { + "node": nid, + "reasons": reasons, + "required_hold": holds.get(nid), + "source_binding": bound, + "promotion_permission": False, + } + ) + return { + "transition_ok": not errors, + "transition_errors": errors, + "illegal_controlling_transitions": errors, + "source_controlling_after": sorted(new_controls), + "unresolved_controlling_sources": unresolved_controlling, + "coverage_repairs": sorted(coverage_repairs), + "unchanged_controlling_holds": sorted( + (new_controls - retained_impacted - set(unresolved_controlling)).intersection( + holds + ) + ), + "promotion_permission": False, + "enforcement_scope": ( + "loss-only source transition over the detected impact graph; " + "controlling sources must be byte-monitorable scientific objects; " + "coverage repair includes unresolved→monitorable and same-carrier " + "identity-precision upgrades only when non-binding semantics / " + "edges / authority seeds are unchanged; not positive admission or legacy acceptance" + ), + "meaning": ( + "transition_ok is false for unsupported new controlling status, " + "impacted consumers left controlling, or controlling nodes without " + "monitorable scientific-object bindings; coverage repairs and demotion remain OK" + ), + } + + +def _sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def load_json_strict(raw: str | bytes, *, where: str = "json") -> Any: + """Fail closed on duplicate keys and nonfinite constants (NaN/Infinity).""" + if isinstance(raw, bytes): + text = raw.decode("utf-8") + else: + text = raw + + def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in items: + if key in result: + raise AdapterError(f"{where}: duplicate JSON key {key!r}") + result[key] = value + return result + + def invalid(value: str) -> None: + raise AdapterError(f"{where}: nonfinite JSON value {value!r}") + + try: + return json.loads(text, object_pairs_hook=pairs, parse_constant=invalid) + except json.JSONDecodeError as exc: + raise AdapterError(f"{where}: malformed JSON: {exc}") from exc + + +def load_json(path: Path) -> Any: + with path.open(encoding="utf-8") as handle: + return load_json_strict(handle.read(), where=str(path)) + + +def relative_repo_path(value: str) -> str: + """PR15 contract: repository-relative path only; reject absolute/.. /URL-ish.""" + path = PurePosixPath(value) + if ( + not value + or path.is_absolute() + or ".." in path.parts + or ":" in value + or "\\" in value + ): + raise AdapterError(f"repository-relative source path required: {value!r}") + return path.as_posix() + + +def _candidate_source_strings(record: dict[str, Any]) -> list[tuple[str, str, str]]: + """Yield (kind_hint, reference, field) triples from a claims record. + + kind_hint: 'path' | 'external' | 'prose' + field: provenance of the declaration (mirror_path / source.path / source_bindings / …) + """ + out: list[tuple[str, str, str]] = [] + for key in ("mirror_path",): + val = record.get(key) + if isinstance(val, str) and val.strip(): + out.append(("path", val.strip(), key)) + # Canonical source_bindings used by semantic_digest — must also bind bytes. + raw_bindings = record.get("source_bindings") + if isinstance(raw_bindings, dict): + raw_bindings = [raw_bindings] + if isinstance(raw_bindings, list): + for index, item in enumerate(raw_bindings): + if isinstance(item, str) and item.strip(): + out.append(("path", item.strip(), f"source_bindings[{index}]")) + elif isinstance(item, dict): + path = item.get("path") or item.get("repo_path") or item.get("file") + repo = item.get("repo") + if isinstance(item.get("owner"), str) and isinstance(repo, str): + repo = f"{item['owner'].strip()}/{repo.strip()}" + if isinstance(path, str) and path.strip(): + if isinstance(repo, str) and repo.strip() and repo.strip() != CURRENT_REPO: + # Keep declared immutable identity; never bind local bytes. + declared = { + "commit": item.get("commit"), + "blob": item.get("blob"), + "hash": item.get("hash"), + "sha256": item.get("sha256"), + } + out.append( + ( + "unsupported_cross_repo", + json.dumps( + { + "repo": repo.strip(), + "path": path.strip(), + **{ + k: v + for k, v in declared.items() + if isinstance(v, str) and v + }, + }, + sort_keys=True, + separators=(",", ":"), + ), + f"source_bindings[{index}].path", + ) + ) + else: + out.append( + ("path", path.strip(), f"source_bindings[{index}].path") + ) + url = item.get("url") or item.get("uri") + if isinstance(url, str) and url.strip(): + out.append( + ("external", url.strip(), f"source_bindings[{index}].url") + ) + src = record.get("source") + if isinstance(src, dict): + path = src.get("path") or src.get("repo_path") or src.get("file") + if isinstance(path, str) and path.strip(): + out.append(("path", path.strip(), "source.path")) + url = src.get("url") or src.get("uri") + if isinstance(url, str) and url.strip(): + out.append(("external", url.strip(), "source.url")) + elif isinstance(src, str) and src.strip(): + text = src.strip() + if text.startswith(("https://", "http://", "external:")): + out.append(("external", text, "source")) + else: + first = re.split(r"[;]", text, maxsplit=1)[0].strip() + first = re.split(r"\s+§", first, maxsplit=1)[0].strip() + if ( + "/" in first + or first.endswith((".md", ".json", ".py", ".lean", ".txt")) + ) and " " not in first: + out.append(("path", first, "source")) + else: + out.append(("prose", text, "source")) + canon = record.get("canon_source") + if isinstance(canon, str) and canon.strip(): + if canon.startswith(("https://", "http://", "external:")): + out.append(("external", canon.strip(), "canon_source")) + else: + out.append(("prose", canon.strip(), "canon_source")) + return out + + +def _bind_one_path( + root: Path, + revision: str, + reference: str, + *, + field: str, + role: str | None = None, + extraction_rule: str | None = None, + expected_sha256: str | None = None, + mirror_freshness: str | None = None, + source_drive_id: str | None = None, + raw_carrier_sha256: str | None = None, + extra_meta: dict[str, Any] | None = None, +) -> dict[str, Any]: + try: + path = relative_repo_path(reference) + except AdapterError: + return { + "kind": "unresolved_prose", + "reference": reference, + "field": field, + "role": role, + } + object_spec = f"{revision}:{path.rstrip('/')}" + kind = _git_bytes(root, "cat-file", "-t", object_spec, missing_ok=True) + if kind is None: + return { + "kind": "missing", + "reference": reference, + "path": path, + "field": field, + "role": role, + "extraction_rule": extraction_rule, + "expected_sha256": expected_sha256, + "mirror_freshness": mirror_freshness, + "source_drive_id": source_drive_id, + "raw_carrier_sha256": raw_carrier_sha256, + } + kind_s = kind.decode().strip() + if kind_s not in {"blob", "tree"}: + raise AdapterError(f"source object must be blob or tree: {object_spec}") + body = _git_bytes(root, "cat-file", "-p", object_spec) + assert body is not None + carrier_sha = _sha256_bytes(body) + try: + object_bytes = extract_scientific_bytes(body, extraction_rule) + except AdapterError as exc: + return { + "kind": "object_hash_mismatch", + "reference": reference, + "path": path, + "field": field, + "role": role or "scientific_object", + "extraction_rule": extraction_rule or "whole_file", + "expected_sha256": expected_sha256, + "mirror_freshness": mirror_freshness, + "source_drive_id": source_drive_id, + "raw_carrier_sha256": raw_carrier_sha256 or carrier_sha, + "carrier_bytes": len(body), + "carrier_sha256": carrier_sha, + "extraction_error": str(exc), + "object_hash_ok": False, + **(extra_meta or {}), + } + object_sha = _sha256_bytes(object_bytes) + object_hash_ok = True + if isinstance(expected_sha256, str) and expected_sha256.strip(): + object_hash_ok = object_sha == expected_sha256.strip().lower() + result_kind = kind_s + if isinstance(expected_sha256, str) and expected_sha256.strip() and not object_hash_ok: + result_kind = "object_hash_mismatch" + freshness = mirror_freshness + if freshness in REFUSED_FRESHNESS: + # Preserve carrier identity but mark non-monitorable freshness failure. + result_kind = { + "unverified": "freshness_unverified", + "stale": "freshness_stale", + "absent": "freshness_absent", + }.get(freshness, "freshness_unverified") + out: dict[str, Any] = { + "kind": result_kind if result_kind != kind_s else kind_s, + "reference": reference, + "path": path, + "field": field, + "bytes": len(body), + "sha256": carrier_sha, + "carrier_bytes": len(body), + "carrier_sha256": carrier_sha, + "object_bytes": len(object_bytes), + "object_sha256": object_sha, + "object_hash_ok": object_hash_ok, + "role": role or "scientific_object", + "extraction_rule": extraction_rule or "whole_file", + "expected_sha256": expected_sha256, + "mirror_freshness": freshness, + "source_drive_id": source_drive_id, + "raw_carrier_sha256": raw_carrier_sha256, + "freshness_scope": ( + "github_monitors_repo_scientific_object_only; " + "drive_sync_is_external_provenance_obligation" + if freshness == "external_sync_obligation" + else None + ), + **(extra_meta or {}), + } + # When hash/freshness fails, promote kind so aggregate unresolved detection + # does not treat the binding as a clean blob monitor. + if not object_hash_ok and isinstance(expected_sha256, str) and expected_sha256.strip(): + out["kind"] = "object_hash_mismatch" + elif freshness in REFUSED_FRESHNESS: + out["kind"] = { + "unverified": "freshness_unverified", + "stale": "freshness_stale", + "absent": "freshness_absent", + }[freshness] + return out + + +def _structured_binding_meta(item: dict[str, Any]) -> dict[str, Any]: + """Pull scientific-object identity fields from a source_bindings dict.""" + expected = item.get("expected_sha256") or item.get("object_sha256") + if isinstance(expected, str): + expected = expected.strip().lower() + else: + expected = None + rule = item.get("extraction_rule") + if not isinstance(rule, str) or not rule.strip(): + rule = None + else: + rule = rule.strip() + role = item.get("role") + if not isinstance(role, str) or not role.strip(): + role = None + else: + role = role.strip() + freshness = item.get("mirror_freshness") or item.get("freshness") + if not isinstance(freshness, str) or not freshness.strip(): + freshness = None + else: + freshness = freshness.strip() + drive_id = item.get("source_drive_id") or item.get("drive_id") + if not isinstance(drive_id, str) or not drive_id.strip(): + drive_id = None + raw_hash = item.get("raw_carrier_sha256") or item.get("carrier_sha256") + if isinstance(raw_hash, str): + raw_hash = raw_hash.strip().lower() + else: + raw_hash = None + extra: dict[str, Any] = {} + for key in ( + "freshness_meaning", + "note", + "raw_carrier_note", + "register_note_drive_id", + ): + val = item.get(key) + if isinstance(val, str) and val.strip(): + extra[key] = val.strip() + return { + "role": role, + "extraction_rule": rule, + "expected_sha256": expected, + "mirror_freshness": freshness, + "source_drive_id": drive_id, + "raw_carrier_sha256": raw_hash, + "extra_meta": extra, + } + + +def bind_source_at_revision( + root: Path, revision: str, record: dict[str, Any] +) -> dict[str, Any]: + """Bind ALL declared load-bearing repository sources at a revision. + + Does not let mirror_path silently shadow source.path / source_bindings. + Structured bindings may declare scientific-object identity via + expected_sha256 + extraction_rule and mirror_freshness. Informational + carriers are bound for provenance but do not seed coverage impact. + External refs → external_unresolved (never fetched). Missing → missing. + Prose-only → unresolved_prose. No invention of absent objects. + """ + candidates = _candidate_source_strings(record) + if not candidates: + return { + "kind": "record_only", + "bindings": [], + "coverage_sha256": _sha256_bytes(b"record_only"), + } + + # Index structured source_bindings by path for identity metadata. + structured_by_path: dict[str, dict[str, Any]] = {} + raw_bindings = record.get("source_bindings") + if isinstance(raw_bindings, dict): + raw_bindings = [raw_bindings] + if isinstance(raw_bindings, list): + for item in raw_bindings: + if not isinstance(item, dict): + continue + path = item.get("path") or item.get("repo_path") or item.get("file") + if isinstance(path, str) and path.strip(): + structured_by_path[path.strip()] = item + + bindings: list[dict[str, Any]] = [] + seen_paths: set[str] = set() + for kind_hint, reference, field in candidates: + if kind_hint == "path": + key = reference + if key in seen_paths: + bindings.append( + { + "kind": "path_alias", + "reference": reference, + "field": field, + "alias_of": reference, + } + ) + continue + seen_paths.add(key) + meta = _structured_binding_meta(structured_by_path.get(key, {})) + bindings.append( + _bind_one_path( + root, + revision, + reference, + field=field, + **meta, + ) + ) + elif kind_hint == "external": + bindings.append( + { + "kind": "external_unresolved", + "reference": reference, + "field": field, + } + ) + elif kind_hint == "unsupported_cross_repo": + try: + declared = json.loads(reference) + except json.JSONDecodeError: + declared = {"reference": reference} + bindings.append( + { + "kind": "unsupported_cross_repo", + "reference": reference, + "field": field, + "expected_repo": CURRENT_REPO, + "declared_repo": declared.get("repo"), + "declared_path": declared.get("path"), + "declared_commit": declared.get("commit"), + "declared_blob": declared.get("blob"), + "declared_hash": declared.get("hash") or declared.get("sha256"), + } + ) + else: + bindings.append( + { + "kind": "unresolved_prose", + "reference": reference, + "field": field, + } + ) + # Coverage hash over scientific-object identities only so informational + # carriers (e.g. whole Q0 master) do not false-trigger revalidation. + coverage_payload = [] + for b in bindings: + role = b.get("role") or "scientific_object" + if role in NON_SCIENTIFIC_ROLES: + continue + if b.get("kind") in {"path_alias"}: + continue + coverage_payload.append( + { + "field": b.get("field"), + "kind": b.get("kind"), + "path": b.get("path"), + "reference": b.get("reference"), + "role": role, + "extraction_rule": b.get("extraction_rule"), + "object_sha256": b.get("object_sha256") or b.get("sha256"), + "expected_sha256": b.get("expected_sha256"), + "mirror_freshness": b.get("mirror_freshness"), + } + ) + scientific_bindings = [ + b + for b in bindings + if b.get("kind") in {"blob", "tree"} + and (b.get("role") or "scientific_object") not in NON_SCIENTIFIC_ROLES + ] + # Prefer classifying by scientific-monitorable outcome for controlling use. + if any(_binding_is_scientific_monitorable(b) for b in bindings): + monitorable_count = sum( + 1 for b in bindings if _binding_is_scientific_monitorable(b) + ) + primary_kind = "multi" if monitorable_count > 1 else "blob" + elif any(b.get("kind") == "object_hash_mismatch" for b in bindings): + primary_kind = "object_hash_mismatch" + elif any(b.get("kind") == "freshness_unverified" for b in bindings): + primary_kind = "freshness_unverified" + elif any(b.get("kind") == "freshness_stale" for b in bindings): + primary_kind = "freshness_stale" + elif any(b.get("kind") == "freshness_absent" for b in bindings): + primary_kind = "freshness_absent" + elif scientific_bindings: + primary_kind = ( + "multi" if len(scientific_bindings) > 1 else scientific_bindings[0]["kind"] + ) + elif any(b.get("kind") == "unsupported_cross_repo" for b in bindings): + primary_kind = "unsupported_cross_repo" + elif any(b.get("kind") == "external_unresolved" for b in bindings): + primary_kind = "external_unresolved" + elif any(b.get("kind") == "missing" for b in bindings): + primary_kind = "missing" + elif any(b.get("kind") == "unresolved_prose" for b in bindings): + primary_kind = "unresolved_prose" + else: + primary_kind = "record_only" + primary_view = next( + (b for b in bindings if _binding_is_scientific_monitorable(b)), + scientific_bindings[0] if scientific_bindings else None, + ) + return { + "kind": primary_kind, + "bindings": bindings, + "coverage_sha256": _sha256_canonical_payload(coverage_payload), + **( + { + "reference": primary_view.get("reference"), + "path": primary_view.get("path"), + "bytes": primary_view.get("object_bytes") or primary_view.get("bytes"), + "sha256": primary_view.get("object_sha256") or primary_view.get("sha256"), + "field": primary_view.get("field"), + "object_sha256": primary_view.get("object_sha256"), + "extraction_rule": primary_view.get("extraction_rule"), + "expected_sha256": primary_view.get("expected_sha256"), + "mirror_freshness": primary_view.get("mirror_freshness"), + "role": primary_view.get("role"), + } + if primary_view + else {} + ), + } + + +def _sha256_canonical_payload(payload: Any) -> str: + blob = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return _sha256_bytes(blob.encode("utf-8")) + + +def bind_claims_sources_at_ref( + root: Path, revision: str, claims: dict[str, Any] +) -> dict[str, dict[str, Any]]: + sources: dict[str, dict[str, Any]] = {} + for bucket in ("premises", "claims"): + for nid, record in (claims.get(bucket) or {}).items(): + if not isinstance(record, dict): + raise AdapterError(f"malformed record for {nid!r}") + sources[nid] = bind_source_at_revision(root, revision, record) + return sources + + +def _git_bytes( + root: Path, *args: str, missing_ok: bool = False +) -> bytes | None: + result = subprocess.run( + ["git", "-C", str(root), *args], + capture_output=True, + timeout=60, + ) + if result.returncode != 0: + if missing_ok: + return None + err = result.stderr.decode("utf-8", errors="replace").strip() + raise AdapterError(f"git {' '.join(args)} failed: {err or 'unknown error'}") + return result.stdout + + +def _require_usable_ref( + ref: str, + *, + role: str, + root: Path | None = None, + resolve: bool = True, +) -> str: + """Reject empty/all-zero refs; optionally resolve once to a full 40-hex commit.""" + if not isinstance(ref, str) or not ref.strip(): + raise AdapterError(f"{role} ref missing or empty") + cleaned = ref.strip() + if cleaned == ZERO_SHA or set(cleaned) == {"0"}: + raise AdapterError(f"{role} ref unavailable or all-zero: {cleaned!r}") + if not resolve: + return cleaned + if root is None: + # Event extraction without a compare root keeps the raw token; compare_claims_refs + # always resolves against the actual repository being audited. + return cleaned + resolved = _git_bytes( + root, "rev-parse", "--verify", f"{cleaned}^{{commit}}", missing_ok=True + ) + if resolved is None: + raise AdapterError(f"{role} ref is not a resolvable commit: {cleaned!r}") + full = resolved.decode().strip() + if not re.fullmatch(r"[0-9a-f]{40}", full): + raise AdapterError(f"{role} ref did not resolve to a 40-hex commit id: {full!r}") + kind = _git_bytes(root, "cat-file", "-t", full, missing_ok=True) + if kind is None or kind.decode().strip() != "commit": + raise AdapterError(f"{role} ref is not a commit object: {full!r}") + return full + + +def git_show_bytes(root: Path, ref: str, relpath: str) -> bytes: + data = _git_bytes(root, "show", f"{ref}:{relpath}") + assert data is not None + return data + + +def load_claims_at_ref(root: Path, ref: str) -> tuple[dict[str, Any], dict[str, str]]: + raw = git_show_bytes(root, ref, CLAIMS_REL) + claims = load_json_strict(raw, where=f"{ref}:{CLAIMS_REL}") + identity = { + "ref": ref, + "path": CLAIMS_REL, + "blob_sha256": _sha256_bytes(raw), + "bytes": str(len(raw)), + } + return claims, identity + + +def load_json_at_ref(root: Path, ref: str, relpath: str) -> tuple[Any, dict[str, str]]: + raw = git_show_bytes(root, ref, relpath) + return load_json_strict(raw, where=f"{ref}:{relpath}"), { + "ref": ref, + "path": relpath, + "blob_sha256": _sha256_bytes(raw), + "bytes": str(len(raw)), + } + + +def load_historical_schema_at_ref( + root: Path, ref: str, relpath: str +) -> tuple[Any, dict[str, Any]]: + """Load old-ref schema distinguishing absence from malformation. + + Missing object → migration fallback (empty schema + absent_old_schema). + Present but duplicate-key / nonfinite / malformed JSON → AdapterError (fail closed). + """ + object_spec = f"{ref}:{relpath}" + kind = _git_bytes(root, "cat-file", "-t", object_spec, missing_ok=True) + if kind is None: + empty: Any + if relpath.endswith("AUTHORITY_MAP.json"): + empty = {"authorities": {}} + else: + empty = {"rows": []} + return empty, { + "ref": ref, + "path": relpath, + "blob_sha256": "", + "bytes": "0", + "absent_old_schema": True, + } + return load_json_at_ref(root, ref, relpath) + + +def _owner_map(crosswalk: dict[str, Any] | None) -> dict[str, str]: + """main_id → authority from crosswalk rows (absent schema → empty).""" + owners: dict[str, str] = {} + if not isinstance(crosswalk, dict): + return owners + for row in crosswalk.get("rows") or []: + if not isinstance(row, dict): + continue + main_id = row.get("main_claim_or_premise_id") + auth = row.get("authority") + if isinstance(main_id, str) and main_id and isinstance(auth, str) and auth: + owners[main_id] = auth + return owners + + +def compare_claims_paths( + before_path: Path, + after_path: Path, + *, + crosswalk_path: Path | None = None, + authority_path: Path | None = None, +) -> dict[str, Any]: + before_raw = before_path.read_bytes() + after_raw = after_path.read_bytes() + before_claims = load_json_strict(before_raw, where=str(before_path)) + after_claims = load_json_strict(after_raw, where=str(after_path)) + crosswalk = load_json(crosswalk_path) if crosswalk_path else None + authority = load_json(authority_path) if authority_path else None + old_g = claims_to_gate_graph( + before_claims, crosswalk=crosswalk, authority_map=authority + ) + new_g = claims_to_gate_graph( + after_claims, crosswalk=crosswalk, authority_map=authority + ) + impact = reverse_impact_between(old_g, new_g) + holds = aggregate_hold_proposals(new_g) + enforcement = evaluate_transition_enforcement( + old_graph=old_g, new_graph=new_g, impact=impact, holds=holds + ) + return { + "mode": "path_compare", + "before_identity": { + "path": str(before_path), + "blob_sha256": _sha256_bytes(before_raw), + "bytes": len(before_raw), + }, + "after_identity": { + "path": str(after_path), + "blob_sha256": _sha256_bytes(after_raw), + "bytes": len(after_raw), + }, + "reverse_impact": impact, + "hold_proposals": holds, + **enforcement, + "promotion_permission": False, + "scientific_effect": "NONE", + "semantic_reference": "Math- PR13/PR15 / main #90 clarification", + "meaning": ( + "path compare; unsupported new/retained controlling fails; " + "consistent demotion and noncontrolling impact remain OK" + ), + } + + +def compare_claims_refs( + before_ref: str, + after_ref: str, + *, + root: Path | None = None, +) -> dict[str, Any]: + root = root or ROOT + before_ref = _require_usable_ref(before_ref, role="before", root=root) + after_ref = _require_usable_ref(after_ref, role="after", root=root) + before_claims, before_id = load_claims_at_ref(root, before_ref) + after_claims, after_id = load_claims_at_ref(root, after_ref) + old_crosswalk, old_crosswalk_id = load_historical_schema_at_ref( + root, before_ref, CROSSWALK_REL + ) + old_authority, old_authority_id = load_historical_schema_at_ref( + root, before_ref, AUTHORITY_REL + ) + crosswalk, crosswalk_id = load_json_at_ref(root, after_ref, CROSSWALK_REL) + authority, authority_id = load_json_at_ref(root, after_ref, AUTHORITY_REL) + old_g = claims_to_gate_graph( + before_claims, crosswalk=old_crosswalk, authority_map=old_authority + ) + new_g = claims_to_gate_graph( + after_claims, crosswalk=crosswalk, authority_map=authority + ) + old_sources = bind_claims_sources_at_ref(root, before_ref, before_claims) + new_sources = bind_claims_sources_at_ref(root, after_ref, after_claims) + old_owners = _owner_map(old_crosswalk) + new_owners = _owner_map(crosswalk) + authority_seeds = sorted( + main_id + for main_id in set(old_owners) | set(new_owners) + if old_owners.get(main_id) != new_owners.get(main_id) + ) + # Seed owner drift BEFORE reverse closure so dependents (incl. controlling + # consumers) enter impacted and feed F1 enforcement. + impact = reverse_impact_between( + old_g, + new_g, + old_sources=old_sources, + new_sources=new_sources, + extra_seeds=authority_seeds, + ) + holds = aggregate_hold_proposals(new_g) + controlling_impacted = [] + for nid in impact["impacted"]: + if nid not in new_g["nodes"]: + continue + if _source_is_controlling(new_g["nodes"][nid]): + controlling_impacted.append(nid) + enforcement = evaluate_transition_enforcement( + old_graph=old_g, + new_graph=new_g, + impact=impact, + holds=holds, + old_sources=old_sources, + new_sources=new_sources, + ) + return { + "mode": "ref_compare", + "base_commit": before_ref, + "head_commit": after_ref, + "before_ref": before_ref, + "after_ref": after_ref, + "before_identity": before_id, + "after_identity": after_id, + "crosswalk_identity": crosswalk_id, + "authority_identity": authority_id, + "old_crosswalk_identity": old_crosswalk_id, + "old_authority_identity": old_authority_id, + "old_sources": old_sources, + "new_sources": new_sources, + "authority_owner_seeds": authority_seeds, + "reverse_impact": impact, + "hold_proposals": holds, + "controlling_impacted": controlling_impacted, + **enforcement, + "promotion_permission": False, + "scientific_effect": "NONE", + "semantic_reference": ( + "Math- PR13 tip baca69c… / PR15 git_transition_audit contract 8c4c946… " + "/ OpenAI trial PR128 F1 / main #90 F2–F5 / clarification" + ), + "meaning": ( + "immutable base→head claims+source-file compare (PR15 + F1–F5); " + "owner-seed reverse closure; controlling sources must be byte-monitorable; " + "malformed historical schema fails closed; never promotion permission" + ), + } + + + +def resolve_event_refs( + *, + environ: dict[str, str] | None = None, + event: dict[str, Any] | None = None, + root: Path | None = None, +) -> tuple[str, str, str]: + """Return (before_ref, after_ref, event_name). Fail closed if unavailable. + + When ``root`` is provided, refs resolve to full immutable commit IDs in that + repository. Without ``root``, only empty/all-zero rejection runs (unit harnesses); + ``compare_claims_refs`` always resolves against the audited checkout. + """ + import os + + env = environ if environ is not None else os.environ + resolve = root is not None + # Explicit overrides for sentinels / local harnesses. + override_before = env.get("CLAIMS_GATE_BEFORE_REF") + override_after = env.get("CLAIMS_GATE_AFTER_REF") + if override_before or override_after: + if not override_before or not override_after: + raise AdapterError( + "CLAIMS_GATE_BEFORE_REF and CLAIMS_GATE_AFTER_REF must both be set" + ) + return ( + _require_usable_ref( + override_before, role="before", root=root, resolve=resolve + ), + _require_usable_ref( + override_after, role="after", root=root, resolve=resolve + ), + "env_override", + ) + + event_name = (env.get("GITHUB_EVENT_NAME") or "").strip() + if event is None: + event_path = env.get("GITHUB_EVENT_PATH") + if not event_path: + raise AdapterError( + "event-compare requires GITHUB_EVENT_PATH or CLAIMS_GATE_BEFORE_REF/" + "CLAIMS_GATE_AFTER_REF (no tip self-compare fallback)" + ) + event = json.loads(Path(event_path).read_text(encoding="utf-8")) + if not isinstance(event, dict): + raise AdapterError("malformed GitHub event payload") + + if event_name == "pull_request" or "pull_request" in event: + pr = event.get("pull_request") or {} + base = (pr.get("base") or {}).get("sha") + head = (pr.get("head") or {}).get("sha") + return ( + _require_usable_ref( + str(base or ""), role="before", root=root, resolve=resolve + ), + _require_usable_ref( + str(head or ""), role="after", root=root, resolve=resolve + ), + "pull_request", + ) + if event_name == "push" or ("before" in event and "after" in event): + return ( + _require_usable_ref( + str(event.get("before") or ""), role="before", root=root, resolve=resolve + ), + _require_usable_ref( + str(event.get("after") or ""), role="after", root=root, resolve=resolve + ), + "push", + ) + raise AdapterError( + f"unsupported or missing GitHub event for claims-gate compare: {event_name!r}" + ) + + +def event_compare( + *, + root: Path | None = None, + environ: dict[str, str] | None = None, + event: dict[str, Any] | None = None, +) -> dict[str, Any]: + compare_root = root or ROOT + before_ref, after_ref, event_name = resolve_event_refs( + environ=environ, event=event, root=compare_root + ) + report = compare_claims_refs(before_ref, after_ref, root=compare_root) + report["mode"] = "event_compare" + report["event_name"] = event_name + # Keep enforcement transition_ok from compare_claims_refs. Impact/HOLD alone + # remain admissible; unsupported controlling-over-unresolved fails closed. + report["meaning"] = ( + "event-derived immutable base→head compare; REVALIDATION/HOLD proposals " + "do not block corrective edits; unsupported controlling over unresolved " + "required premises fails transition_ok; never promotion permission" + ) + return report + + +def main(argv: list[str] | None = None) -> int: + import argparse + + parser = argparse.ArgumentParser( + prog="claims_gate_adapter", + description=( + "Thin #90 claims→gate adapter. tip-health is identity-only; " + "compare/event-compare exercise real before/after inputs." + ), + ) + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser( + "tip-health", + help="Identity self-compare + HOLD inventory (NOT base→head evidence)", + ) + + compare_p = sub.add_parser( + "compare", + help="Compare two claims JSON files (path mode)", + ) + compare_p.add_argument("--before", type=Path, required=True) + compare_p.add_argument("--after", type=Path, required=True) + compare_p.add_argument("--crosswalk", type=Path, default=None) + compare_p.add_argument("--authority", type=Path, default=None) + compare_p.add_argument("--write-report", type=Path, default=None) + + refs_p = sub.add_parser( + "compare-refs", + help="Compare claims at two immutable git refs", + ) + refs_p.add_argument("--before-ref", required=True) + refs_p.add_argument("--after-ref", required=True) + refs_p.add_argument("--repo-root", type=Path, default=None) + refs_p.add_argument("--write-report", type=Path, default=None) + + event_p = sub.add_parser( + "event-compare", + help="Compare using GitHub event base/head (or CLAIMS_GATE_*_REF overrides)", + ) + event_p.add_argument("--repo-root", type=Path, default=None) + event_p.add_argument("--write-report", type=Path, default=None) + + args = parser.parse_args(argv) + + try: + if args.command == "tip-health": + report = audit_tip() + elif args.command == "compare": + if not args.before.is_file() or not args.after.is_file(): + raise AdapterError("compare --before/--after must be existing files") + report = compare_claims_paths( + args.before, + args.after, + crosswalk_path=args.crosswalk, + authority_path=args.authority, + ) + elif args.command == "compare-refs": + report = compare_claims_refs( + args.before_ref, + args.after_ref, + root=args.repo_root or ROOT, + ) + elif args.command == "event-compare": + report = event_compare(root=args.repo_root or ROOT) + else: + raise AdapterError(f"unknown command {args.command!r}") + except AdapterError as exc: + print(json.dumps({"error": str(exc), "promotion_permission": False, + "scientific_effect": "NONE"}, indent=2, sort_keys=True)) + return 1 + + write_report = getattr(args, "write_report", None) + if write_report is not None: + out = write_report.resolve() + repo_root = (getattr(args, "repo_root", None) or ROOT).resolve() + # PR15 contract: report must be new and outside the repository checkout. + try: + out.relative_to(repo_root) + inside = True + except ValueError: + inside = False + if inside: + print( + json.dumps( + { + "error": ( + f"write-report must be outside the repository " + f"(refused in-tree path {out})" + ), + "promotion_permission": False, + "scientific_effect": "NONE", + }, + indent=2, + sort_keys=True, + ) + ) + return 1 + if out.exists(): + print( + json.dumps( + { + "error": f"write-report must be a new file: {out}", + "promotion_permission": False, + "scientific_effect": "NONE", + }, + indent=2, + sort_keys=True, + ) + ) + return 1 + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + print(json.dumps(report, indent=2, sort_keys=True)) + # Diagnostics may propose HOLD without failing. Transition modes must also + # refuse unsafe actual source status; missing enforcement fails closed. + if args.command != "tip-health" and report.get("transition_ok") is not True: + return 1 + if report.get("promotion_permission") is True: + return 1 + if report.get("problems"): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/closure_pipeline.py b/tools/closure_pipeline.py index 49ac7bf48..fc602398e 100644 --- a/tools/closure_pipeline.py +++ b/tools/closure_pipeline.py @@ -41,6 +41,12 @@ 'python tools/closure_pipeline.py check-plan': ['OQ-013'], 'python tools/claims_check.py': ['OBL-D1-PROMOTE'], 'python tools/lanes_check.py': ['A1', 'A5'], + 'python tools/scientific_state_check.py': ['OQ-013'], + 'python tools/claims_gate_adapter.py tip-health': ['OBL-D1-PROMOTE', 'OQ-013'], + 'python tools/claims_gate_adapter.py event-compare --write-report /tmp/claims-gate-impact.json': [ + 'OBL-D1-PROMOTE', + 'OQ-013', + ], } LIMITS = [ 'Dated repository snapshots only; no claim of a complete or current Drive inventory.', diff --git a/tools/run_checks.py b/tools/run_checks.py index 73d27432c..633926877 100644 --- a/tools/run_checks.py +++ b/tools/run_checks.py @@ -85,6 +85,9 @@ "python tools/lpw_amplitude_check.py", "python tools/closure_pipeline.py check-plan", "python tools/math_status_check.py", + "python tools/scientific_state_check.py", + "python tools/claims_gate_adapter.py tip-health", + "python tools/claims_gate_adapter.py event-compare --write-report /tmp/claims-gate-impact.json", "python tools/navigation_check.py", "python -m unittest tests.test_navigation -v", "python -m pytest -q", diff --git a/tools/scientific_state_check.py b/tools/scientific_state_check.py new file mode 100644 index 000000000..17c97bce9 --- /dev/null +++ b/tools/scientific_state_check.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Fail-closed checker for architecture/scientific_state (main #95 pilot). + +Validates schema shape, authority ownership, ID crosswalk referential integrity +against local claims/graph.json, and refuses smuggled status/grade/classification/ +controlling payloads in this package. + +Scientific effect: NONE. This tool never promotes mathematics, flips lemma_closed, +or writes authoritative statuses. It can only REFUSE. +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PKG = ROOT / "architecture" / "scientific_state" / "v1" +CLAIMS_GRAPH = ROOT / "claims" / "graph.json" + +REQUIRED_FILES = ( + "SCHEMA.json", + "AUTHORITY_MAP.json", + "ID_CROSSWALK.json", + "VERIFICATION_LEVELS.json", +) + +FORBIDDEN_OWNED = frozenset( + { + "status", + "grade", + "classification", + "controlling", + "lemma_closed", + "prizes_solved", + "independence_credit", + } +) + +REQUIRED_SCHEMA_KEYS = frozenset( + { + "schema_id", + "schema_version", + "object", + "scientific_effect", + "node_fields", + "forbidden_owned_fields", + "orthogonal_axes", + } +) + +REQUIRED_LEVELS = ("L0", "L1", "L2", "L3", "L4", "L5") + + +def load_json(path: Path) -> Any: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def walk_forbidden(obj: Any, path: str, problems: list[str], *, allow_schema_meta: bool) -> None: + """Refuse forbidden owned field names appearing as object keys. + + SCHEMA.json may *list* the forbidden names under forbidden_owned_fields and + may document them inside node_fields notes — those meta locations are + allowed. AUTHORITY_MAP may name them under owns/never_writes lists. + """ + if isinstance(obj, dict): + for key, value in obj.items(): + child = f"{path}.{key}" if path else key + if key in FORBIDDEN_OWNED: + if allow_schema_meta and path in { + "", + "node_fields", + "forbidden_owned_fields", + }: + # SCHEMA top-level listing / documentation only. + pass + elif allow_schema_meta and path.startswith("node_fields."): + pass + else: + problems.append( + f"{path or ''}: forbidden owned field key {key!r} " + f"(statuses/grades/classifications belong to external authorities)" + ) + walk_forbidden(value, child, problems, allow_schema_meta=allow_schema_meta) + elif isinstance(obj, list): + for index, value in enumerate(obj): + walk_forbidden(value, f"{path}[{index}]", problems, allow_schema_meta=allow_schema_meta) + + +def check_schema(schema: dict[str, Any], problems: list[str]) -> None: + missing = sorted(REQUIRED_SCHEMA_KEYS - set(schema)) + for key in missing: + problems.append(f"SCHEMA.json missing required key {key!r}") + if schema.get("scientific_effect") != "NONE": + problems.append( + f"SCHEMA.json scientific_effect must be 'NONE', got {schema.get('scientific_effect')!r}" + ) + forbidden = schema.get("forbidden_owned_fields") + if not isinstance(forbidden, list) or not FORBIDDEN_OWNED.issubset(set(forbidden)): + problems.append( + "SCHEMA.json forbidden_owned_fields must include " + + ", ".join(sorted(FORBIDDEN_OWNED)) + ) + node_fields = schema.get("node_fields") + if not isinstance(node_fields, dict) or "claim_id" not in node_fields: + problems.append("SCHEMA.json node_fields.claim_id is required") + elif not node_fields["claim_id"].get("required"): + problems.append("SCHEMA.json node_fields.claim_id.required must be true") + if "source_authority" not in (node_fields or {}): + problems.append("SCHEMA.json node_fields.source_authority is required") + for derived in ("semantic_digest", "evidence_digest"): + if derived not in (node_fields or {}): + problems.append(f"SCHEMA.json node_fields.{derived} is required (v1.1)") + axes = schema.get("orthogonal_axes") + if not isinstance(axes, dict): + problems.append("SCHEMA.json orthogonal_axes must be an object") + else: + for axis in ( + "semantic_digest", + "evidence_digest", + "verification_level", + "scientific_status", + ): + if axis not in axes: + problems.append(f"SCHEMA.json orthogonal_axes missing {axis!r}") + # Refuse using verification_level as an acceptance field in the contract. + vl = (node_fields or {}).get("verification_level", {}) + role = vl.get("role") if isinstance(vl, dict) else None + if role and role != "evidence_metadata": + problems.append( + "SCHEMA.json verification_level.role must be 'evidence_metadata' " + "(level is not acceptance)" + ) + sem = (node_fields or {}).get("semantic_digest", {}) + if isinstance(sem, dict) and sem.get("role") not in {None, "derived_content_address"}: + problems.append( + "SCHEMA.json semantic_digest.role must be 'derived_content_address'" + ) + + +def check_authority_map(auth: dict[str, Any], problems: list[str]) -> None: + if auth.get("scientific_effect") != "NONE": + problems.append("AUTHORITY_MAP.json scientific_effect must be 'NONE'") + authorities = auth.get("authorities") + if not isinstance(authorities, dict): + problems.append("AUTHORITY_MAP.json authorities must be an object") + authorities = {} + this_pkg = auth.get("this_package") + if not isinstance(this_pkg, dict) or this_pkg.get("id") != "scientific_state_architecture": + problems.append("AUTHORITY_MAP.json this_package.id must be scientific_state_architecture") + never = set(this_pkg.get("never_writes", [])) if isinstance(this_pkg, dict) else set() + if not FORBIDDEN_OWNED.issubset(never): + problems.append( + "AUTHORITY_MAP.json this_package.never_writes must cover forbidden owned fields" + ) + required = auth.get("required_authority_ids") + if not isinstance(required, list): + problems.append("AUTHORITY_MAP.json required_authority_ids must be a list") + return + known = set(authorities) | ( + {this_pkg["id"]} if isinstance(this_pkg, dict) and "id" in this_pkg else set() + ) + for auth_id in required: + if auth_id not in known: + problems.append(f"AUTHORITY_MAP.json missing required authority {auth_id!r}") + for needed in ( + "claims_firewall", + "math_downstream_gate", + "human_d0_crosswalk", + "math_status_packet", + ): + if needed not in authorities: + problems.append(f"AUTHORITY_MAP.json authorities missing {needed!r}") + + +def check_verification_levels(levels_doc: dict[str, Any], problems: list[str]) -> None: + if levels_doc.get("scientific_effect") != "NONE": + problems.append("VERIFICATION_LEVELS.json scientific_effect must be 'NONE'") + levels = levels_doc.get("levels") + if not isinstance(levels, dict): + problems.append("VERIFICATION_LEVELS.json levels must be an object") + return + for level_id in REQUIRED_LEVELS: + if level_id not in levels: + problems.append(f"VERIFICATION_LEVELS.json missing {level_id}") + rule = str(levels_doc.get("rule", "")).lower() + if "acceptance" not in rule and "controlling" not in rule: + problems.append( + "VERIFICATION_LEVELS.json rule must state that level is not acceptance/CONTROLLING" + ) + + +def check_crosswalk( + crosswalk: dict[str, Any], + auth: dict[str, Any], + claims: dict[str, Any], + problems: list[str], +) -> None: + if crosswalk.get("scientific_effect") != "NONE": + problems.append("ID_CROSSWALK.json scientific_effect must be 'NONE'") + rows = crosswalk.get("rows") + if not isinstance(rows, list) or not rows: + problems.append("ID_CROSSWALK.json rows must be a non-empty list") + return + authorities = set((auth.get("authorities") or {})) | {"scientific_state_architecture"} + this_pkg = auth.get("this_package") + if isinstance(this_pkg, dict) and "id" in this_pkg: + authorities.add(this_pkg["id"]) + premises = set((claims.get("premises") or {})) + claim_ids = set((claims.get("claims") or {})) + seen_row_ids: set[str] = set() + for index, row in enumerate(rows): + if not isinstance(row, dict): + problems.append(f"ID_CROSSWALK.json rows[{index}] must be an object") + continue + # Smuggled status payloads + for banned in FORBIDDEN_OWNED: + if banned in row: + problems.append( + f"ID_CROSSWALK.json rows[{index}] smuggles owned field {banned!r}" + ) + row_id = row.get("row_id") + if not isinstance(row_id, str) or not row_id: + problems.append(f"ID_CROSSWALK.json rows[{index}] missing row_id") + elif row_id in seen_row_ids: + problems.append(f"ID_CROSSWALK.json duplicate row_id {row_id!r}") + else: + seen_row_ids.add(row_id) + authority = row.get("authority") + if authority not in authorities: + problems.append( + f"ID_CROSSWALK.json rows[{index}] unknown authority {authority!r}" + ) + main_id = row.get("main_claim_or_premise_id") + bucket = row.get("main_bucket") + math_id = row.get("math_gate_id") + if main_id is None and math_id is None: + problems.append( + f"ID_CROSSWALK.json rows[{index}] needs main_claim_or_premise_id or math_gate_id" + ) + if main_id is not None: + if bucket == "premises": + if main_id not in premises: + problems.append( + f"ID_CROSSWALK.json rows[{index}] unknown premise {main_id!r}" + ) + elif bucket == "claims": + if main_id not in claim_ids: + problems.append( + f"ID_CROSSWALK.json rows[{index}] unknown claim {main_id!r}" + ) + else: + problems.append( + f"ID_CROSSWALK.json rows[{index}] main_bucket must be " + f"'premises' or 'claims' when main id is set" + ) + if math_id is not None and not isinstance(math_id, str): + problems.append(f"ID_CROSSWALK.json rows[{index}] math_gate_id must be a string") + + +def audit(root: Path | None = None) -> dict[str, Any]: + root = root or ROOT + pkg = root / "architecture" / "scientific_state" / "v1" + problems: list[str] = [] + for name in REQUIRED_FILES: + if not (pkg / name).is_file(): + problems.append(f"missing {pkg.relative_to(root) / name}") + if problems: + return {"problems": problems, "files_checked": 0, "scientific_effect": "NONE"} + + schema = load_json(pkg / "SCHEMA.json") + auth = load_json(pkg / "AUTHORITY_MAP.json") + crosswalk = load_json(pkg / "ID_CROSSWALK.json") + levels = load_json(pkg / "VERIFICATION_LEVELS.json") + claims_path = root / "claims" / "graph.json" + if not claims_path.is_file(): + problems.append("claims/graph.json missing (required for crosswalk integrity)") + claims: dict[str, Any] = {} + else: + claims = load_json(claims_path) + + check_schema(schema, problems) + check_authority_map(auth, problems) + check_verification_levels(levels, problems) + check_crosswalk(crosswalk, auth, claims, problems) + + # Package-wide forbidden key scan (crosswalk + authority this_package already covered). + walk_forbidden(crosswalk, "ID_CROSSWALK", problems, allow_schema_meta=False) + # SCHEMA may document forbidden names. + walk_forbidden(schema, "", problems, allow_schema_meta=True) + + return { + "problems": problems, + "files_checked": len(REQUIRED_FILES), + "crosswalk_rows": len(crosswalk.get("rows") or []), + "scientific_effect": "NONE", + "scope": "architecture/scientific_state pointers only; not mathematical acceptance", + } + + +def main(argv: list[str] | None = None) -> int: + report = audit() + print(json.dumps(report, indent=2, sort_keys=True)) + return 1 if report["problems"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/semantic_digest.py b/tools/semantic_digest.py new file mode 100644 index 000000000..28a171662 --- /dev/null +++ b/tools/semantic_digest.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Canonical semantic / evidence digests for scientific-state nodes (#95 v1.1). + +Four orthogonal axes (digest match never promotes mathematics): + semantic_digest = H(canonical scientific content + typed deps) + evidence_digest = H(evidence identities) + verification_level = L0–L5 evidence metadata (separate) + scientific_status = owned by external authorities only (never derived here) + +Manual fingerprints may remain provenance; they are not the sole change detector. +Scientific effect: NONE. +""" +from __future__ import annotations + +import hashlib +import json +from typing import Any + + +class DigestError(ValueError): + """Fail-closed digest / typed-edge refusal.""" + + +def _require_strict_bool(value: Any, *, field: str) -> bool: + if type(value) is not bool: + raise DigestError( + f"{field} must be a strict boolean, got {type(value).__name__}: {value!r}" + ) + return value + + +def _sha256_canonical(payload: Any) -> str: + blob = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(blob.encode("utf-8")).hexdigest() + + +def normalize_typed_edge(edge: dict[str, Any] | str, *, default_relation: str) -> dict[str, Any]: + """Normalize a depends_on / sub_obligation entry into a typed edge record.""" + if isinstance(edge, str): + return { + "target_id": edge, + "relation": default_relation, + "required": True, + } + if not isinstance(edge, dict): + raise DigestError("typed edge must be a string id or object") + target = edge.get("target_id") or edge.get("to") or edge.get("id") + if not isinstance(target, str) or not target: + raise DigestError("typed edge missing target_id") + relation = edge.get("relation") or edge.get("edge_type") or default_relation + required = edge["required"] if "required" in edge else True + _require_strict_bool(required, field="required") + return { + "target_id": target, + "relation": relation, + "required": required, + } + + +def validate_typed_edge(edge: dict[str, Any]) -> None: + if not isinstance(edge, dict): + raise DigestError("typed edge must be an object") + if not isinstance(edge.get("target_id"), str) or not edge["target_id"]: + raise DigestError("typed edge missing target_id") + if not isinstance(edge.get("relation"), str) or not edge["relation"]: + raise DigestError("typed edge missing relation") + _require_strict_bool(edge.get("required"), field="required") + + +def validate_source_binding(binding: dict[str, Any]) -> None: + """Canonical source binding: owner/repo/path + immutable commit/blob/hash when present.""" + if not isinstance(binding, dict): + raise DigestError("source binding must be an object") + path = binding.get("path") + if not isinstance(path, str) or not path: + raise DigestError("source binding missing path") + # Prefer structured owner/repo; accept repo string that already includes owner/name. + has_owner_repo = ( + (isinstance(binding.get("owner"), str) and isinstance(binding.get("repo"), str)) + or isinstance(binding.get("repo"), str) + ) + if not has_owner_repo: + raise DigestError("source binding missing owner/repo") + immutable = ( + binding.get("commit") + or binding.get("blob") + or binding.get("hash") + or binding.get("sha256") + ) + if immutable is not None and not isinstance(immutable, str): + raise DigestError("source binding immutable identity must be a string when present") + + +def _typed_edges_from_record(record: dict[str, Any]) -> list[dict[str, Any]]: + edges: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + + def container(field: str) -> list[Any]: + if field not in record: + return [] + value = record[field] + if type(value) is not list: + raise DigestError( + f"{field} must be a list, got {type(value).__name__}: {value!r}" + ) + return value + + for raw in container("depends_on"): + edge = normalize_typed_edge(raw, default_relation="depends_on") + validate_typed_edge(edge) + key = (edge["target_id"], edge["relation"]) + if key in seen: + raise DigestError( + f"duplicate typed edge {edge['target_id']!r} ({edge['relation']})" + ) + seen.add(key) + edges.append(edge) + for raw in container("sub_obligations"): + edge = normalize_typed_edge(raw, default_relation="sub_obligation") + validate_typed_edge(edge) + key = (edge["target_id"], edge["relation"]) + if key in seen: + raise DigestError( + f"duplicate typed edge {edge['target_id']!r} ({edge['relation']})" + ) + seen.add(key) + edges.append(edge) + return sorted(edges, key=lambda e: (e["relation"], e["target_id"], e["required"])) + + +def _source_bindings_from_record(record: dict[str, Any]) -> list[dict[str, Any]]: + bindings: list[dict[str, Any]] = [] + # Prefer explicit source_bindings. Do not fold `evidence` into the semantic + # axis — evidence identities belong exclusively to evidence_digest. + raw = record.get("source_bindings") or [] + if isinstance(raw, dict): + raw = [raw] + if not isinstance(raw, list): + raise DigestError("source_bindings must be a list or object") + for item in raw: + if isinstance(item, str): + bindings.append({"path": item, "legacy_string": True}) + continue + if not isinstance(item, dict): + raise DigestError("source binding entries must be objects or strings") + if "path" in item: + bindings.append( + { + "owner": item.get("owner"), + "repo": item.get("repo"), + "path": item.get("path"), + "commit": item.get("commit"), + "blob": item.get("blob"), + "hash": item.get("hash") or item.get("sha256"), + } + ) + else: + bindings.append({"legacy": item}) + # Scalar source / canon_source pointers remain part of scientific content. + for key in ("source", "canon_source"): + if key in record and record[key] is not None: + bindings.append({"scalar_key": key, "value": record[key]}) + return bindings + + +def canonical_semantic_payload(claim_id: str, record: dict[str, Any]) -> dict[str, Any]: + """Normalized scientific content used for semantic_digest. + + claim_id + statement/version + scope/domain + canonical source bindings + + typed dependency/sub_obligation records. Excludes grades/statuses/controlling. + """ + if not isinstance(claim_id, str) or not claim_id: + raise DigestError("claim_id required") + if not isinstance(record, dict): + raise DigestError("record must be an object") + return { + "claim_id": claim_id, + "statement": record.get("statement") or record.get("note"), + "version": record.get("version") or record.get("as_of"), + "scope": record.get("scope"), + "domain": record.get("domain"), + "source_bindings": _source_bindings_from_record(record), + "typed_edges": _typed_edges_from_record(record), + } + + +def semantic_digest(claim_id: str, record: dict[str, Any]) -> str: + """H(canonical scientific content). Digest match ≠ mathematical acceptance.""" + return _sha256_canonical(canonical_semantic_payload(claim_id, record)) + + +def evidence_digest(record: dict[str, Any]) -> str: + """H(evidence identities only). Separate from semantic_digest.""" + if not isinstance(record, dict): + raise DigestError("record must be an object") + evidence = record.get("evidence") or record.get("source_bindings") or [] + if isinstance(evidence, dict): + evidence = [evidence] + identities: list[Any] = [] + for item in evidence: + if isinstance(item, str): + identities.append({"path": item}) + elif isinstance(item, dict): + identities.append( + { + "owner": item.get("owner"), + "repo": item.get("repo"), + "path": item.get("path"), + "commit": item.get("commit"), + "blob": item.get("blob"), + "hash": item.get("hash") or item.get("sha256"), + } + ) + else: + raise DigestError("evidence entry must be string or object") + return _sha256_canonical({"evidence_identities": identities}) + + +def migrate_legacy_fingerprint( + claim_id: str, + record: dict[str, Any], + *, + legacy_fingerprint: str | None = None, +) -> dict[str, Any]: + """Crosswalk a legacy fingerprint-bearing node without rewriting the source record. + + Returns derived digests + provenance of the old fingerprint. Does not mutate + `record` and does not treat fingerprint equality as scientific acceptance. + """ + derived = semantic_digest(claim_id, record) + return { + "claim_id": claim_id, + "semantic_digest": derived, + "evidence_digest": evidence_digest(record), + "legacy_fingerprint": legacy_fingerprint, + "legacy_fingerprint_is_sole_detector": False, + "scientific_effect": "NONE", + "meaning": "migration projection; digest match is not acceptance", + }