Spaces:
Sleeping
Sleeping
| """ | |
| 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", | |
| ] | |
| 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 | |