Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/surreal_memory/engine/enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ async def find_cross_cluster_links(
)
cluster_anchors.append(best_fiber.anchor_neuron_id)

# Anchors are fiber members resolved lazily; a deleted anchor would turn
# every enrichment pass into a factory of RELATED_TO edges pointing at
# missing rows (issue #194). Drop dead anchors up front.
anchor_probe = await storage.get_neurons_batch([a for a in cluster_anchors if a is not None])
live_anchors = set(anchor_probe)

# Check existing synapses between cluster anchors
existing_synapses = await storage.get_synapses_paged(type=SynapseType.RELATED_TO)
existing_pairs: set[tuple[str, str]] = set()
Expand All @@ -194,6 +200,10 @@ async def find_cross_cluster_links(

anchor_a = cluster_anchors[i]
anchor_b = cluster_anchors[j]
if anchor_a is None or anchor_b is None:
continue
if anchor_a not in live_anchors or anchor_b not in live_anchors:
continue
if anchor_a == anchor_b:
continue
if (anchor_a, anchor_b) in existing_pairs:
Expand Down
55 changes: 55 additions & 0 deletions src/surreal_memory/storage/memory_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import random
from collections import defaultdict
from dataclasses import replace
from datetime import datetime, timedelta
from typing import Any, Literal
from uuid import uuid4
Expand Down Expand Up @@ -210,8 +211,62 @@ async def delete_neuron(self, neuron_id: str) -> bool:

del self._neurons[brain_id][neuron_id]
self._states[brain_id].pop(neuron_id, None)

# Same contract as the SurrealDB backend (issue #194): no fiber keeps
# listing a deleted member; a fiber losing its anchor is soft-forgotten
# (typed_memory expires_at, like smem_forget) and tombstoned in metadata.
now = utcnow()
for fid, fiber in list(self._fibers[brain_id].items()):
if neuron_id not in fiber.neuron_ids and fiber.anchor_neuron_id != neuron_id:
continue
members = fiber.neuron_ids - {neuron_id}
updates: dict[str, Any] = {"neuron_ids": members}
if fiber.anchor_neuron_id == neuron_id:
updates["metadata"] = {
**fiber.metadata,
"_anchor_deleted": now.isoformat(),
}
self._fibers[brain_id][fid] = replace(fiber, **updates)
if fiber.anchor_neuron_id == neuron_id:
typed = self._typed_memories[brain_id].get(fid)
if typed is not None:
self._typed_memories[brain_id][fid] = replace(typed, expires_at=now)
return True

async def repair_fiber_member_drift(self) -> dict[str, int]:
"""One-off maintenance: shed ids that no longer resolve from all fibers.

In-memory twin of the SurrealDB method (the storage-parity meta-test
keeps the backends symmetrical). Same rules as the delete cascade.
"""
brain_id = self._get_brain_id()
stats = {
"fibers_scanned": len(self._fibers[brain_id]),
"fibers_repaired": 0,
"ids_shed": 0,
"fibers_expired": 0,
}
candidate_ids = {n for f in self._fibers[brain_id].values() for n in f.neuron_ids}
candidate_ids.update(f.anchor_neuron_id for f in self._fibers[brain_id].values())
live = set(self._neurons[brain_id])
now = utcnow()
for fid, fiber in list(self._fibers[brain_id].items()):
shed = {m for m in fiber.neuron_ids if m in live}
anchor_dead = fiber.anchor_neuron_id not in live
if shed == fiber.neuron_ids and not anchor_dead:
continue
updates: dict[str, Any] = {"neuron_ids": shed}
if anchor_dead:
updates["metadata"] = {**fiber.metadata, "_anchor_deleted": now.isoformat()}
typed = self._typed_memories[brain_id].get(fid)
if typed is not None:
self._typed_memories[brain_id][fid] = replace(typed, expires_at=now)
stats["fibers_expired"] += 1
self._fibers[brain_id][fid] = replace(fiber, **updates)
stats["fibers_repaired"] += 1
stats["ids_shed"] += len(fiber.neuron_ids) - len(shed)
return stats

# ========== Neuron State Operations ==========

async def get_neuron_state(self, neuron_id: str) -> NeuronState | None:
Expand Down
104 changes: 103 additions & 1 deletion src/surreal_memory/storage/surrealdb/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import random
import re
from collections.abc import Iterator
from dataclasses import replace as dc_replace
from datetime import datetime
from hashlib import sha256
from typing import Any, Literal, TypeVar
Expand Down Expand Up @@ -1529,10 +1530,111 @@ async def delete_neuron(self, neuron_id: str) -> bool:
try:
await conn.delete(f"neuron:{sid}")
await self._record_change_internal("neuron", neuron_id, "delete")
return True
except Exception:
return False

# Fibers otherwise keep claiming a member that no longer exists (issue
# #194): fiber counts drift, prune's orphan-protection set grows
# monotonically, and enrichment later mints RELATED_TO edges at the
# missing anchor. Fail-soft — a failed cascade must not un-delete.
try:
await self._shed_neuron_from_fibers(neuron_id)
except Exception:
logger.warning(
"Fiber cascade after neuron delete failed (drift possible)", exc_info=True
)
return True

async def _shed_neuron_from_fibers(self, neuron_id: str) -> dict[str, int]:
"""Remove ``neuron_id`` from every fiber listing it (issue #194).

A fiber whose ANCHOR is the deleted neuron loses its load-bearing
member: it is soft-forgotten the way ``smem_forget`` does it — the
typed_memory row's ``expires_at`` is set to now, so it leaves recall
immediately — and carries a ``_anchor_deleted`` metadata tombstone
(graph-only fibers without a typed row keep at least the marker).
Fibers that merely listed the id keep living with the id shed.
Returns ``{fibers_repaired, ids_shed, fibers_expired}``.
"""
conn = self._ensure_conn()
rows = await self._query(
"SELECT id, neuron_ids, anchor_neuron_id, metadata FROM fiber"
" WHERE neuron_ids CONTAINS $nid",
nid=neuron_id,
)
stats = {"fibers_repaired": 0, "ids_shed": 0, "fibers_expired": 0}
now = utcnow()
for row in rows:
fid = row.get("id")
fiber_id = str(fid).rsplit(":", 1)[-1].strip("⟨⟩`") if fid is not None else ""
if not fiber_id:
continue
members = list(row.get("neuron_ids") or [])
shed = [m for m in members if m != neuron_id]
update: dict[str, Any] = {"neuron_ids": shed}
if row.get("anchor_neuron_id") == neuron_id:
metadata = dict(row.get("metadata") or {})
metadata["_anchor_deleted"] = now.isoformat()
update["metadata"] = metadata
typed = await self.get_typed_memory(fiber_id)
if typed is not None:
await self.update_typed_memory(dc_replace(typed, expires_at=now))
stats["fibers_expired"] += 1
await conn.merge(fid, update)
await self._record_change_internal("fiber", fiber_id, "update")
stats["fibers_repaired"] += 1
stats["ids_shed"] += len(members) - len(shed)
return stats

async def repair_fiber_member_drift(self) -> dict[str, int]:
"""One-off maintenance: shed ids that no longer resolve from all fibers.

Databases that accumulated dangling ``neuron_ids`` before the delete
cascade existed (issue #194) converge here. Applies the same rules as
the delete-path cascade: shed dead members, expire fibers whose anchor
is dead. Returns ``{fibers_scanned, fibers_repaired, ids_shed,
fibers_expired}``.
"""
rows = await self._query("SELECT id, neuron_ids, anchor_neuron_id, metadata FROM fiber")
stats = {
"fibers_scanned": len(rows),
"fibers_repaired": 0,
"ids_shed": 0,
"fibers_expired": 0,
}
candidate_ids = {n for r in rows for n in (r.get("neuron_ids") or [])}
candidate_ids.update(r.get("anchor_neuron_id") for r in rows if r.get("anchor_neuron_id"))
if not candidate_ids:
return stats
live = set(await self.get_neurons_batch(list(candidate_ids)))
conn = self._ensure_conn()
now = utcnow()
for row in rows:
members = list(row.get("neuron_ids") or [])
shed = [m for m in members if m in live]
anchor = row.get("anchor_neuron_id")
anchor_dead = anchor is not None and anchor not in live
if len(shed) == len(members) and not anchor_dead:
continue
fid = row.get("id")
fiber_id = str(fid).rsplit(":", 1)[-1].strip("⟨⟩`") if fid is not None else ""
if not fiber_id:
continue
update: dict[str, Any] = {"neuron_ids": shed}
if anchor_dead:
metadata = dict(row.get("metadata") or {})
metadata["_anchor_deleted"] = now.isoformat()
update["metadata"] = metadata
typed = await self.get_typed_memory(fiber_id)
if typed is not None:
await self.update_typed_memory(dc_replace(typed, expires_at=now))
stats["fibers_expired"] += 1
await conn.merge(fid, update)
await self._record_change_internal("fiber", fiber_id, "update")
stats["fibers_repaired"] += 1
stats["ids_shed"] += len(members) - len(shed)
return stats

async def delete_neurons_batch(self, neuron_ids: list[str]) -> int:
"""Delete multiple neurons sequentially.

Expand Down
1 change: 1 addition & 0 deletions tests/unit/_surrealdb_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"record-id-lookups-live", # test_surrealdb_record_id_lookups_live.py
"change-log-payload-live", # test_surrealdb_change_log_payload_live.py
"sync-change-entry-live", # test_surrealdb_sync_change_entry_live.py
"delete-cascade-live", # test_delete_neuron_cascade_live.py
}
)

Expand Down
89 changes: 89 additions & 0 deletions tests/unit/test_delete_neuron_cascade_live.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Live-DB regression: delete_neuron cascades to fibers (issue #194).

The unit tests pin the contract on the in-memory backend; this file proves the
SurrealQL cascade on the only production backend — the id is shed from
``fiber.neuron_ids`` everywhere, and a fiber losing its ANCHOR is
soft-forgotten (typed_memory ``expires_at`` + ``_anchor_deleted`` metadata
tombstone) instead of being left claiming a member that no longer exists.
Skipped unless SURREALDB_URL points at a running SurrealDB.
"""

from __future__ import annotations

import os

import pytest

from surreal_memory.core.brain import Brain
from surreal_memory.core.fiber import Fiber
from surreal_memory.core.memory_types import MemoryType, Priority, TypedMemory
from surreal_memory.core.neuron import Neuron, NeuronType
from tests.unit._surrealdb_live import cleanup_live_brains, ensure_real_surrealdb_sdk

SURREALDB_URL = os.getenv("SURREALDB_URL")

pytestmark = pytest.mark.skipif(
not SURREALDB_URL,
reason="requires SURREALDB_URL pointing at a running SurrealDB",
)

BRAIN_NAME = "delete-cascade-live"


@pytest.fixture
async def storage(): # type: ignore[no-untyped-def]
ensure_real_surrealdb_sdk()
from surreal_memory.storage.surrealdb.store import SurrealDBStorage

store = SurrealDBStorage(url=SURREALDB_URL)
await store.initialize()
brain = Brain.create(name=BRAIN_NAME)
await store.save_brain(brain)
store.set_brain(brain.id)

yield store

try:
await cleanup_live_brains(store, own_brain_id=brain.id)
finally:
await store.close()


@pytest.mark.asyncio
async def test_delete_sheds_member_and_soft_forgets_anchor_fiber(storage) -> None: # type: ignore[no-untyped-def]
anchor = Neuron.create(type=NeuronType.CONCEPT, content="anchor", neuron_id="dc-anchor")
member = Neuron.create(type=NeuronType.CONCEPT, content="member", neuron_id="dc-member")
await storage.add_neuron(anchor)
await storage.add_neuron(member)
await storage.add_fiber(
Fiber.create(
neuron_ids={"dc-anchor", "dc-member"},
synapse_ids=set(),
anchor_neuron_id="dc-anchor",
fiber_id="dc-fiber",
)
)
await storage.add_typed_memory(
TypedMemory.create(
fiber_id="dc-fiber",
memory_type=MemoryType.CONTEXT,
priority=Priority.NORMAL,
source="test",
)
)

# Member delete: id shed, fiber alive.
assert await storage.delete_neuron("dc-member") is True
fiber = await storage.get_fiber("dc-fiber")
assert fiber is not None
assert "dc-member" not in fiber.neuron_ids
assert fiber.anchor_neuron_id == "dc-anchor"

# Anchor delete: soft-forget + tombstone + shed.
assert await storage.delete_neuron("dc-anchor") is True
fiber = await storage.get_fiber("dc-fiber")
assert fiber is not None
assert "dc-anchor" not in fiber.neuron_ids
assert fiber.metadata.get("_anchor_deleted"), "tombstone must persist"
typed = await storage.get_typed_memory("dc-fiber")
assert typed is not None and typed.expires_at is not None
Loading
Loading