""" 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