File size: 6,243 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
"""
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