graphrefly 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.
- graphrefly/__init__.py +160 -0
- graphrefly/compat/__init__.py +18 -0
- graphrefly/compat/async_utils.py +228 -0
- graphrefly/compat/asyncio_runner.py +89 -0
- graphrefly/compat/trio_runner.py +81 -0
- graphrefly/core/__init__.py +142 -0
- graphrefly/core/clock.py +20 -0
- graphrefly/core/dynamic_node.py +749 -0
- graphrefly/core/guard.py +277 -0
- graphrefly/core/meta.py +149 -0
- graphrefly/core/node.py +963 -0
- graphrefly/core/protocol.py +460 -0
- graphrefly/core/runner.py +107 -0
- graphrefly/core/subgraph_locks.py +296 -0
- graphrefly/core/sugar.py +138 -0
- graphrefly/core/versioning.py +193 -0
- graphrefly/extra/__init__.py +313 -0
- graphrefly/extra/adapters.py +2149 -0
- graphrefly/extra/backoff.py +287 -0
- graphrefly/extra/backpressure.py +113 -0
- graphrefly/extra/checkpoint.py +307 -0
- graphrefly/extra/composite.py +303 -0
- graphrefly/extra/cron.py +133 -0
- graphrefly/extra/data_structures.py +707 -0
- graphrefly/extra/resilience.py +727 -0
- graphrefly/extra/sources.py +766 -0
- graphrefly/extra/tier1.py +1067 -0
- graphrefly/extra/tier2.py +1802 -0
- graphrefly/graph/__init__.py +31 -0
- graphrefly/graph/graph.py +2249 -0
- graphrefly/integrations/__init__.py +1 -0
- graphrefly/integrations/fastapi.py +767 -0
- graphrefly/patterns/__init__.py +5 -0
- graphrefly/patterns/ai.py +2132 -0
- graphrefly/patterns/cqrs.py +515 -0
- graphrefly/patterns/memory.py +639 -0
- graphrefly/patterns/messaging.py +553 -0
- graphrefly/patterns/orchestration.py +536 -0
- graphrefly/patterns/reactive_layout/__init__.py +81 -0
- graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
- graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
- graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
- graphrefly/py.typed +1 -0
- graphrefly-0.1.0.dist-info/METADATA +253 -0
- graphrefly-0.1.0.dist-info/RECORD +47 -0
- graphrefly-0.1.0.dist-info/WHEEL +4 -0
- graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"""Memory patterns (roadmap §4.3).
|
|
2
|
+
|
|
3
|
+
Domain-layer helpers composed from GraphRefly primitives. ``vector_index`` uses
|
|
4
|
+
exact cosine search by default; HNSW can be wired as an optional dependency
|
|
5
|
+
through ``hnsw_factory``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar
|
|
14
|
+
|
|
15
|
+
from graphrefly.core.clock import monotonic_ns
|
|
16
|
+
from graphrefly.core.protocol import MessageType
|
|
17
|
+
from graphrefly.core.sugar import derived, state
|
|
18
|
+
from graphrefly.graph.graph import Graph
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
22
|
+
|
|
23
|
+
from graphrefly.core.node import Node, NodeImpl
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
TEntity = TypeVar("TEntity")
|
|
27
|
+
TRelation = TypeVar("TRelation", bound=str)
|
|
28
|
+
TMeta = TypeVar("TMeta")
|
|
29
|
+
|
|
30
|
+
CollectionPolicy = Literal["fifo", "lru"]
|
|
31
|
+
VectorBackend = Literal["flat", "hnsw"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class LightCollectionEntry[T]:
|
|
36
|
+
"""Collection item metadata used by ``light_collection``."""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
value: T
|
|
40
|
+
created_at_ns: int
|
|
41
|
+
last_access_ns: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def decay(
|
|
45
|
+
base_score: float, age_seconds: float, rate_per_second: float, min_score: float = 0.0
|
|
46
|
+
) -> float:
|
|
47
|
+
"""Exponential score decay with floor."""
|
|
48
|
+
if not math.isfinite(base_score):
|
|
49
|
+
return float(min_score)
|
|
50
|
+
if not math.isfinite(age_seconds) or age_seconds <= 0:
|
|
51
|
+
return max(min_score, float(base_score))
|
|
52
|
+
if not math.isfinite(rate_per_second) or rate_per_second <= 0:
|
|
53
|
+
return max(min_score, float(base_score))
|
|
54
|
+
decayed = float(base_score) * math.exp(-rate_per_second * age_seconds)
|
|
55
|
+
return max(min_score, decayed)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LightCollection[T]:
|
|
59
|
+
"""FIFO/LRU collection without reactive scoring."""
|
|
60
|
+
|
|
61
|
+
__slots__ = ("_entries", "_entries_node", "_max_size", "_policy")
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
max_size: int | None = None,
|
|
67
|
+
policy: CollectionPolicy = "fifo",
|
|
68
|
+
name: str | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
if max_size is not None and max_size < 1:
|
|
71
|
+
raise ValueError("max_size must be >= 1")
|
|
72
|
+
self._max_size = max_size
|
|
73
|
+
self._policy = policy
|
|
74
|
+
self._entries: dict[str, LightCollectionEntry[T]] = {}
|
|
75
|
+
self._entries_node: NodeImpl[Mapping[str, LightCollectionEntry[T]]] = state(
|
|
76
|
+
MappingProxyType({}),
|
|
77
|
+
name=name,
|
|
78
|
+
describe_kind="state",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def entries(self) -> Node[Mapping[str, LightCollectionEntry[T]]]:
|
|
83
|
+
return self._entries_node
|
|
84
|
+
|
|
85
|
+
def _commit(self) -> None:
|
|
86
|
+
snap = MappingProxyType(dict(self._entries))
|
|
87
|
+
self._entries_node.down([(MessageType.DATA, snap)])
|
|
88
|
+
|
|
89
|
+
def _evict_if_needed(self) -> None:
|
|
90
|
+
if self._max_size is None:
|
|
91
|
+
return
|
|
92
|
+
while len(self._entries) > self._max_size:
|
|
93
|
+
victim: LightCollectionEntry[T] | None = None
|
|
94
|
+
for entry in self._entries.values():
|
|
95
|
+
if victim is None:
|
|
96
|
+
victim = entry
|
|
97
|
+
continue
|
|
98
|
+
lhs = entry.last_access_ns if self._policy == "lru" else entry.created_at_ns
|
|
99
|
+
rhs = victim.last_access_ns if self._policy == "lru" else victim.created_at_ns
|
|
100
|
+
if lhs < rhs:
|
|
101
|
+
victim = entry
|
|
102
|
+
if victim is None:
|
|
103
|
+
return
|
|
104
|
+
self._entries.pop(victim.id, None)
|
|
105
|
+
|
|
106
|
+
def upsert(self, item_id: str, value: T) -> None:
|
|
107
|
+
now = monotonic_ns()
|
|
108
|
+
prev = self._entries.get(item_id)
|
|
109
|
+
self._entries[item_id] = LightCollectionEntry(
|
|
110
|
+
id=item_id,
|
|
111
|
+
value=value,
|
|
112
|
+
created_at_ns=prev.created_at_ns if prev is not None else now,
|
|
113
|
+
last_access_ns=now,
|
|
114
|
+
)
|
|
115
|
+
self._evict_if_needed()
|
|
116
|
+
self._commit()
|
|
117
|
+
|
|
118
|
+
def remove(self, item_id: str) -> None:
|
|
119
|
+
if item_id not in self._entries:
|
|
120
|
+
return
|
|
121
|
+
del self._entries[item_id]
|
|
122
|
+
self._commit()
|
|
123
|
+
|
|
124
|
+
def clear(self) -> None:
|
|
125
|
+
if not self._entries:
|
|
126
|
+
return
|
|
127
|
+
self._entries.clear()
|
|
128
|
+
self._commit()
|
|
129
|
+
|
|
130
|
+
def get(self, item_id: str) -> T | None:
|
|
131
|
+
found = self._entries.get(item_id)
|
|
132
|
+
if found is None:
|
|
133
|
+
return None
|
|
134
|
+
if self._policy == "lru":
|
|
135
|
+
refreshed = LightCollectionEntry(
|
|
136
|
+
id=found.id,
|
|
137
|
+
value=found.value,
|
|
138
|
+
created_at_ns=found.created_at_ns,
|
|
139
|
+
last_access_ns=monotonic_ns(),
|
|
140
|
+
)
|
|
141
|
+
self._entries[item_id] = refreshed
|
|
142
|
+
self._commit()
|
|
143
|
+
return refreshed.value
|
|
144
|
+
return found.value
|
|
145
|
+
|
|
146
|
+
def has(self, item_id: str) -> bool:
|
|
147
|
+
return item_id in self._entries
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class CollectionEntry[T]:
|
|
152
|
+
id: str
|
|
153
|
+
value: T
|
|
154
|
+
base_score: float
|
|
155
|
+
created_at_ns: int
|
|
156
|
+
last_access_ns: int
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class RankedCollectionEntry[T]:
|
|
161
|
+
id: str
|
|
162
|
+
value: T
|
|
163
|
+
base_score: float
|
|
164
|
+
score: float
|
|
165
|
+
created_at_ns: int
|
|
166
|
+
last_access_ns: int
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class CollectionGraph[T](Graph):
|
|
170
|
+
"""Scored memory collection represented as a graph."""
|
|
171
|
+
|
|
172
|
+
__slots__ = (
|
|
173
|
+
"_items",
|
|
174
|
+
"_items_node",
|
|
175
|
+
"_max_size",
|
|
176
|
+
"_policy",
|
|
177
|
+
"_score_fn",
|
|
178
|
+
"_decay_rate",
|
|
179
|
+
"_min_score",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
name: str,
|
|
185
|
+
*,
|
|
186
|
+
max_size: int | None = None,
|
|
187
|
+
policy: CollectionPolicy = "lru",
|
|
188
|
+
score: Callable[[T], float] | None = None,
|
|
189
|
+
decay_rate: float = 0.0,
|
|
190
|
+
min_score: float = 0.0,
|
|
191
|
+
opts: dict[str, Any] | None = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
super().__init__(name, opts)
|
|
194
|
+
if max_size is not None and max_size < 1:
|
|
195
|
+
raise ValueError("max_size must be >= 1")
|
|
196
|
+
self._max_size = max_size
|
|
197
|
+
self._policy = policy
|
|
198
|
+
self._score_fn = score if score is not None else (lambda _v: 1.0)
|
|
199
|
+
self._decay_rate = decay_rate
|
|
200
|
+
self._min_score = min_score
|
|
201
|
+
self._items: dict[str, CollectionEntry[T]] = {}
|
|
202
|
+
self._items_node: NodeImpl[Mapping[str, CollectionEntry[T]]] = state(
|
|
203
|
+
MappingProxyType({}),
|
|
204
|
+
name="items",
|
|
205
|
+
describe_kind="state",
|
|
206
|
+
)
|
|
207
|
+
ranked = derived(
|
|
208
|
+
[self._items_node],
|
|
209
|
+
lambda deps, _a: self._ranked(deps[0]),
|
|
210
|
+
initial=[],
|
|
211
|
+
name="ranked",
|
|
212
|
+
)
|
|
213
|
+
size = derived(
|
|
214
|
+
[self._items_node],
|
|
215
|
+
lambda deps, _a: len(deps[0]),
|
|
216
|
+
initial=0,
|
|
217
|
+
name="size",
|
|
218
|
+
)
|
|
219
|
+
ranked.subscribe(lambda _msgs: None)
|
|
220
|
+
size.subscribe(lambda _msgs: None)
|
|
221
|
+
self.add("items", self._items_node)
|
|
222
|
+
self.add("ranked", ranked)
|
|
223
|
+
self.add("size", size)
|
|
224
|
+
self.connect("items", "ranked")
|
|
225
|
+
self.connect("items", "size")
|
|
226
|
+
|
|
227
|
+
def _commit(self) -> None:
|
|
228
|
+
self._items_node.down([(MessageType.DATA, MappingProxyType(dict(self._items)))])
|
|
229
|
+
|
|
230
|
+
def _effective(self, entry: CollectionEntry[T], now_ns: int) -> float:
|
|
231
|
+
age_seconds = (now_ns - entry.last_access_ns) / 1_000_000_000
|
|
232
|
+
return decay(entry.base_score, age_seconds, self._decay_rate, self._min_score)
|
|
233
|
+
|
|
234
|
+
def _ranked(
|
|
235
|
+
self,
|
|
236
|
+
snapshot: Mapping[str, CollectionEntry[T]],
|
|
237
|
+
) -> list[RankedCollectionEntry[T]]:
|
|
238
|
+
now = monotonic_ns()
|
|
239
|
+
out = [
|
|
240
|
+
RankedCollectionEntry(
|
|
241
|
+
id=row.id,
|
|
242
|
+
value=row.value,
|
|
243
|
+
base_score=row.base_score,
|
|
244
|
+
score=self._effective(row, now),
|
|
245
|
+
created_at_ns=row.created_at_ns,
|
|
246
|
+
last_access_ns=row.last_access_ns,
|
|
247
|
+
)
|
|
248
|
+
for row in snapshot.values()
|
|
249
|
+
]
|
|
250
|
+
out.sort(key=lambda row: (row.score, row.last_access_ns), reverse=True)
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
def _evict_if_needed(self) -> None:
|
|
254
|
+
if self._max_size is None:
|
|
255
|
+
return
|
|
256
|
+
while len(self._items) > self._max_size:
|
|
257
|
+
now = monotonic_ns()
|
|
258
|
+
victim: CollectionEntry[T] | None = None
|
|
259
|
+
victim_score = float("inf")
|
|
260
|
+
for row in self._items.values():
|
|
261
|
+
score = self._effective(row, now)
|
|
262
|
+
if score < victim_score:
|
|
263
|
+
victim = row
|
|
264
|
+
victim_score = score
|
|
265
|
+
continue
|
|
266
|
+
if score == victim_score and victim is not None:
|
|
267
|
+
lhs = row.last_access_ns if self._policy == "lru" else row.created_at_ns
|
|
268
|
+
rhs = victim.last_access_ns if self._policy == "lru" else victim.created_at_ns
|
|
269
|
+
if lhs < rhs:
|
|
270
|
+
victim = row
|
|
271
|
+
if victim is None:
|
|
272
|
+
return
|
|
273
|
+
self._items.pop(victim.id, None)
|
|
274
|
+
|
|
275
|
+
def upsert(self, item_id: str, value: T, *, score: float | None = None) -> None:
|
|
276
|
+
now = monotonic_ns()
|
|
277
|
+
prev = self._items.get(item_id)
|
|
278
|
+
base_score = float(score if score is not None else self._score_fn(value))
|
|
279
|
+
self._items[item_id] = CollectionEntry(
|
|
280
|
+
id=item_id,
|
|
281
|
+
value=value,
|
|
282
|
+
base_score=base_score,
|
|
283
|
+
created_at_ns=prev.created_at_ns if prev is not None else now,
|
|
284
|
+
last_access_ns=now,
|
|
285
|
+
)
|
|
286
|
+
self._evict_if_needed()
|
|
287
|
+
self._commit()
|
|
288
|
+
|
|
289
|
+
def remove(self, item_id: str) -> None:
|
|
290
|
+
if item_id not in self._items:
|
|
291
|
+
return
|
|
292
|
+
del self._items[item_id]
|
|
293
|
+
self._commit()
|
|
294
|
+
|
|
295
|
+
def clear(self) -> None:
|
|
296
|
+
if not self._items:
|
|
297
|
+
return
|
|
298
|
+
self._items.clear()
|
|
299
|
+
self._commit()
|
|
300
|
+
|
|
301
|
+
def get_item(self, item_id: str) -> CollectionEntry[T] | None:
|
|
302
|
+
row = self._items.get(item_id)
|
|
303
|
+
if row is None:
|
|
304
|
+
return None
|
|
305
|
+
if self._policy == "lru":
|
|
306
|
+
refreshed = CollectionEntry(
|
|
307
|
+
id=row.id,
|
|
308
|
+
value=row.value,
|
|
309
|
+
base_score=row.base_score,
|
|
310
|
+
created_at_ns=row.created_at_ns,
|
|
311
|
+
last_access_ns=monotonic_ns(),
|
|
312
|
+
)
|
|
313
|
+
self._items[item_id] = refreshed
|
|
314
|
+
self._commit()
|
|
315
|
+
return refreshed
|
|
316
|
+
return row
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@dataclass(frozen=True, slots=True)
|
|
320
|
+
class VectorRecord[TMeta]:
|
|
321
|
+
id: str
|
|
322
|
+
vector: tuple[float, ...]
|
|
323
|
+
meta: TMeta | None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass(frozen=True, slots=True)
|
|
327
|
+
class VectorSearchResult[TMeta]:
|
|
328
|
+
id: str
|
|
329
|
+
score: float
|
|
330
|
+
meta: TMeta | None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class HnswAdapter[TMeta](Protocol):
|
|
334
|
+
def upsert(self, item_id: str, vector: Sequence[float], meta: TMeta | None = None) -> None: ...
|
|
335
|
+
|
|
336
|
+
def remove(self, item_id: str) -> None: ...
|
|
337
|
+
|
|
338
|
+
def clear(self) -> None: ...
|
|
339
|
+
|
|
340
|
+
def search(self, query: Sequence[float], k: int) -> Sequence[VectorSearchResult[TMeta]]: ...
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class VectorIndex[TMeta]:
|
|
344
|
+
"""Vector index with optional HNSW backend."""
|
|
345
|
+
|
|
346
|
+
__slots__ = ("_backend", "_dimension", "_entries", "_entries_node", "_hnsw")
|
|
347
|
+
|
|
348
|
+
def __init__(
|
|
349
|
+
self,
|
|
350
|
+
*,
|
|
351
|
+
backend: VectorBackend = "flat",
|
|
352
|
+
dimension: int | None = None,
|
|
353
|
+
hnsw_factory: Callable[[], HnswAdapter[TMeta]] | None = None,
|
|
354
|
+
) -> None:
|
|
355
|
+
self._backend = backend
|
|
356
|
+
self._dimension = dimension
|
|
357
|
+
self._entries: dict[str, VectorRecord[TMeta]] = {}
|
|
358
|
+
self._entries_node: NodeImpl[Mapping[str, VectorRecord[TMeta]]] = state(
|
|
359
|
+
MappingProxyType({}),
|
|
360
|
+
name="vector_index",
|
|
361
|
+
describe_kind="state",
|
|
362
|
+
)
|
|
363
|
+
self._hnsw = hnsw_factory() if backend == "hnsw" and hnsw_factory is not None else None
|
|
364
|
+
if backend == "hnsw" and self._hnsw is None:
|
|
365
|
+
msg = (
|
|
366
|
+
'vector_index backend "hnsw" requires an optional dependency adapter; '
|
|
367
|
+
"install your HNSW package and provide hnsw_factory."
|
|
368
|
+
)
|
|
369
|
+
raise ValueError(msg)
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def backend(self) -> VectorBackend:
|
|
373
|
+
return self._backend
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def entries(self) -> Node[Mapping[str, VectorRecord[TMeta]]]:
|
|
377
|
+
return self._entries_node
|
|
378
|
+
|
|
379
|
+
def _commit(self) -> None:
|
|
380
|
+
self._entries_node.down([(MessageType.DATA, MappingProxyType(dict(self._entries)))])
|
|
381
|
+
|
|
382
|
+
def _assert_dimension(self, vector: Sequence[float]) -> None:
|
|
383
|
+
if self._dimension is not None and len(vector) != self._dimension:
|
|
384
|
+
raise ValueError(
|
|
385
|
+
f"vector dimension mismatch: expected {self._dimension}, got {len(vector)}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def upsert(self, item_id: str, vector: Sequence[float], meta: TMeta | None = None) -> None:
|
|
389
|
+
self._assert_dimension(vector)
|
|
390
|
+
row = VectorRecord(id=item_id, vector=tuple(float(v) for v in vector), meta=meta)
|
|
391
|
+
self._entries[item_id] = row
|
|
392
|
+
if self._backend == "hnsw":
|
|
393
|
+
self._hnsw.upsert(item_id, vector, meta) # type: ignore[union-attr]
|
|
394
|
+
self._commit()
|
|
395
|
+
|
|
396
|
+
def remove(self, item_id: str) -> None:
|
|
397
|
+
if item_id not in self._entries:
|
|
398
|
+
return
|
|
399
|
+
del self._entries[item_id]
|
|
400
|
+
if self._backend == "hnsw":
|
|
401
|
+
self._hnsw.remove(item_id) # type: ignore[union-attr]
|
|
402
|
+
self._commit()
|
|
403
|
+
|
|
404
|
+
def clear(self) -> None:
|
|
405
|
+
if not self._entries:
|
|
406
|
+
return
|
|
407
|
+
self._entries.clear()
|
|
408
|
+
if self._backend == "hnsw":
|
|
409
|
+
self._hnsw.clear() # type: ignore[union-attr]
|
|
410
|
+
self._commit()
|
|
411
|
+
|
|
412
|
+
def search(self, query: Sequence[float], k: int = 5) -> list[VectorSearchResult[TMeta]]:
|
|
413
|
+
self._assert_dimension(query)
|
|
414
|
+
if k <= 0:
|
|
415
|
+
return []
|
|
416
|
+
if self._backend == "hnsw":
|
|
417
|
+
return list(self._hnsw.search(query, k)) # type: ignore[union-attr]
|
|
418
|
+
query_tuple = tuple(float(v) for v in query)
|
|
419
|
+
scored = [
|
|
420
|
+
VectorSearchResult(
|
|
421
|
+
id=row.id, score=_cosine_similarity(query_tuple, row.vector), meta=row.meta
|
|
422
|
+
)
|
|
423
|
+
for row in self._entries.values()
|
|
424
|
+
]
|
|
425
|
+
scored.sort(key=lambda row: row.score, reverse=True)
|
|
426
|
+
return scored[:k]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@dataclass(frozen=True, slots=True)
|
|
430
|
+
class KnowledgeEdge[TRelation: str]:
|
|
431
|
+
from_id: str
|
|
432
|
+
to_id: str
|
|
433
|
+
relation: TRelation
|
|
434
|
+
weight: float
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class KnowledgeGraph[TEntity, TRelation: str](Graph):
|
|
438
|
+
"""Entity + relation graph represented as a GraphRefly graph."""
|
|
439
|
+
|
|
440
|
+
__slots__ = ("_entities", "_relations", "_entities_node", "_edges_node")
|
|
441
|
+
|
|
442
|
+
def __init__(self, name: str, opts: dict[str, Any] | None = None) -> None:
|
|
443
|
+
super().__init__(name, opts)
|
|
444
|
+
self._entities: dict[str, TEntity] = {}
|
|
445
|
+
self._relations: list[KnowledgeEdge[TRelation]] = []
|
|
446
|
+
self._entities_node: NodeImpl[Mapping[str, TEntity]] = state(
|
|
447
|
+
MappingProxyType({}),
|
|
448
|
+
name="entities",
|
|
449
|
+
describe_kind="state",
|
|
450
|
+
)
|
|
451
|
+
self._edges_node: NodeImpl[tuple[KnowledgeEdge[TRelation], ...]] = state(
|
|
452
|
+
(),
|
|
453
|
+
name="edges",
|
|
454
|
+
describe_kind="state",
|
|
455
|
+
)
|
|
456
|
+
adjacency = derived(
|
|
457
|
+
[self._edges_node],
|
|
458
|
+
lambda deps, _a: _build_adjacency(deps[0]),
|
|
459
|
+
initial=MappingProxyType({}),
|
|
460
|
+
name="adjacency",
|
|
461
|
+
)
|
|
462
|
+
adjacency.subscribe(lambda _msgs: None)
|
|
463
|
+
self.add("entities", self._entities_node)
|
|
464
|
+
self.add("edges", self._edges_node)
|
|
465
|
+
self.add("adjacency", adjacency)
|
|
466
|
+
self.connect("edges", "adjacency")
|
|
467
|
+
|
|
468
|
+
def _commit_entities(self) -> None:
|
|
469
|
+
self._entities_node.down([(MessageType.DATA, MappingProxyType(dict(self._entities)))])
|
|
470
|
+
|
|
471
|
+
def _commit_edges(self) -> None:
|
|
472
|
+
self._edges_node.down([(MessageType.DATA, tuple(self._relations))])
|
|
473
|
+
|
|
474
|
+
def upsert_entity(self, entity_id: str, value: TEntity) -> None:
|
|
475
|
+
self._entities[entity_id] = value
|
|
476
|
+
self._commit_entities()
|
|
477
|
+
|
|
478
|
+
def remove_entity(self, entity_id: str) -> None:
|
|
479
|
+
had_entity = entity_id in self._entities
|
|
480
|
+
if had_entity:
|
|
481
|
+
del self._entities[entity_id]
|
|
482
|
+
next_edges = [
|
|
483
|
+
edge
|
|
484
|
+
for edge in self._relations
|
|
485
|
+
if edge.from_id != entity_id and edge.to_id != entity_id
|
|
486
|
+
]
|
|
487
|
+
if not had_entity and len(next_edges) == len(self._relations):
|
|
488
|
+
return
|
|
489
|
+
self._relations = next_edges
|
|
490
|
+
self._commit_entities()
|
|
491
|
+
self._commit_edges()
|
|
492
|
+
|
|
493
|
+
def link(self, from_id: str, to_id: str, relation: TRelation, weight: float = 1.0) -> None:
|
|
494
|
+
key = (from_id, to_id, relation)
|
|
495
|
+
replaced = False
|
|
496
|
+
next_edges: list[KnowledgeEdge[TRelation]] = []
|
|
497
|
+
for edge in self._relations:
|
|
498
|
+
if (edge.from_id, edge.to_id, edge.relation) == key:
|
|
499
|
+
next_edges.append(
|
|
500
|
+
KnowledgeEdge(
|
|
501
|
+
from_id=from_id, to_id=to_id, relation=relation, weight=float(weight)
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
replaced = True
|
|
505
|
+
else:
|
|
506
|
+
next_edges.append(edge)
|
|
507
|
+
if not replaced:
|
|
508
|
+
next_edges.append(
|
|
509
|
+
KnowledgeEdge(from_id=from_id, to_id=to_id, relation=relation, weight=float(weight))
|
|
510
|
+
)
|
|
511
|
+
self._relations = next_edges
|
|
512
|
+
self._commit_edges()
|
|
513
|
+
|
|
514
|
+
def unlink(self, from_id: str, to_id: str, relation: TRelation | None = None) -> None:
|
|
515
|
+
next_edges = [
|
|
516
|
+
edge
|
|
517
|
+
for edge in self._relations
|
|
518
|
+
if not (
|
|
519
|
+
edge.from_id == from_id
|
|
520
|
+
and edge.to_id == to_id
|
|
521
|
+
and (relation is None or edge.relation == relation)
|
|
522
|
+
)
|
|
523
|
+
]
|
|
524
|
+
if len(next_edges) == len(self._relations):
|
|
525
|
+
return
|
|
526
|
+
self._relations = next_edges
|
|
527
|
+
self._commit_edges()
|
|
528
|
+
|
|
529
|
+
def related(
|
|
530
|
+
self, entity_id: str, relation: TRelation | None = None
|
|
531
|
+
) -> list[KnowledgeEdge[TRelation]]:
|
|
532
|
+
return [
|
|
533
|
+
edge
|
|
534
|
+
for edge in self._relations
|
|
535
|
+
if (edge.from_id == entity_id or edge.to_id == entity_id)
|
|
536
|
+
and (relation is None or edge.relation == relation)
|
|
537
|
+
]
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def light_collection(
|
|
541
|
+
*,
|
|
542
|
+
max_size: int | None = None,
|
|
543
|
+
policy: CollectionPolicy = "fifo",
|
|
544
|
+
name: str | None = None,
|
|
545
|
+
) -> LightCollection[Any]:
|
|
546
|
+
"""Create an unscored FIFO/LRU collection."""
|
|
547
|
+
return LightCollection(max_size=max_size, policy=policy, name=name)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def collection(
|
|
551
|
+
name: str,
|
|
552
|
+
*,
|
|
553
|
+
max_size: int | None = None,
|
|
554
|
+
policy: CollectionPolicy = "lru",
|
|
555
|
+
score: Callable[[Any], float] | None = None,
|
|
556
|
+
decay_rate: float = 0.0,
|
|
557
|
+
min_score: float = 0.0,
|
|
558
|
+
opts: dict[str, Any] | None = None,
|
|
559
|
+
) -> CollectionGraph[Any]:
|
|
560
|
+
"""Create a scored memory collection graph."""
|
|
561
|
+
return CollectionGraph(
|
|
562
|
+
name,
|
|
563
|
+
max_size=max_size,
|
|
564
|
+
policy=policy,
|
|
565
|
+
score=score,
|
|
566
|
+
decay_rate=decay_rate,
|
|
567
|
+
min_score=min_score,
|
|
568
|
+
opts=opts,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def vector_index(
|
|
573
|
+
*,
|
|
574
|
+
backend: VectorBackend = "flat",
|
|
575
|
+
dimension: int | None = None,
|
|
576
|
+
hnsw_factory: Callable[[], HnswAdapter[Any]] | None = None,
|
|
577
|
+
) -> VectorIndex[Any]:
|
|
578
|
+
"""Create a vector index with optional HNSW backend adapter."""
|
|
579
|
+
return VectorIndex(
|
|
580
|
+
backend=backend,
|
|
581
|
+
dimension=dimension,
|
|
582
|
+
hnsw_factory=hnsw_factory,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def knowledge_graph(
|
|
587
|
+
name: str,
|
|
588
|
+
*,
|
|
589
|
+
opts: dict[str, Any] | None = None,
|
|
590
|
+
) -> KnowledgeGraph[Any, str]:
|
|
591
|
+
"""Create a knowledge graph domain graph."""
|
|
592
|
+
return KnowledgeGraph(name, opts=opts)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
|
|
596
|
+
n = max(len(a), len(b))
|
|
597
|
+
dot = 0.0
|
|
598
|
+
na = 0.0
|
|
599
|
+
nb = 0.0
|
|
600
|
+
for i in range(n):
|
|
601
|
+
avf = float(a[i]) if i < len(a) else 0.0
|
|
602
|
+
bvf = float(b[i]) if i < len(b) else 0.0
|
|
603
|
+
dot += avf * bvf
|
|
604
|
+
na += avf * avf
|
|
605
|
+
nb += bvf * bvf
|
|
606
|
+
if na == 0.0 or nb == 0.0:
|
|
607
|
+
return 0.0
|
|
608
|
+
return dot / math.sqrt(na * nb)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _build_adjacency(
|
|
612
|
+
edges: Sequence[KnowledgeEdge[Any]],
|
|
613
|
+
) -> Mapping[str, tuple[KnowledgeEdge[Any], ...]]:
|
|
614
|
+
raw: dict[str, list[KnowledgeEdge[Any]]] = {}
|
|
615
|
+
for edge in edges:
|
|
616
|
+
raw.setdefault(edge.from_id, []).append(edge)
|
|
617
|
+
return MappingProxyType({key: tuple(vals) for key, vals in raw.items()})
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
__all__ = [
|
|
621
|
+
"CollectionEntry",
|
|
622
|
+
"CollectionGraph",
|
|
623
|
+
"CollectionPolicy",
|
|
624
|
+
"HnswAdapter",
|
|
625
|
+
"KnowledgeEdge",
|
|
626
|
+
"KnowledgeGraph",
|
|
627
|
+
"LightCollection",
|
|
628
|
+
"LightCollectionEntry",
|
|
629
|
+
"RankedCollectionEntry",
|
|
630
|
+
"VectorBackend",
|
|
631
|
+
"VectorIndex",
|
|
632
|
+
"VectorRecord",
|
|
633
|
+
"VectorSearchResult",
|
|
634
|
+
"collection",
|
|
635
|
+
"decay",
|
|
636
|
+
"knowledge_graph",
|
|
637
|
+
"light_collection",
|
|
638
|
+
"vector_index",
|
|
639
|
+
]
|