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/__init__.py +336 -0
- memex/_time.py +26 -0
- memex/_uuid.py +62 -0
- memex/bulk.py +138 -0
- memex/commands.py +75 -0
- memex/envelope.py +69 -0
- memex/errors.py +51 -0
- memex/factories.py +97 -0
- memex/graph.py +30 -0
- memex/integrity.py +317 -0
- memex/intent.py +318 -0
- memex/models.py +271 -0
- memex/query.py +435 -0
- memex/reducer.py +151 -0
- memex/replay.py +144 -0
- memex/retrieval.py +266 -0
- memex/schemas.py +67 -0
- memex/serialization.py +47 -0
- memex/stats.py +71 -0
- memex/store.py +222 -0
- memex/task.py +361 -0
- memex/transplant.py +480 -0
- memex_python-0.13.0.dist-info/METADATA +150 -0
- memex_python-0.13.0.dist-info/RECORD +26 -0
- memex_python-0.13.0.dist-info/WHEEL +4 -0
- memex_python-0.13.0.dist-info/licenses/LICENSE +190 -0
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
|