"""Cascade-invalidation scenarios for the derivation graph (``mnema.derive.graph``). The derivation graph records, for every claim, which evidence and which other claims it was derived from. These tests pin the cascade contract: * invalidating a claim mechanically invalidates everything derived from it, transitively; * refuting a claim marks the claim itself ``refuted`` and its descendants ``invalidated``; * unrelated branches are never touched; * a claim with multiple inputs falls when *any* of its inputs falls (conservative semantics — re-derivation may later restore it from the surviving inputs, but the stale composite must not remain active). """ import pytest from mnema.derive.graph import DerivationGraph INVALID_STATES = {"invalidated", "refuted"} def _ids(items): """Normalise a collection of claims-or-ids into a set of id strings.""" out = set() for item in items or (): out.add(item if isinstance(item, str) else getattr(item, "claim_id")) return out # --------------------------------------------------------------------------- # Fixtures: two canonical topologies # # chain: ev-1 -> A -> B -> C (plus D <- ev-2, unrelated) # diamond: ev-1 -> A -> {B, C} -> D (D depends on BOTH B and C) # --------------------------------------------------------------------------- @pytest.fixture def chain_graph(claim_factory): g = DerivationGraph() for cid, inputs in ( ("A", ["ev-1"]), ("B", ["A"]), ("C", ["B"]), ("D", ["ev-2"]), ): g.add_claim(claim_factory(cid, inputs)) return g @pytest.fixture def diamond_graph(claim_factory): g = DerivationGraph() for cid, inputs in ( ("A", ["ev-1"]), ("B", ["A"]), ("C", ["A"]), ("D", ["B", "C"]), ): g.add_claim(claim_factory(cid, inputs)) return g # --------------------------------------------------------------------------- # Structure queries # --------------------------------------------------------------------------- class TestStructure: def test_get_returns_claim(self, chain_graph): claim = chain_graph.get("A") assert claim.claim_id == "A" assert claim.status == "active" def test_inputs_query(self, chain_graph): assert _ids(chain_graph.inputs("A")) == {"ev-1"} assert _ids(chain_graph.inputs("B")) == {"A"} assert _ids(chain_graph.inputs("C")) == {"B"} def test_dependents_query(self, chain_graph): assert _ids(chain_graph.dependents("A")) == {"B"} assert _ids(chain_graph.dependents("B")) == {"C"} assert _ids(chain_graph.dependents("C")) == set() def test_external_evidence_inputs_are_tolerated(self, claim_factory): # Evidence ids referenced in provenance are not themselves graph # claims; the graph must treat them as external leaves, not errors. g = DerivationGraph() g.add_claim(claim_factory("X", ["ev-unknown-1", "ev-unknown-2"])) assert _ids(g.inputs("X")) == {"ev-unknown-1", "ev-unknown-2"} assert g.get("X").status == "active" # --------------------------------------------------------------------------- # Linear cascade # --------------------------------------------------------------------------- class TestLinearCascade: def test_invalidating_root_cascades_down_the_chain(self, chain_graph): affected = _ids(chain_graph.invalidate("A")) # B and C must be in the affected set; whether A itself is reported # is an implementation detail, but nothing else may appear. assert {"B", "C"} <= affected <= {"A", "B", "C"} for cid in ("A", "B", "C"): assert chain_graph.get(cid).status in INVALID_STATES def test_unrelated_branch_is_untouched(self, chain_graph): chain_graph.invalidate("A") assert chain_graph.get("D").status == "active" def test_invalidating_midpoint_spares_ancestors(self, chain_graph): chain_graph.invalidate("B") assert chain_graph.get("A").status == "active" assert chain_graph.get("B").status in INVALID_STATES assert chain_graph.get("C").status in INVALID_STATES def test_invalidation_is_idempotent(self, chain_graph): first = _ids(chain_graph.invalidate("A")) assert {"B", "C"} <= first # Re-invalidating must not raise and must not flip statuses back. second = _ids(chain_graph.invalidate("A")) assert second <= {"A", "B", "C"} for cid in ("A", "B", "C"): assert chain_graph.get(cid).status in INVALID_STATES assert chain_graph.get("D").status == "active" # --------------------------------------------------------------------------- # Diamond cascade # --------------------------------------------------------------------------- class TestDiamondCascade: def test_full_cascade_from_root(self, diamond_graph): affected = _ids(diamond_graph.invalidate("A")) assert {"B", "C", "D"} <= affected <= {"A", "B", "C", "D"} for cid in ("A", "B", "C", "D"): assert diamond_graph.get(cid).status in INVALID_STATES def test_multi_input_claim_falls_when_any_input_falls(self, diamond_graph): # Refute only B. D depends on both B and C, so D must fall even # though C survives — the stale composite must not remain active. diamond_graph.refute("B") assert diamond_graph.get("B").status == "refuted" assert diamond_graph.get("D").status in INVALID_STATES # Siblings and ancestors of the refuted claim are NOT touched. assert diamond_graph.get("C").status == "active" assert diamond_graph.get("A").status == "active" def test_refute_marks_root_refuted_and_descendants_invalidated(self, diamond_graph): diamond_graph.refute("A") assert diamond_graph.get("A").status == "refuted" for cid in ("B", "C", "D"): assert diamond_graph.get(cid).status == "invalidated" # --------------------------------------------------------------------------- # Active-claim filtering # --------------------------------------------------------------------------- class TestActiveClaims: def test_all_active_initially(self, chain_graph): assert _ids(chain_graph.active_claims()) == {"A", "B", "C", "D"} def test_cascade_removes_from_active_set(self, chain_graph): chain_graph.invalidate("A") assert _ids(chain_graph.active_claims()) == {"D"} def test_refutation_removes_from_active_set(self, diamond_graph): diamond_graph.refute("B") active = _ids(diamond_graph.active_claims()) assert "B" not in active assert "D" not in active assert {"A", "C"} <= active