nomem 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nomem/__init__.py +86 -0
- nomem/_http.py +63 -0
- nomem/_vector.py +58 -0
- nomem/backends/__init__.py +49 -0
- nomem/backends/_common.py +78 -0
- nomem/backends/base.py +135 -0
- nomem/backends/neo4j.py +543 -0
- nomem/backends/postgres.py +546 -0
- nomem/backends/sqlite.py +545 -0
- nomem/config.py +170 -0
- nomem/core/__init__.py +10 -0
- nomem/core/decay.py +65 -0
- nomem/core/extraction.py +255 -0
- nomem/core/graph.py +278 -0
- nomem/core/retrieval.py +249 -0
- nomem/embedders/__init__.py +56 -0
- nomem/embedders/base.py +30 -0
- nomem/embedders/custom.py +60 -0
- nomem/embedders/nomic.py +84 -0
- nomem/embedders/openai.py +110 -0
- nomem/exceptions.py +49 -0
- nomem/graph.py +245 -0
- nomem/llms/__init__.py +43 -0
- nomem/llms/base.py +33 -0
- nomem/llms/custom.py +50 -0
- nomem/llms/ollama.py +79 -0
- nomem/models.py +182 -0
- nomem/plugins.py +93 -0
- nomem/py.typed +0 -0
- nomem/sync.py +74 -0
- nomem-0.1.0.dist-info/METADATA +178 -0
- nomem-0.1.0.dist-info/RECORD +34 -0
- nomem-0.1.0.dist-info/WHEEL +4 -0
- nomem-0.1.0.dist-info/licenses/LICENSE +28 -0
nomem/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""nomem — No One's Memory Management.
|
|
2
|
+
|
|
3
|
+
Persistent, self-updating, per-user memory for LLM applications, structured as a
|
|
4
|
+
bi-temporal knowledge graph with an explicit LLM-powered CRUD interface.
|
|
5
|
+
|
|
6
|
+
from nomem import MemoryGraph
|
|
7
|
+
|
|
8
|
+
graph = MemoryGraph(user_id="u123", backend="sqlite", embedder="nomic")
|
|
9
|
+
graph.ingest(user="...", assistant="...")
|
|
10
|
+
result = graph.retrieve("Kira")
|
|
11
|
+
|
|
12
|
+
Storage, embedding, and extraction are adapter boundaries: SQLite (zero infra),
|
|
13
|
+
PostgreSQL/pgvector, and Neo4j all pass one identical behavioral contract suite,
|
|
14
|
+
the nomic (Ollama) and OpenAI embedders both talk plain HTTP with no extra
|
|
15
|
+
dependency, and any piece can be replaced with your own class or a bare callable.
|
|
16
|
+
Every method has an ``a``-prefixed async twin; the sync API is a thin wrapper over
|
|
17
|
+
the async core.
|
|
18
|
+
|
|
19
|
+
Capability is extended through the ``nomem.plugins`` entry point rather than by
|
|
20
|
+
subclassing or forking — see ``docs/stability.md`` for what ``>=0.1,<0.2``
|
|
21
|
+
guarantees.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from importlib.metadata import PackageNotFoundError
|
|
27
|
+
from importlib.metadata import version as _pkg_version
|
|
28
|
+
|
|
29
|
+
from .config import DecayConfig, IngestConfig, MemoryGraphConfig, RetrievalConfig
|
|
30
|
+
from .exceptions import (
|
|
31
|
+
BackendError,
|
|
32
|
+
ConfigError,
|
|
33
|
+
EdgeNotFoundError,
|
|
34
|
+
EmbedderError,
|
|
35
|
+
ExtractionError,
|
|
36
|
+
NodeNotFoundError,
|
|
37
|
+
NomemError,
|
|
38
|
+
NotSupportedError,
|
|
39
|
+
RetrievalBudgetExceededError,
|
|
40
|
+
)
|
|
41
|
+
from .graph import MemoryGraph
|
|
42
|
+
from .models import (
|
|
43
|
+
DecayResult,
|
|
44
|
+
Edge,
|
|
45
|
+
ExtractedEntity,
|
|
46
|
+
ExtractedRelation,
|
|
47
|
+
IngestReceipt,
|
|
48
|
+
Node,
|
|
49
|
+
PurgeResult,
|
|
50
|
+
ResolutionOutcome,
|
|
51
|
+
SubGraph,
|
|
52
|
+
)
|
|
53
|
+
from .plugins import Plugin
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
__version__ = _pkg_version("nomem")
|
|
57
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
58
|
+
__version__ = "0.0.0+unknown"
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"BackendError",
|
|
62
|
+
"ConfigError",
|
|
63
|
+
"DecayConfig",
|
|
64
|
+
"DecayResult",
|
|
65
|
+
"Edge",
|
|
66
|
+
"EdgeNotFoundError",
|
|
67
|
+
"EmbedderError",
|
|
68
|
+
"ExtractedEntity",
|
|
69
|
+
"ExtractedRelation",
|
|
70
|
+
"ExtractionError",
|
|
71
|
+
"IngestConfig",
|
|
72
|
+
"IngestReceipt",
|
|
73
|
+
"MemoryGraph",
|
|
74
|
+
"MemoryGraphConfig",
|
|
75
|
+
"Node",
|
|
76
|
+
"NodeNotFoundError",
|
|
77
|
+
"NomemError",
|
|
78
|
+
"NotSupportedError",
|
|
79
|
+
"Plugin",
|
|
80
|
+
"PurgeResult",
|
|
81
|
+
"ResolutionOutcome",
|
|
82
|
+
"RetrievalBudgetExceededError",
|
|
83
|
+
"RetrievalConfig",
|
|
84
|
+
"SubGraph",
|
|
85
|
+
"__version__",
|
|
86
|
+
]
|
nomem/_http.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Minimal async JSON-over-HTTP helper (stdlib only).
|
|
2
|
+
|
|
3
|
+
The default embedder and LLM adapters talk to a local Ollama. nomem's core has
|
|
4
|
+
no third-party runtime dependencies, so this wraps :mod:`urllib.request` and
|
|
5
|
+
offloads the blocking call to a worker thread.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HTTPError(RuntimeError):
|
|
18
|
+
"""A non-2xx response or a transport-level failure."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _post_json_sync(
|
|
22
|
+
url: str,
|
|
23
|
+
payload: dict[str, Any],
|
|
24
|
+
timeout: float,
|
|
25
|
+
headers: dict[str, str] | None = None,
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
data = json.dumps(payload).encode("utf-8")
|
|
28
|
+
req = urllib.request.Request(
|
|
29
|
+
url,
|
|
30
|
+
data=data,
|
|
31
|
+
headers={"Content-Type": "application/json", **(headers or {})},
|
|
32
|
+
method="POST",
|
|
33
|
+
)
|
|
34
|
+
try:
|
|
35
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
36
|
+
body = resp.read()
|
|
37
|
+
except urllib.error.HTTPError as exc: # pragma: no cover - network dependent
|
|
38
|
+
detail = exc.read().decode("utf-8", "replace")
|
|
39
|
+
raise HTTPError(f"POST {url} -> {exc.code}: {detail}") from exc
|
|
40
|
+
except urllib.error.URLError as exc: # pragma: no cover - network dependent
|
|
41
|
+
raise HTTPError(f"POST {url} failed: {exc.reason}") from exc
|
|
42
|
+
try:
|
|
43
|
+
parsed = json.loads(body)
|
|
44
|
+
except json.JSONDecodeError as exc: # pragma: no cover - defensive
|
|
45
|
+
raise HTTPError(f"POST {url} returned non-JSON body") from exc
|
|
46
|
+
if not isinstance(parsed, dict):
|
|
47
|
+
raise HTTPError(f"POST {url} returned a JSON {type(parsed).__name__}, expected an object")
|
|
48
|
+
return parsed
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def post_json(
|
|
52
|
+
url: str,
|
|
53
|
+
payload: dict[str, Any],
|
|
54
|
+
*,
|
|
55
|
+
timeout: float = 60.0,
|
|
56
|
+
headers: dict[str, str] | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""POST ``payload`` as JSON to ``url`` and return the decoded JSON response.
|
|
59
|
+
|
|
60
|
+
``headers`` are merged over the default ``Content-Type`` — that is how the
|
|
61
|
+
OpenAI embedder passes its ``Authorization``.
|
|
62
|
+
"""
|
|
63
|
+
return await asyncio.to_thread(_post_json_sync, url, payload, timeout, headers)
|
nomem/_vector.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Vector helpers (stdlib only).
|
|
2
|
+
|
|
3
|
+
Embeddings are stored as packed 32-bit floats; similarity is computed in Python.
|
|
4
|
+
Fine at SQLite/dev scale — the Postgres and Neo4j backends push this into the
|
|
5
|
+
database.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from array import array
|
|
12
|
+
|
|
13
|
+
from .models import Vector
|
|
14
|
+
|
|
15
|
+
_TYPECODE = "f"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pack(vec: Vector) -> bytes:
|
|
19
|
+
"""Pack a float vector into a compact ``bytes`` blob."""
|
|
20
|
+
return array(_TYPECODE, vec).tobytes()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def unpack(blob: bytes) -> Vector:
|
|
24
|
+
"""Reverse :func:`pack`."""
|
|
25
|
+
out = array(_TYPECODE)
|
|
26
|
+
out.frombytes(blob)
|
|
27
|
+
return list(out)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def mean(vectors: list[Vector]) -> Vector:
|
|
31
|
+
"""Component-wise mean of equal-length vectors; ``[]`` if the input is empty."""
|
|
32
|
+
usable = [v for v in vectors if v]
|
|
33
|
+
if not usable:
|
|
34
|
+
return []
|
|
35
|
+
dim = len(usable[0])
|
|
36
|
+
acc = [0.0] * dim
|
|
37
|
+
for vec in usable:
|
|
38
|
+
if len(vec) != dim:
|
|
39
|
+
continue
|
|
40
|
+
for i, x in enumerate(vec):
|
|
41
|
+
acc[i] += x
|
|
42
|
+
return [x / len(usable) for x in acc]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cosine(a: Vector, b: Vector) -> float:
|
|
46
|
+
"""Cosine similarity in ``[-1, 1]``; ``0.0`` if either vector is empty/zero."""
|
|
47
|
+
if not a or not b or len(a) != len(b):
|
|
48
|
+
return 0.0
|
|
49
|
+
dot = 0.0
|
|
50
|
+
na = 0.0
|
|
51
|
+
nb = 0.0
|
|
52
|
+
for x, y in zip(a, b, strict=True):
|
|
53
|
+
dot += x * y
|
|
54
|
+
na += x * x
|
|
55
|
+
nb += y * y
|
|
56
|
+
if na == 0.0 or nb == 0.0:
|
|
57
|
+
return 0.0
|
|
58
|
+
return dot / (math.sqrt(na) * math.sqrt(nb))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Backend adapters and the name -> class registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..exceptions import ConfigError
|
|
8
|
+
from .base import BaseBackend
|
|
9
|
+
from .neo4j import Neo4jBackend
|
|
10
|
+
from .postgres import PostgresBackend
|
|
11
|
+
from .sqlite import SQLiteBackend
|
|
12
|
+
|
|
13
|
+
BACKEND_REGISTRY: dict[str, type[BaseBackend]] = {
|
|
14
|
+
"sqlite": SQLiteBackend,
|
|
15
|
+
"postgres": PostgresBackend,
|
|
16
|
+
"neo4j": Neo4jBackend,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_backend(
|
|
21
|
+
backend: str | BaseBackend,
|
|
22
|
+
options: dict[str, Any] | None = None,
|
|
23
|
+
) -> BaseBackend:
|
|
24
|
+
"""Turn a config value into a live backend.
|
|
25
|
+
|
|
26
|
+
``backend`` may be a registered name (``"sqlite"``) or an already-built
|
|
27
|
+
:class:`BaseBackend` instance (which is returned as-is).
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(backend, BaseBackend):
|
|
30
|
+
return backend
|
|
31
|
+
if not isinstance(backend, str):
|
|
32
|
+
raise ConfigError(f"backend must be a name or a BaseBackend, got {type(backend)!r}")
|
|
33
|
+
try:
|
|
34
|
+
cls = BACKEND_REGISTRY[backend]
|
|
35
|
+
except KeyError:
|
|
36
|
+
raise ConfigError(
|
|
37
|
+
f"unknown backend {backend!r}; known: {sorted(BACKEND_REGISTRY)}"
|
|
38
|
+
) from None
|
|
39
|
+
return cls(**(options or {}))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"BACKEND_REGISTRY",
|
|
44
|
+
"BaseBackend",
|
|
45
|
+
"Neo4jBackend",
|
|
46
|
+
"PostgresBackend",
|
|
47
|
+
"SQLiteBackend",
|
|
48
|
+
"resolve_backend",
|
|
49
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Helpers shared by the concrete backends.
|
|
2
|
+
|
|
3
|
+
Keeps bi-temporal filtering and graph traversal identical across adapters, so
|
|
4
|
+
switching backends never changes what a query returns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
|
|
11
|
+
from ..models import Edge, Node, SubGraph
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def now_utc() -> datetime:
|
|
15
|
+
return datetime.now(tz=UTC)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def iso(dt: datetime) -> str:
|
|
19
|
+
"""Serialize a datetime as a UTC ISO-8601 string (lexically comparable)."""
|
|
20
|
+
if dt.tzinfo is None:
|
|
21
|
+
dt = dt.replace(tzinfo=UTC)
|
|
22
|
+
return dt.astimezone(UTC).isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_ts(value: str | datetime | None) -> datetime | None:
|
|
26
|
+
if value is None or isinstance(value, datetime):
|
|
27
|
+
return value
|
|
28
|
+
return datetime.fromisoformat(value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def aware(dt: datetime) -> datetime:
|
|
32
|
+
return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def active_at(record: Node | Edge, as_of: datetime) -> bool:
|
|
36
|
+
"""True if ``record`` was recorded by ``as_of`` and its window contains it."""
|
|
37
|
+
as_of = aware(as_of)
|
|
38
|
+
if aware(record.created_at) > as_of or aware(record.valid_from) > as_of:
|
|
39
|
+
return False
|
|
40
|
+
return record.valid_to is None or aware(record.valid_to) > as_of
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bfs_subgraph(
|
|
44
|
+
nodes_by_id: dict[str, Node],
|
|
45
|
+
edges: list[Edge],
|
|
46
|
+
seed_ids: list[str],
|
|
47
|
+
hops: int,
|
|
48
|
+
) -> SubGraph:
|
|
49
|
+
"""Expand ``hops`` undirected edge-steps out from ``seed_ids``.
|
|
50
|
+
|
|
51
|
+
``nodes_by_id`` / ``edges`` must already be filtered to the visible set
|
|
52
|
+
(active-only, or ``as_of`` a timestamp).
|
|
53
|
+
"""
|
|
54
|
+
frontier = {sid for sid in seed_ids if sid in nodes_by_id}
|
|
55
|
+
visited = set(frontier)
|
|
56
|
+
kept_edges: dict[str, Edge] = {}
|
|
57
|
+
|
|
58
|
+
for _ in range(max(hops, 0)):
|
|
59
|
+
next_frontier: set[str] = set()
|
|
60
|
+
for edge in edges:
|
|
61
|
+
if edge.source_id in frontier and edge.target_id in nodes_by_id:
|
|
62
|
+
kept_edges[edge.id] = edge
|
|
63
|
+
if edge.target_id not in visited:
|
|
64
|
+
next_frontier.add(edge.target_id)
|
|
65
|
+
if edge.target_id in frontier and edge.source_id in nodes_by_id:
|
|
66
|
+
kept_edges[edge.id] = edge
|
|
67
|
+
if edge.source_id not in visited:
|
|
68
|
+
next_frontier.add(edge.source_id)
|
|
69
|
+
if not next_frontier:
|
|
70
|
+
break
|
|
71
|
+
visited |= next_frontier
|
|
72
|
+
frontier = next_frontier
|
|
73
|
+
|
|
74
|
+
return SubGraph(
|
|
75
|
+
nodes=[nodes_by_id[nid] for nid in visited if nid in nodes_by_id],
|
|
76
|
+
edges=list(kept_edges.values()),
|
|
77
|
+
metadata={"hops": max(hops, 0), "seed_node_ids": list(seed_ids)},
|
|
78
|
+
)
|
nomem/backends/base.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Backend adapter interface.
|
|
2
|
+
|
|
3
|
+
Any object implementing :class:`BaseBackend` is a valid nomem storage backend —
|
|
4
|
+
devs can write their own. Storage is an adapter boundary: swapping a backend
|
|
5
|
+
touches nothing else (AGENT.md "Backend and embedder agnosticism").
|
|
6
|
+
|
|
7
|
+
Contract notes that apply to every implementation:
|
|
8
|
+
|
|
9
|
+
* **Bi-temporal, no hard delete.** ``retire_node`` / ``retire_edge`` set
|
|
10
|
+
``valid_to = now`` and, when given, ``superseded_by``. Records are never
|
|
11
|
+
removed.
|
|
12
|
+
* **``as_of`` honesty.** ``get_node`` and ``traverse`` with ``as_of=`` must
|
|
13
|
+
return the graph as it was *known* at that instant.
|
|
14
|
+
* **User scoping.** Every record carries ``user_id``; a backend instance must
|
|
15
|
+
never leak rows across users.
|
|
16
|
+
* **Async.** Every method is a coroutine. Blocking drivers must be run in a
|
|
17
|
+
thread/executor by the adapter.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ..config import DecayConfig
|
|
27
|
+
from ..exceptions import NotSupportedError
|
|
28
|
+
from ..models import DecayResult, Edge, Node, PurgeResult, SubGraph, Vector
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BaseBackend(ABC):
|
|
32
|
+
"""Abstract storage adapter. See module docstring for the shared contract."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def create_node(self, node: Node) -> Node:
|
|
36
|
+
"""Persist a new node and return the stored record."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def update_node(self, node_id: str, updates: dict[str, Any]) -> Node:
|
|
40
|
+
"""Apply a partial update to an active node and return the new record."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def retire_node(self, node_id: str, superseded_by: str | None = None) -> Node:
|
|
44
|
+
"""Retire a node: set ``valid_to = now`` (+ ``superseded_by``). Never delete."""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def get_node(self, node_id: str, as_of: datetime | None = None) -> Node | None:
|
|
48
|
+
"""Return a node as known at ``as_of`` (or now), or ``None`` if absent."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def upsert_edge(self, edge: Edge) -> Edge:
|
|
52
|
+
"""Create the edge, or bump ``weight`` / metadata if an active one exists."""
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def retire_edge(self, edge_id: str, superseded_by: str | None = None) -> Edge:
|
|
56
|
+
"""Retire an edge: set ``valid_to = now`` (+ ``superseded_by``). Never delete."""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def get_edge(self, edge_id: str, as_of: datetime | None = None) -> Edge | None:
|
|
60
|
+
"""Return an edge as known at ``as_of`` (or now), or ``None`` if absent.
|
|
61
|
+
|
|
62
|
+
The node-side counterpart is :meth:`get_node`; the temporal rule is the
|
|
63
|
+
same one :func:`nomem.backends._common.active_at` implements.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
async def vector_search(self, embedding: Vector, top_k: int) -> list[Node]:
|
|
68
|
+
"""Return the ``top_k`` active nodes most similar to ``embedding``."""
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
async def list_nodes(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
active_only: bool = True,
|
|
75
|
+
context: list[str] | None = None,
|
|
76
|
+
as_of: datetime | None = None,
|
|
77
|
+
) -> list[Node]:
|
|
78
|
+
"""Enumerate this user's nodes.
|
|
79
|
+
|
|
80
|
+
``context`` keeps only nodes whose ``metadata["context"]`` intersects the
|
|
81
|
+
given tags. ``as_of`` applies the same temporal filter as ``get_node``.
|
|
82
|
+
Used by hierarchical retrieval and by callers that need the whole set.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
async def list_edges(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
active_only: bool = True,
|
|
90
|
+
as_of: datetime | None = None,
|
|
91
|
+
) -> list[Edge]:
|
|
92
|
+
"""Enumerate this user's edges.
|
|
93
|
+
|
|
94
|
+
``as_of`` applies the same temporal filter as :meth:`get_edge` and takes
|
|
95
|
+
precedence over ``active_only``; ``active_only=False`` without ``as_of``
|
|
96
|
+
returns every edge on record, retired ones included. Enumeration — not
|
|
97
|
+
seed-based :meth:`traverse` — is what a full graph dump needs to
|
|
98
|
+
round-trip retired records.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
async def traverse(
|
|
103
|
+
self, seed_ids: list[str], hops: int, as_of: datetime | None = None
|
|
104
|
+
) -> SubGraph:
|
|
105
|
+
"""Expand ``hops`` edges out from ``seed_ids``; honor ``as_of`` if given."""
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
async def cross_reference(self, node: Node, threshold: float) -> list[tuple[Node, float]]:
|
|
109
|
+
"""Return active nodes whose similarity to ``node`` is >= ``threshold``."""
|
|
110
|
+
|
|
111
|
+
@abstractmethod
|
|
112
|
+
async def run_decay(self, config: DecayConfig) -> DecayResult:
|
|
113
|
+
"""Run one decay/pruning pass over this user's nodes per ``config``."""
|
|
114
|
+
|
|
115
|
+
# --- the one hard-delete path ----------------------------------
|
|
116
|
+
|
|
117
|
+
async def purge_user(self, user_id: str) -> PurgeResult:
|
|
118
|
+
"""Really delete every row for ``user_id``. **Not** part of retirement.
|
|
119
|
+
|
|
120
|
+
This is the single exception to "nomem never hard-deletes", and it
|
|
121
|
+
exists so GDPR right-to-forget can be built as a plugin instead of a
|
|
122
|
+
fork. It is deliberately fenced:
|
|
123
|
+
|
|
124
|
+
* it is **not abstract** — a custom backend may leave this default body,
|
|
125
|
+
which raises :class:`~nomem.exceptions.NotSupportedError`;
|
|
126
|
+
* implementations must raise :class:`~nomem.exceptions.BackendError`
|
|
127
|
+
unless ``user_id`` matches the backend's own user;
|
|
128
|
+
* it is **unreachable from** :class:`nomem.MemoryGraph` and
|
|
129
|
+
``GraphCRUD``, and a guard test asserts it stays that way. A backend
|
|
130
|
+
handle is not the public API.
|
|
131
|
+
"""
|
|
132
|
+
raise NotSupportedError(
|
|
133
|
+
f"{type(self).__name__} does not implement purge_user; "
|
|
134
|
+
"nomem retires records via valid_to and never hard-deletes"
|
|
135
|
+
)
|