File size: 11,933 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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
"""
Forward refutation propagation — the core of FALSIFY's belief revision.

When a piece of Evidence is refuted, the conclusions that *depend on it* can no
longer stand. This module walks the dependency structure and flips the truth-state
of every node that transitively rests on the refuted evidence, then re-scores the
competing hypotheses.

Why this can't be done by RAG
-----------------------------
Vector similarity has no notion of "this fact supports that conclusion three hops
away." Refutation propagation is *graph traversal over typed edges* — it is exactly
the thing a knowledge graph can do and an embedding index cannot. This is FALSIFY's
differentiator and maps directly to the hackathon's "Best Use of Cognee" criterion.

Direction of travel (critical detail)
-------------------------------------
The ``depends_on`` edge points **Conclusion -> Evidence** (a conclusion depends on
the evidence it rests on). So to find what *breaks* when Evidence ``E`` is refuted,
we look for ``depends_on`` edges whose **target** is ``E``; their **sources** are the
dependent Conclusions. We then recurse: a newly-invalidated Conclusion may itself be
the target of further ``depends_on`` edges.

Correctness cases handled (REQUIREMENTS §4.3)
--------------------------------------------
* **Cycle safety** — a ``visited`` set guarantees termination on cyclic graphs.
* **Critical vs non-critical** — only a ``critical: true`` dependency can invalidate
  a conclusion. A non-critical dependency being refuted decays confidence but the
  conclusion stays ``alive``.
* **Diamond / partial refutation** — a conclusion with several critical supporters is
  invalidated only when it loses its **last** alive critical supporter. If an
  alternative critical support is still alive, the conclusion survives (and the
  refuted evidence is *retained*, because it still feeds a live node).
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set

from falsify import graph_ops
from falsify.edges import (
    DEPENDENCY_EDGE_TYPES,
    DEPENDS_ON,
    SUPPORTS,
    is_critical_dependency,
)
from falsify.models import TruthState

logger = logging.getLogger("falsify.propagate")

# Truth states that count as "dead" for the purpose of dependency support.
_DEAD_STATES = {TruthState.REFUTED.value, TruthState.INVALIDATED.value, TruthState.FORGOTTEN.value}


@dataclass
class PropagationResult:
    """Outcome of a refutation cascade.

    Attributes:
        refuted: evidence node ids set to ``refuted`` (the cascade seeds).
        invalidated: conclusion node ids set to ``invalidated`` by the cascade.
        weakened: conclusion ids whose confidence decayed but stayed ``alive``
            (a non-critical dependency was refuted).
        epoch: the revision epoch stamped on every state change in this cascade.
        affected: convenience union of refuted + invalidated ids (the death set
            candidates for :mod:`falsify.tasks.cascade_forget`).
    """

    refuted: List[str] = field(default_factory=list)
    invalidated: List[str] = field(default_factory=list)
    weakened: List[str] = field(default_factory=list)
    epoch: int = 0

    @property
    def affected(self) -> List[str]:
        return list(dict.fromkeys(self.refuted + self.invalidated))


async def _next_epoch() -> int:
    """Return a monotonically increasing revision epoch.

    We derive it from the current maximum ``truth_epoch`` present on any node so the
    counter survives restarts (state is persisted on nodes). Falls back to 1.
    """
    try:
        nodes, _edges = await graph_ops.load_graph()
        max_epoch = 0
        for _nid, props in nodes:
            ep = props.get("truth_epoch")
            if isinstance(ep, int) and ep > max_epoch:
                max_epoch = ep
        return max_epoch + 1
    except Exception as exc:  # pragma: no cover - defensive
        logger.debug("epoch derivation failed (%s); defaulting to 1", exc)
        return 1


async def propagate_refutation(
    refuted_evidence_ids: List[str],
    epoch: Optional[int] = None,
) -> PropagationResult:
    """Refute the given evidence and cascade the consequence forward.

    Args:
        refuted_evidence_ids: evidence node ids directly contradicted by a new fact.
        epoch: optional explicit revision epoch; if omitted a fresh one is derived.

    Returns:
        A :class:`PropagationResult` describing what changed. All state changes are
        persisted on the graph nodes via ``set_node_truth_state`` (so they survive a
        process restart — the basis of cross-session belief revision).

    Algorithm — grounded least-fixpoint justification
    -------------------------------------------------
    A conclusion is *justified* only if it has a **critical** ``depends_on`` support
    chain that bottoms out in a still-alive node. We therefore:

        1. Mark each seed evidence ``refuted``.
        2. Build the "dead" set = seeds plus anything already refuted / invalidated /
           superseded from prior revisions.
        3. Compute the GROUNDED set as a least fixpoint: a node is grounded if it is
           not dead and either (a) it has no critical ``depends_on`` edges (a base
           node — evidence, or a conclusion resting only on non-critical support) or
           (b) at least one of its critical dependencies is itself grounded. Iterate
           to convergence.
        4. Every *conclusion* (a node that is the source of a ``depends_on`` edge)
           that is currently alive but **not** grounded is ``invalidated``.
        5. A conclusion that survives (stays grounded) yet lost some dependency to the
           dead set is merely ``weakened`` (confidence decayed).

    This single formulation is correct for chains, diamonds (survives while any
    critical alternative is grounded), partial/non-critical refutation, **and cycles**
    (a mutually-supporting loop with no grounded base is not justified, so it
    collapses) — the fixpoint terminates because ``grounded`` only ever grows.
    """
    result = PropagationResult(epoch=epoch if epoch is not None else await _next_epoch())
    seeds = [str(e) for e in refuted_evidence_ids if e]
    if not seeds:
        logger.info("propagate_refutation called with no seeds; nothing to do")
        return result

    nodes, edges = await graph_ops.load_graph()
    node_ids = [str(nid) for nid, _p in nodes]

    # 1) seed refutations (persisted)
    for ev_id in seeds:
        await graph_ops.set_state(ev_id, TruthState.REFUTED, result.epoch)
        await graph_ops.set_weight(ev_id, 0.0)
        result.refuted.append(ev_id)
        logger.info("refuted evidence %s", ev_id)

    # 2) dead set = seeds + already-dead-from-prior-revisions
    truth = await graph_ops.get_truth(node_ids)
    dead: Set[str] = set(seeds)
    for nid in node_ids:
        alignment = truth.get(nid, [TruthState.ALIVE.value])
        if any(s in _DEAD_STATES or s == TruthState.SUPERSEDED.value for s in alignment):
            dead.add(nid)

    # Dependency structure: node -> critical / all depends_on targets.
    conclusions: Set[str] = set()
    critical_targets: Dict[str, Set[str]] = {}
    all_targets: Dict[str, Set[str]] = {}
    for src, dst, rel, props in edges:
        if rel not in DEPENDENCY_EDGE_TYPES:
            continue
        s, d = str(src), str(dst)
        conclusions.add(s)
        all_targets.setdefault(s, set()).add(d)
        if is_critical_dependency(rel, props):
            critical_targets.setdefault(s, set()).add(d)

    def _has_critical(n: str) -> bool:
        return bool(critical_targets.get(n))

    # 3) grounded least fixpoint
    grounded: Set[str] = {nid for nid in node_ids if nid not in dead and not _has_critical(nid)}
    changed = True
    while changed:
        changed = False
        for c in conclusions:
            if c in grounded or c in dead:
                continue
            if critical_targets.get(c, set()) & grounded:
                grounded.add(c)
                changed = True

    # 4) invalidate currently-alive conclusions that lost grounding
    for c in conclusions:
        if c in grounded:
            continue
        alignment = truth.get(c, [TruthState.ALIVE.value])
        if TruthState.ALIVE.value not in alignment:
            continue  # already dead in a prior revision; don't re-report
        await graph_ops.set_state(c, TruthState.INVALIDATED, result.epoch)
        await graph_ops.set_weight(c, 0.0)
        result.invalidated.append(c)
        logger.info("invalidated conclusion %s (lost grounded critical support)", c)

    # 5) weaken survivors that lost some dependency to the dead set
    for c in conclusions:
        if c not in grounded:
            continue
        if all_targets.get(c, set()) & dead:
            result.weakened.append(c)
            await graph_ops.set_weight(c, 0.3)
            logger.info("weakened conclusion %s (lost a dependency but stays grounded)", c)

    logger.info(
        "propagation done: refuted=%d invalidated=%d weakened=%d epoch=%d",
        len(result.refuted),
        len(result.invalidated),
        len(result.weakened),
        result.epoch,
    )
    return result


async def promote_competing_hypothesis(
    refuted_evidence_ids: List[str],
    epoch: int,
) -> Dict[str, str]:
    """Demote hypotheses whose support just died; promote the strongest survivor.

    A hypothesis is ``superseded`` when every ``supports`` Evidence pointing at it is
    now dead. Among the hypotheses still holding at least one alive ``supports`` edge,
    the one with the greatest summed support ``weight`` is promoted (its feedback
    weight is boosted) and becomes the new frontier answer.

    Returns a dict mapping hypothesis id -> action (``"superseded"`` / ``"promoted"``).
    """
    actions: Dict[str, str] = {}
    _nodes, edges = await graph_ops.load_graph()

    # Collect hypotheses that are the target of any supports edge.
    supports_edges = [(s, d, p) for (s, d, r, p) in edges if r == SUPPORTS]
    hypothesis_ids = {str(d) for (_s, d, _p) in supports_edges}
    if not hypothesis_ids:
        return actions

    # Determine current dead evidence set (seeds + anything already refuted/invalidated).
    all_ids = list({str(s) for (s, _d, _p) in supports_edges} | {str(e) for e in refuted_evidence_ids})
    truth = await graph_ops.get_truth(all_ids)

    def _is_dead(node_id: str) -> bool:
        alignment = truth.get(str(node_id), [TruthState.ALIVE.value])
        return any(state in _DEAD_STATES for state in alignment) or str(node_id) in {
            str(e) for e in refuted_evidence_ids
        }

    # Score each hypothesis by its surviving support.
    live_support: Dict[str, float] = {}
    for hyp_id in hypothesis_ids:
        total = 0.0
        for (src, dst, props) in supports_edges:
            if str(dst) != hyp_id:
                continue
            if _is_dead(src):
                continue
            total += float(props.get("weight", 0.5))
        live_support[hyp_id] = total

    # Demote hypotheses with zero surviving support.
    for hyp_id, score in live_support.items():
        if score <= 0.0:
            await graph_ops.set_state(hyp_id, TruthState.SUPERSEDED, epoch)
            await graph_ops.set_weight(hyp_id, 0.0)
            actions[hyp_id] = "superseded"
            logger.info("superseded hypothesis %s (no surviving support)", hyp_id)

    # Promote the strongest surviving hypothesis, if any.
    survivors = {h: s for h, s in live_support.items() if s > 0.0}
    if survivors:
        winner = max(survivors, key=survivors.get)
        await graph_ops.set_weight(winner, 1.0)
        actions[winner] = "promoted"
        logger.info("promoted hypothesis %s (support=%.2f) as new frontier", winner, survivors[winner])

    return actions