File size: 6,539 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
"""
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_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]