Falsify / falsify /tasks /cascade_forget.py
Aaryan Kumar
deploy to hugging face
1605cbb
Raw
History Blame Contribute Delete
6.24 kB
"""
Surgical forget — prune orphaned dead-ends from graph **and** vector stores.
After :mod:`falsify.tasks.propagate_refutation` marks nodes ``refuted`` /
``invalidated``, some of those nodes are still useful: they explain *why* a belief
changed (provenance) or they still feed a node that is alive. Others are pure
dead-ends with no surviving consumer. FALSIFY hard-deletes only the latter, from
both the graph (``delete_nodes``) and the vector index (``delete_data_points``), so a
subsequent ``recall()`` — and even a raw vector search — can never resurface them.
Orphan rule (REQUIREMENTS §1.5) — a node is FORGOTTEN iff ALL hold:
(a) truth state is ``refuted`` or ``invalidated`` (never ``alive``/``superseded``;
superseded nodes are kept as provenance), AND
(b) no surviving ALIVE node reaches it via ``depends_on`` or ``supports``
(it has no live consumer), AND
(c) it is NOT the target of a ``supersedes`` edge FROM an alive node (such a node
is the provenance anchor of the new truth and must be retained as a tombstone).
The asymmetry is deliberate and is what makes FALSIFY look *surgical*: in the demo the
orphaned Conclusion K is deleted, while the refuted Evidence E_qa is kept — flagged
red — because it is the supersedes-anchor of the new fact.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Set
from falsify import graph_ops
from falsify.edges import CONSUMER_EDGE_TYPES, SUPERSEDES
from falsify.models import (
Conclusion,
Evidence,
TruthState,
)
logger = logging.getLogger("falsify.forget")
_DELETABLE_STATES = {TruthState.REFUTED.value, TruthState.INVALIDATED.value}
_ALIVE = TruthState.ALIVE.value
# Vector collections FALSIFY writes to (``"{ClassName}_{embeddable_field}"``).
# Deleting an id from a collection it isn't in is a best-effort no-op.
_VECTOR_COLLECTIONS = [
"Evidence_claim",
"Conclusion_statement",
"Hypothesis_statement",
"Assertion_text",
"InvestigationQuestion_question",
]
@dataclass
class ForgetResult:
"""Outcome of a forget pass.
Attributes:
forgotten: node ids hard-deleted from graph + vector.
retained_provenance: refuted/invalidated ids deliberately kept (a supersedes
anchor, or still feeding a live node).
labels: id -> human label for the forgotten nodes (for demo output).
"""
forgotten: List[str] = field(default_factory=list)
retained_provenance: List[str] = field(default_factory=list)
labels: Dict[str, str] = field(default_factory=dict)
async def _alive_consumer_exists(node_id: str, edges, truth: Dict[str, List[str]]) -> bool:
"""True if some ALIVE node reaches ``node_id`` via depends_on/supports.
``depends_on`` (Conclusion->Evidence) and ``supports`` (Evidence->Hypothesis) both
point *from consumer to the thing consumed*, so an incoming edge's **source** is a
consumer of ``node_id``.
"""
nid = str(node_id)
for rel in CONSUMER_EDGE_TYPES:
for src, _props in graph_ops.incoming(nid, edges, rel):
alignment = truth.get(str(src), [_ALIVE])
if _ALIVE in alignment:
return True
return False
def _is_supersedes_anchor(node_id: str, edges, truth: Dict[str, List[str]]) -> bool:
"""True if ``node_id`` is the target of a ``supersedes`` edge from an ALIVE node.
That alive source is the new, current truth; the target is its tombstone and must
be retained as provenance.
"""
nid = str(node_id)
for src, _props in graph_ops.incoming(nid, edges, SUPERSEDES):
alignment = truth.get(str(src), [_ALIVE])
if _ALIVE in alignment:
return True
return False
async def cascade_forget(candidate_ids: List[str]) -> ForgetResult:
"""Delete truly-orphaned dead-ends among ``candidate_ids``; keep provenance.
Args:
candidate_ids: nodes marked refuted/invalidated by a preceding cascade.
Returns:
A :class:`ForgetResult`. Deleted nodes are removed from the graph and from
every FALSIFY vector collection, so no retrieval path can resurface them.
"""
result = ForgetResult()
candidates = [str(c) for c in dict.fromkeys(candidate_ids) if c]
if not candidates:
return result
nodes, edges = await graph_ops.load_graph()
props_by_id = {str(nid): (props or {}) for nid, props in nodes}
# Current truth for candidates + their neighbors (consumers/anchors).
neighbor_ids: Set[str] = set(candidates)
for cid in candidates:
for rel in (*CONSUMER_EDGE_TYPES, SUPERSEDES):
neighbor_ids.update(str(s) for s, _p in graph_ops.incoming(cid, edges, rel))
truth = await graph_ops.get_truth(list(neighbor_ids))
death_set: List[str] = []
for cid in candidates:
alignment = truth.get(cid, [_ALIVE])
# (a) must be refuted/invalidated
if not any(state in _DELETABLE_STATES for state in alignment):
continue
# (c) keep supersedes anchors (provenance tombstones)
if _is_supersedes_anchor(cid, edges, truth):
result.retained_provenance.append(cid)
logger.info("retained %s as supersedes provenance anchor", cid)
continue
# (b) keep nodes that still feed a live consumer
if await _alive_consumer_exists(cid, edges, truth):
result.retained_provenance.append(cid)
logger.info("retained %s (still feeds a live node)", cid)
continue
death_set.append(cid)
result.labels[cid] = graph_ops.node_label(props_by_id.get(cid, {}))
if not death_set:
logger.info("forget pass: nothing orphaned; %d provenance nodes retained",
len(result.retained_provenance))
return result
# Hard-delete from graph + all vector collections in one batch.
deleted = await graph_ops.delete_from_both_stores(death_set, _VECTOR_COLLECTIONS)
result.forgotten = death_set
logger.info(
"forget pass: hard-deleted %d orphan(s) from graph + vector; retained %d provenance",
deleted,
len(result.retained_provenance),
)
return result