Spaces:
Sleeping
Sleeping
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
|