memex-python 0.13.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.
memex/errors.py ADDED
@@ -0,0 +1,51 @@
1
+ """Domain errors for the memory graph.
2
+
3
+ Score-bound violations surface as ``pydantic.ValidationError`` (see D2/D7 in
4
+ PLAN.md); ``cost_fn`` contract violations surface as ``ValueError``. The typed
5
+ exceptions below mirror the named error classes thrown by the reducer layer in
6
+ the TypeScript library. Intent/Task graphs add their own typed errors in their
7
+ respective modules.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __all__ = [
13
+ "MemexError",
14
+ "MemoryNotFoundError",
15
+ "EdgeNotFoundError",
16
+ "DuplicateMemoryError",
17
+ "DuplicateEdgeError",
18
+ "InvalidTimestampError",
19
+ ]
20
+
21
+
22
+ class MemexError(Exception):
23
+ """Base class for all memex domain errors."""
24
+
25
+
26
+ class MemoryNotFoundError(MemexError):
27
+ def __init__(self, item_id: str) -> None:
28
+ super().__init__(f"Memory item not found: {item_id}")
29
+ self.item_id = item_id
30
+
31
+
32
+ class EdgeNotFoundError(MemexError):
33
+ def __init__(self, edge_id: str) -> None:
34
+ super().__init__(f"Edge not found: {edge_id}")
35
+ self.edge_id = edge_id
36
+
37
+
38
+ class DuplicateMemoryError(MemexError):
39
+ def __init__(self, item_id: str) -> None:
40
+ super().__init__(f"Memory item already exists: {item_id}")
41
+ self.item_id = item_id
42
+
43
+
44
+ class DuplicateEdgeError(MemexError):
45
+ def __init__(self, edge_id: str) -> None:
46
+ super().__init__(f"Edge already exists: {edge_id}")
47
+ self.edge_id = edge_id
48
+
49
+
50
+ class InvalidTimestampError(MemexError):
51
+ """Raised when a timestamp cannot be extracted or parsed from input."""
memex/factories.py ADDED
@@ -0,0 +1,97 @@
1
+ """Factories that mint ids/timestamps, mirroring ``helpers.ts``.
2
+
3
+ ``create_memory_item`` / ``create_edge`` validate score bounds at construction
4
+ (via the Pydantic ``Field`` constraints on the models). ``created_at`` is
5
+ derived from an explicit value, else the UUIDv7 timestamp, else the wall clock.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from . import _time
13
+ from ._uuid import safe_extract_timestamp, uuid7
14
+ from .models import Edge, EventEnvelope, MemoryItem
15
+
16
+ __all__ = ["create_memory_item", "create_edge", "create_event_envelope"]
17
+
18
+
19
+ def create_memory_item(
20
+ *,
21
+ scope: str,
22
+ kind: str,
23
+ content: dict[str, Any],
24
+ author: str,
25
+ source_kind: str,
26
+ authority: float,
27
+ id: str | None = None,
28
+ parents: list[str] | None = None,
29
+ conviction: float | None = None,
30
+ importance: float | None = None,
31
+ created_at: int | None = None,
32
+ intent_id: str | None = None,
33
+ task_id: str | None = None,
34
+ meta: dict[str, Any] | None = None,
35
+ ) -> MemoryItem:
36
+ item_id = id if id is not None else uuid7()
37
+ ts = created_at if created_at is not None else (safe_extract_timestamp(item_id) or _time.now_ms())
38
+ return MemoryItem(
39
+ id=item_id,
40
+ scope=scope,
41
+ kind=kind,
42
+ content=content,
43
+ author=author,
44
+ source_kind=source_kind,
45
+ parents=parents,
46
+ authority=authority,
47
+ conviction=conviction,
48
+ importance=importance,
49
+ created_at=ts,
50
+ intent_id=intent_id,
51
+ task_id=task_id,
52
+ meta=meta,
53
+ )
54
+
55
+
56
+ def create_edge(
57
+ *,
58
+ from_: str,
59
+ to: str,
60
+ kind: str,
61
+ author: str,
62
+ source_kind: str,
63
+ authority: float,
64
+ edge_id: str | None = None,
65
+ active: bool | None = None,
66
+ weight: float | None = None,
67
+ meta: dict[str, Any] | None = None,
68
+ ) -> Edge:
69
+ return Edge(
70
+ edge_id=edge_id if edge_id is not None else uuid7(),
71
+ from_=from_,
72
+ to=to,
73
+ kind=kind,
74
+ weight=weight,
75
+ author=author,
76
+ source_kind=source_kind,
77
+ authority=authority,
78
+ active=active if active is not None else True,
79
+ meta=meta,
80
+ )
81
+
82
+
83
+ def create_event_envelope(
84
+ type: str,
85
+ payload: Any,
86
+ *,
87
+ trace_id: str | None = None,
88
+ namespace: str = "memory",
89
+ ) -> EventEnvelope[Any]:
90
+ return EventEnvelope(
91
+ id=uuid7(),
92
+ namespace=namespace,
93
+ type=type,
94
+ ts=_time.now_iso(),
95
+ trace_id=trace_id,
96
+ payload=payload,
97
+ )
memex/graph.py ADDED
@@ -0,0 +1,30 @@
1
+ """Graph state container.
2
+
3
+ ``GraphState`` is a lightweight frozen dataclass holding plain dicts — NOT a
4
+ Pydantic model (D3). The reducer clones the relevant dict on every command
5
+ (``dict(state.items)`` mirrors the TS ``new Map(state.items)``); a validated
6
+ model here would re-validate every item per command and be unusably slow.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from .models import Edge, MemoryItem
14
+
15
+ __all__ = ["GraphState", "create_graph_state", "clone_graph_state"]
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class GraphState:
20
+ items: dict[str, MemoryItem] = field(default_factory=dict)
21
+ edges: dict[str, Edge] = field(default_factory=dict)
22
+
23
+
24
+ def create_graph_state() -> GraphState:
25
+ return GraphState(items={}, edges={})
26
+
27
+
28
+ def clone_graph_state(state: GraphState) -> GraphState:
29
+ """Shallow clone — new dicts, shared (immutable) item/edge instances."""
30
+ return GraphState(items=dict(state.items), edges=dict(state.edges))
memex/integrity.py ADDED
@@ -0,0 +1,317 @@
1
+ """Contradiction & alias integrity, stale detection, cascade retraction, budget packing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable
7
+ from typing import Any, NamedTuple
8
+
9
+ from .commands import EdgeCreate
10
+ from .factories import create_edge
11
+ from .graph import GraphState
12
+ from .models import Edge, MemoryFilter, MemoryItem, MemoryLifecycleEvent, ScoredItem, ScoreWeights
13
+ from .query import get_children, get_edges, get_scored_items
14
+ from .reducer import CommandResult, apply_command
15
+
16
+ __all__ = [
17
+ "Contradiction",
18
+ "StaleItem",
19
+ "CascadeResult",
20
+ "get_contradictions",
21
+ "mark_contradiction",
22
+ "resolve_contradiction",
23
+ "get_stale_items",
24
+ "get_dependents",
25
+ "cascade_retract",
26
+ "mark_alias",
27
+ "get_aliases",
28
+ "get_alias_group",
29
+ "get_items_by_budget",
30
+ ]
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # 1. Contradiction detection & resolution
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ class Contradiction(NamedTuple):
39
+ a: MemoryItem
40
+ b: MemoryItem
41
+ edge: Edge | None = None
42
+
43
+
44
+ def get_contradictions(state: GraphState) -> list[Contradiction]:
45
+ contradict_edges = get_edges(state, {"kind": "CONTRADICTS", "active_only": True})
46
+ results: list[Contradiction] = []
47
+ for edge in contradict_edges:
48
+ a = state.items.get(edge.from_)
49
+ b = state.items.get(edge.to)
50
+ if a is not None and b is not None:
51
+ results.append(Contradiction(a=a, b=b, edge=edge))
52
+ return results
53
+
54
+
55
+ def mark_contradiction(
56
+ state: GraphState,
57
+ item_id_a: str,
58
+ item_id_b: str,
59
+ author: str,
60
+ meta: dict[str, Any] | None = None,
61
+ ) -> CommandResult:
62
+ # A self-CONTRADICTS edge is meaningful (an internally inconsistent item);
63
+ # downstream annotation already skips self-edges, so it's safe to record.
64
+ edge = create_edge(
65
+ from_=item_id_a, to=item_id_b, kind="CONTRADICTS", author=author,
66
+ source_kind="derived_deterministic", authority=1, meta=meta,
67
+ )
68
+ return apply_command(state, EdgeCreate(edge=edge))
69
+
70
+
71
+ def resolve_contradiction(
72
+ state: GraphState,
73
+ winner_id: str,
74
+ loser_id: str,
75
+ author: str,
76
+ reason: str | None = None,
77
+ ) -> CommandResult:
78
+ current = state
79
+ all_events: list[MemoryLifecycleEvent] = []
80
+
81
+ to_retract: list[str] = []
82
+ for edge in current.edges.values():
83
+ if (
84
+ edge.kind == "CONTRADICTS"
85
+ and edge.active
86
+ and (
87
+ (edge.from_ == winner_id and edge.to == loser_id)
88
+ or (edge.from_ == loser_id and edge.to == winner_id)
89
+ )
90
+ ):
91
+ to_retract.append(edge.edge_id)
92
+
93
+ for edge_id in to_retract:
94
+ r = apply_command(
95
+ current, {"type": "edge.retract", "edge_id": edge_id, "author": author, "reason": reason}
96
+ )
97
+ current = r.state
98
+ all_events.extend(r.events)
99
+
100
+ if not to_retract:
101
+ # Stale/duplicate call — no-op rather than crash the fold.
102
+ return CommandResult(current, all_events)
103
+
104
+ supersedes = create_edge(
105
+ from_=winner_id, to=loser_id, kind="SUPERSEDES", author=author,
106
+ source_kind="derived_deterministic", authority=1,
107
+ meta={"reason": reason} if reason else None,
108
+ )
109
+ r1 = apply_command(current, EdgeCreate(edge=supersedes))
110
+ current = r1.state
111
+ all_events.extend(r1.events)
112
+
113
+ loser = current.items.get(loser_id)
114
+ if loser is not None:
115
+ r2 = apply_command(
116
+ current,
117
+ {"type": "memory.update", "item_id": loser_id,
118
+ "partial": {"authority": loser.authority * 0.1}, "author": author, "reason": reason},
119
+ )
120
+ current = r2.state
121
+ all_events.extend(r2.events)
122
+
123
+ return CommandResult(current, all_events)
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # 2. Stale detection & cascade
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ class StaleItem(NamedTuple):
132
+ item: MemoryItem
133
+ missing_parents: list[str]
134
+
135
+
136
+ def get_stale_items(state: GraphState) -> list[StaleItem]:
137
+ results: list[StaleItem] = []
138
+ for item in state.items.values():
139
+ if not item.parents:
140
+ continue
141
+ missing = [pid for pid in item.parents if pid not in state.items]
142
+ if missing:
143
+ results.append(StaleItem(item=item, missing_parents=missing))
144
+ return results
145
+
146
+
147
+ def get_dependents(state: GraphState, item_id: str, transitive: bool = False) -> list[MemoryItem]:
148
+ direct = get_children(state, item_id)
149
+ if not transitive:
150
+ return direct
151
+
152
+ visited: set[str] = set()
153
+ result: list[MemoryItem] = []
154
+ queue = list(direct)
155
+ while queue:
156
+ item = queue.pop()
157
+ if item.id in visited:
158
+ continue
159
+ visited.add(item.id)
160
+ result.append(item)
161
+ queue.extend(get_children(state, item.id))
162
+ return result
163
+
164
+
165
+ class CascadeResult(NamedTuple):
166
+ state: GraphState
167
+ events: list[MemoryLifecycleEvent]
168
+ retracted: list[str]
169
+
170
+
171
+ def cascade_retract(
172
+ state: GraphState,
173
+ item_id: str,
174
+ author: str,
175
+ reason: str | None = None,
176
+ ) -> CascadeResult:
177
+ """Retract an item and all transitive dependents in post-order (leaves first).
178
+
179
+ Iterative post-order DFS: cycle-safe, DAG-safe (shared children), and does
180
+ not consume the call stack on deep dependency chains. The root is pre-marked
181
+ visited so a cycle pointing back to it is ignored — it's retracted last.
182
+ """
183
+ visited: set[str] = {item_id}
184
+ order: list[str] = []
185
+
186
+ stack: list[tuple[str, str]] = [(child.id, "enter") for child in get_children(state, item_id)]
187
+ while stack:
188
+ frame_id, phase = stack.pop()
189
+ if phase == "exit":
190
+ order.append(frame_id)
191
+ continue
192
+ if frame_id in visited:
193
+ continue
194
+ visited.add(frame_id)
195
+ stack.append((frame_id, "exit")) # processed after all children (post-order)
196
+ for child in get_children(state, frame_id):
197
+ if child.id not in visited:
198
+ stack.append((child.id, "enter"))
199
+
200
+ current = state
201
+ all_events: list[MemoryLifecycleEvent] = []
202
+ retracted: list[str] = []
203
+
204
+ for dep_id in order:
205
+ if dep_id not in current.items:
206
+ continue
207
+ r = apply_command(
208
+ current,
209
+ {"type": "memory.retract", "item_id": dep_id, "author": author,
210
+ "reason": reason if reason is not None else f"parent {item_id} retracted"},
211
+ )
212
+ current = r.state
213
+ all_events.extend(r.events)
214
+ retracted.append(dep_id)
215
+
216
+ if item_id in current.items:
217
+ r = apply_command(
218
+ current, {"type": "memory.retract", "item_id": item_id, "author": author, "reason": reason}
219
+ )
220
+ current = r.state
221
+ all_events.extend(r.events)
222
+ retracted.append(item_id)
223
+
224
+ return CascadeResult(current, all_events, retracted)
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # 3. Identity / aliasing
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ def mark_alias(
233
+ state: GraphState,
234
+ item_id_a: str,
235
+ item_id_b: str,
236
+ author: str,
237
+ meta: dict[str, Any] | None = None,
238
+ ) -> CommandResult:
239
+ if item_id_a == item_id_b:
240
+ # Self-alias is redundant — no-op rather than throw.
241
+ return CommandResult(state, [])
242
+
243
+ current = state
244
+ all_events: list[MemoryLifecycleEvent] = []
245
+
246
+ e1 = create_edge(
247
+ from_=item_id_a, to=item_id_b, kind="ALIAS", author=author,
248
+ source_kind="derived_deterministic", authority=1, meta=meta,
249
+ )
250
+ r1 = apply_command(current, EdgeCreate(edge=e1))
251
+ current = r1.state
252
+ all_events.extend(r1.events)
253
+
254
+ e2 = create_edge(
255
+ from_=item_id_b, to=item_id_a, kind="ALIAS", author=author,
256
+ source_kind="derived_deterministic", authority=1, meta=meta,
257
+ )
258
+ r2 = apply_command(current, EdgeCreate(edge=e2))
259
+ current = r2.state
260
+ all_events.extend(r2.events)
261
+
262
+ return CommandResult(current, all_events)
263
+
264
+
265
+ def get_aliases(state: GraphState, item_id: str) -> list[MemoryItem]:
266
+ alias_edges = get_edges(state, {"from": item_id, "kind": "ALIAS", "active_only": True})
267
+ results: list[MemoryItem] = []
268
+ for edge in alias_edges:
269
+ item = state.items.get(edge.to)
270
+ if item is not None:
271
+ results.append(item)
272
+ return results
273
+
274
+
275
+ def get_alias_group(state: GraphState, item_id: str) -> list[MemoryItem]:
276
+ visited: set[str] = set()
277
+ result: list[MemoryItem] = []
278
+ queue = [item_id]
279
+ while queue:
280
+ node_id = queue.pop()
281
+ if node_id in visited:
282
+ continue
283
+ visited.add(node_id)
284
+ item = state.items.get(node_id)
285
+ if item is not None:
286
+ result.append(item)
287
+ for edge in get_edges(state, {"from": node_id, "kind": "ALIAS", "active_only": True}):
288
+ queue.append(edge.to)
289
+ return result
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # 4. Budget-aware retrieval
294
+ # ---------------------------------------------------------------------------
295
+
296
+
297
+ def get_items_by_budget(
298
+ state: GraphState,
299
+ *,
300
+ budget: float,
301
+ cost_fn: Callable[[MemoryItem], float],
302
+ weights: ScoreWeights | dict[str, Any],
303
+ filter: MemoryFilter | dict[str, Any] | None = None,
304
+ ) -> list[ScoredItem]:
305
+ """Retrieve the highest-scoring items that fit within a budget (greedy pack)."""
306
+ scored = get_scored_items(state, weights, {"pre": filter})
307
+
308
+ results: list[ScoredItem] = []
309
+ remaining = budget
310
+ for entry in scored:
311
+ cost = cost_fn(entry.item)
312
+ if cost < 0 or not math.isfinite(cost):
313
+ raise ValueError(f"cost_fn must return a finite non-negative number, got {cost}")
314
+ if cost <= remaining:
315
+ results.append(entry)
316
+ remaining -= cost
317
+ return results