File size: 8,313 Bytes
1605cbb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""
FALSIFY core-logic tests — belief revision without any API key or database.

Each test builds a small graph via the :class:`~tests.conftest.FakeGraph` fixture,
runs the real cascade/forget code, and asserts on the resulting truth-states. These
cover the correctness cases enumerated in REQUIREMENTS.md §4.3:

    * direct refutation                       (test_direct_refutation)
    * forward cascade invalidates conclusion  (test_forward_cascade_invalidates)
    * diamond / partial refutation            (test_diamond_partial_refutation)
    * non-critical dependency survives         (test_non_critical_dependency_survives)
    * cycle safety (termination)              (test_cycle_safety)
    * surgical forget of orphans only         (test_forget_orphan_keeps_provenance)
    * hypothesis demote/promote               (test_promote_competing_hypothesis)
"""

from __future__ import annotations

import pytest

from falsify.edges import DEPENDS_ON, SUPERSEDES, SUPPORTS
from falsify.models import TruthState
from falsify.tasks.cascade_forget import cascade_forget
from falsify.tasks.propagate_refutation import (
    promote_competing_hypothesis,
    propagate_refutation,
)

pytestmark = pytest.mark.asyncio


async def _alignment(g, nid: str):
    entry = g.truth.get(str(nid))
    return entry["truth_alignment"] if entry else None


async def test_direct_refutation(fake_graph):
    """Refuting an evidence node sets its truth_alignment to ['refuted']."""
    g = fake_graph
    g.add_node("E", claim="the March QA report documents the defect")

    res = await propagate_refutation(["E"])

    assert res.refuted == ["E"]
    assert await _alignment(g, "E") == [TruthState.REFUTED.value]


async def test_forward_cascade_invalidates(fake_graph):
    """A→B→C depends_on chain: refuting the base evidence invalidates the chain.

    Topology (depends_on points Conclusion→Evidence):
        C  --depends_on(critical)-->  B  --depends_on(critical)-->  E(evidence)
    Refuting E must invalidate B, then C.
    """
    g = fake_graph
    g.add_node("E", claim="base evidence")
    g.add_node("B", statement="mid conclusion")
    g.add_node("C", statement="top conclusion")
    g.add_edge("B", "E", DEPENDS_ON, critical=True)
    g.add_edge("C", "B", DEPENDS_ON, critical=True)

    res = await propagate_refutation(["E"])

    assert await _alignment(g, "E") == [TruthState.REFUTED.value]
    assert await _alignment(g, "B") == [TruthState.INVALIDATED.value]
    assert await _alignment(g, "C") == [TruthState.INVALIDATED.value]
    assert set(res.invalidated) == {"B", "C"}


async def test_diamond_partial_refutation(fake_graph):
    """A conclusion with two critical supporters survives losing only one.

    K depends_on E1 (critical) AND E2 (critical). Refuting only E1 must keep K alive
    (E2 still supports it); refuting E2 as well then invalidates K.
    """
    g = fake_graph
    g.add_node("E1", claim="evidence one")
    g.add_node("E2", claim="evidence two")
    g.add_node("K", statement="conclusion on both")
    g.add_edge("K", "E1", DEPENDS_ON, critical=True)
    g.add_edge("K", "E2", DEPENDS_ON, critical=True)

    # First refutation: K keeps an alive critical supporter (E2) -> stays alive.
    await propagate_refutation(["E1"])
    assert await _alignment(g, "E1") == [TruthState.REFUTED.value]
    assert await _alignment(g, "K") == [TruthState.ALIVE.value]

    # Second refutation: K loses its last critical supporter -> invalidated.
    await propagate_refutation(["E2"])
    assert await _alignment(g, "K") == [TruthState.INVALIDATED.value]


async def test_diamond_demo_two_phase(fake_graph):
    """Full --diamond demo scenario: single-dep K dies in phase 1 while the
    diamond K2 survives, then K2 collapses in phase 2.

    Topology mirrors seed.build_diamond_investigation():
        K  --depends_on(critical)--> E_qa                 (single leg)
        K2 --depends_on(critical)--> E_qa                 (diamond leg 1)
        K2 --depends_on(critical)--> E_email              (diamond leg 2)

    This is the discriminating case the naive-cascade strawman gets wrong:
    refuting E_qa must kill K but NOT K2 (E_email still grounds it).
    """
    g = fake_graph
    g.add_node("E_qa", claim="QA report March 2021")
    g.add_node("E_email", claim="supplier email Jan 2021")
    g.add_node("K", statement="knew by March 2021")
    g.add_node("K2", statement="multiple sources confirm pre-recall knowledge")
    g.add_edge("K", "E_qa", DEPENDS_ON, critical=True)
    g.add_edge("K2", "E_qa", DEPENDS_ON, critical=True)
    g.add_edge("K2", "E_email", DEPENDS_ON, critical=True)

    # Phase 1: refute E_qa. K (single leg) dies; K2 survives on E_email.
    res1 = await propagate_refutation(["E_qa"])
    assert await _alignment(g, "E_qa") == [TruthState.REFUTED.value]
    assert await _alignment(g, "K") == [TruthState.INVALIDATED.value]
    assert await _alignment(g, "K2") == [TruthState.ALIVE.value]  # the whole point
    assert "K" in res1.invalidated
    assert "K2" not in res1.invalidated

    # Phase 2: refute E_email. K2 loses its last leg and finally collapses.
    res2 = await propagate_refutation(["E_email"])
    assert await _alignment(g, "E_email") == [TruthState.REFUTED.value]
    assert await _alignment(g, "K2") == [TruthState.INVALIDATED.value]
    assert "K2" in res2.invalidated


async def test_non_critical_dependency_survives(fake_graph):
    """Refuting a NON-critical dependency weakens but does not invalidate."""
    g = fake_graph
    g.add_node("E", claim="soft evidence")
    g.add_node("K", statement="conclusion softly resting on E")
    g.add_edge("K", "E", DEPENDS_ON, critical=False)

    res = await propagate_refutation(["E"])

    assert await _alignment(g, "K") == [TruthState.ALIVE.value]
    assert "K" in res.weakened
    assert "K" not in res.invalidated


async def test_cycle_safety(fake_graph):
    """A cyclic depends_on graph terminates (visited set) and doesn't hang."""
    g = fake_graph
    g.add_node("E", claim="evidence")
    g.add_node("X", statement="X")
    g.add_node("Y", statement="Y")
    # Cycle among conclusions, all critically resting on E and each other.
    g.add_edge("X", "E", DEPENDS_ON, critical=True)
    g.add_edge("Y", "X", DEPENDS_ON, critical=True)
    g.add_edge("X", "Y", DEPENDS_ON, critical=True)

    res = await propagate_refutation(["E"])  # must return, not loop forever

    assert await _alignment(g, "E") == [TruthState.REFUTED.value]
    assert "X" in res.invalidated and "Y" in res.invalidated


async def test_forget_orphan_keeps_provenance(fake_graph):
    """cascade_forget deletes an orphaned invalidated conclusion but keeps the
    refuted evidence that is a supersedes-anchor (provenance tombstone)."""
    g = fake_graph
    g.add_node("E", claim="refuted evidence")
    g.add_node("K", statement="orphaned conclusion")
    g.add_node("NEW", claim="the new fact")
    g.add_edge("K", "E", DEPENDS_ON, critical=True)
    g.add_edge("NEW", "E", SUPERSEDES, confidence=0.9)  # NEW (alive) supersedes E

    # Run the cascade, then forget.
    prop = await propagate_refutation(["E"])
    forget = await cascade_forget(prop.affected)

    # K is orphaned (no alive consumer) -> deleted from both stores.
    assert "K" in forget.forgotten
    assert "K" in g.deleted
    assert g.deleted_from_collections  # vector collections were targeted
    # E is refuted but retained as the supersedes provenance anchor.
    assert "E" in forget.retained_provenance
    assert "E" not in g.deleted


async def test_promote_competing_hypothesis(fake_graph):
    """When A's only support dies, A is superseded and rival B is promoted."""
    g = fake_graph
    g.add_node("E_a", claim="evidence for A")
    g.add_node("E_b", claim="evidence for B")
    g.add_node("A", statement="hypothesis A")
    g.add_node("B", statement="hypothesis B")
    g.add_edge("E_a", "A", SUPPORTS, weight=0.8)
    g.add_edge("E_b", "B", SUPPORTS, weight=0.7)

    # Refute A's evidence, then re-score hypotheses.
    prop = await propagate_refutation(["E_a"])
    actions = await promote_competing_hypothesis(["E_a"], prop.epoch)

    assert actions.get("A") == "superseded"
    assert actions.get("B") == "promoted"
    assert await _alignment(g, "A") == [TruthState.SUPERSEDED.value]