agentic-composite-memory 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.
@@ -0,0 +1,490 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Sequence
6
+
7
+ from ..governance import EntityResolutionGuard
8
+ from ..models import (
9
+ CompiledTBox,
10
+ EpisodicRecord,
11
+ GraphEdgeMutation,
12
+ GraphFactReadResult,
13
+ GraphVertexUpsert,
14
+ OutboxOperation,
15
+ PlatformTenantContext,
16
+ ReasoningTraceStep,
17
+ )
18
+ from ..ports import (
19
+ EpisodicAndSeedStoreAdapter,
20
+ GraphStoreAdapter,
21
+ RegistryStoreAdapter,
22
+ )
23
+ from .redis_playbook import RedisPlaybookAndWorkingAdapter
24
+
25
+
26
+ class RedisRegistryAdapter(RedisPlaybookAndWorkingAdapter, RegistryStoreAdapter):
27
+ """
28
+ Standalone Redis implementation of `RegistryStoreAdapter`.
29
+ Can be paired with any Seed, Graph, or Playbook adapter.
30
+ """
31
+
32
+ def _k_registry(self, tenant_id: str) -> str:
33
+ return f"{self.key_prefix}:registry:{tenant_id}"
34
+
35
+ async def register_app(
36
+ self,
37
+ tenant_id: str,
38
+ app_name: str,
39
+ read_namespaces: list[str],
40
+ write_namespace: str,
41
+ tbox: CompiledTBox,
42
+ ) -> None:
43
+ r = await self._get_redis()
44
+ if r is None:
45
+ raise RuntimeError("RedisRegistryAdapter requires a valid `redis_url`.")
46
+ payload = {
47
+ "tenant_id": tenant_id,
48
+ "app_name": app_name,
49
+ "read_namespaces": read_namespaces,
50
+ "write_namespace": write_namespace,
51
+ "tbox": tbox.model_dump(mode="json"),
52
+ }
53
+ await r.hset(self._k_registry(tenant_id), app_name, json.dumps(payload))
54
+
55
+ async def resolve_context(
56
+ self, tenant_id: str, app_name: str, user_id: str
57
+ ) -> PlatformTenantContext:
58
+ r = await self._get_redis()
59
+ if r is None:
60
+ raise RuntimeError("RedisRegistryAdapter requires a valid `redis_url`.")
61
+ app_private_ns = f"app:{tenant_id}:{app_name}"
62
+ user_private_ns = f"user:{tenant_id}:{user_id}"
63
+ raw = await r.hget(self._k_registry(tenant_id), app_name)
64
+ if not raw:
65
+ return PlatformTenantContext(
66
+ tenant_id=tenant_id,
67
+ app_name=app_name,
68
+ user_id=user_id,
69
+ app_private_namespace=app_private_ns,
70
+ write_namespace=app_private_ns,
71
+ read_namespaces=[
72
+ app_private_ns,
73
+ user_private_ns,
74
+ f"global:{tenant_id}:core",
75
+ ],
76
+ tbox=CompiledTBox(),
77
+ )
78
+ data = json.loads(raw)
79
+ merged_read_ns = list(
80
+ dict.fromkeys([*list(data["read_namespaces"]), user_private_ns])
81
+ )
82
+ return PlatformTenantContext(
83
+ tenant_id=tenant_id,
84
+ app_name=app_name,
85
+ user_id=user_id,
86
+ app_private_namespace=app_private_ns,
87
+ write_namespace=data["write_namespace"],
88
+ read_namespaces=merged_read_ns,
89
+ tbox=CompiledTBox.model_validate(data["tbox"]),
90
+ )
91
+
92
+
93
+ class RedisEpisodicAndSeedAdapter(
94
+ RedisPlaybookAndWorkingAdapter, EpisodicAndSeedStoreAdapter
95
+ ):
96
+ """
97
+ Standalone Redis implementation of `EpisodicAndSeedStoreAdapter` (Tier 1 Episodic
98
+ Storage, Tier 2 Hybrid RRF Seeding, Entity Resolution Guard, and Transactional Outbox).
99
+ Can be paired with Neo4j, Neptune, PostgresGraphAdapter, or RedisGraphAdapter.
100
+ """
101
+
102
+ def _k_episodes(self, tenant_id: str) -> str:
103
+ return f"{self.key_prefix}:episodes:{tenant_id}"
104
+
105
+ def _k_vertices(self, tenant_id: str) -> str:
106
+ return f"{self.key_prefix}:vertices:{tenant_id}"
107
+
108
+ def _k_outbox(self) -> str:
109
+ return f"{self.key_prefix}:outbox:global"
110
+
111
+ async def resolve_existing_vertex_id(
112
+ self,
113
+ ctx: PlatformTenantContext,
114
+ entity_class: str,
115
+ canonical_name: str,
116
+ embedding: list[float],
117
+ similarity_threshold: float = 0.91,
118
+ ) -> str | None:
119
+ r = await self._get_redis()
120
+ raw_vertices = await r.hvals(self._k_vertices(ctx.tenant_id))
121
+ best_vid: str | None = None
122
+ best_score: float = -1.0
123
+ for raw in raw_vertices:
124
+ v = GraphVertexUpsert.model_validate_json(raw)
125
+ if v.namespace not in ctx.read_namespaces or v.entity_class != entity_class:
126
+ continue
127
+ sim = self._cosine(v.embedding, embedding)
128
+ if EntityResolutionGuard.can_merge(
129
+ v.canonical_name, canonical_name, sim, similarity_threshold
130
+ ):
131
+ if v.canonical_name.strip().lower() == canonical_name.strip().lower():
132
+ return v.vertex_id
133
+ if sim > best_score:
134
+ best_score = sim
135
+ best_vid = v.vertex_id
136
+ return best_vid
137
+
138
+ async def commit_episodes_and_outbox(
139
+ self,
140
+ ctx: PlatformTenantContext,
141
+ episodes: Sequence[EpisodicRecord],
142
+ vertex_upserts: Sequence[GraphVertexUpsert],
143
+ outbox_op: OutboxOperation | None,
144
+ ) -> None:
145
+ r = await self._get_redis()
146
+ pipe = r.pipeline(transaction=True)
147
+ for ep in episodes:
148
+ pipe.hset(
149
+ self._k_episodes(ctx.tenant_id),
150
+ ep.episode_id,
151
+ ep.model_dump_json(),
152
+ )
153
+ for v in vertex_upserts:
154
+ pipe.hset(
155
+ self._k_vertices(ctx.tenant_id),
156
+ v.vertex_id,
157
+ v.model_dump_json(),
158
+ )
159
+ if outbox_op is not None:
160
+ pipe.hset(
161
+ self._k_outbox(),
162
+ outbox_op.outbox_id,
163
+ outbox_op.model_dump_json(),
164
+ )
165
+ await pipe.execute()
166
+
167
+ async def hybrid_seed_search(
168
+ self,
169
+ ctx: PlatformTenantContext,
170
+ query_text: str,
171
+ query_embedding: list[float],
172
+ limit: int = 8,
173
+ ) -> list[GraphVertexUpsert]:
174
+ r = await self._get_redis()
175
+ raw_vertices = await r.hvals(self._k_vertices(ctx.tenant_id))
176
+ candidates: list[GraphVertexUpsert] = []
177
+ pinned_user_seeds: list[GraphVertexUpsert] = []
178
+ user_ns = f"user:{ctx.tenant_id}:{ctx.user_id}"
179
+
180
+ for raw in raw_vertices:
181
+ v = GraphVertexUpsert.model_validate_json(raw)
182
+ if v.namespace not in ctx.read_namespaces:
183
+ continue
184
+ if v.namespace == user_ns:
185
+ pinned_user_seeds.append(v)
186
+ candidates.append(v)
187
+
188
+ if not candidates:
189
+ return []
190
+
191
+ q_tokens = set(re.findall(r"[a-z0-9_]+", query_text.lower()))
192
+ vec_ranked = sorted(
193
+ candidates,
194
+ key=lambda x: self._cosine(x.embedding, query_embedding),
195
+ reverse=True,
196
+ )
197
+ lex_ranked = sorted(
198
+ candidates,
199
+ key=lambda x: len(
200
+ q_tokens
201
+ & set(
202
+ re.findall(
203
+ r"[a-z0-9_]+", f"{x.canonical_name} {x.summary}".lower()
204
+ )
205
+ )
206
+ ),
207
+ reverse=True,
208
+ )
209
+
210
+ rrf_scores: dict[str, float] = {}
211
+ by_id: dict[str, GraphVertexUpsert] = {}
212
+ for rank, v in enumerate(vec_ranked, start=1):
213
+ by_id[v.vertex_id] = v
214
+ rrf_scores[v.vertex_id] = rrf_scores.get(v.vertex_id, 0.0) + 1.0 / (
215
+ 60 + rank
216
+ )
217
+ for rank, v in enumerate(lex_ranked, start=1):
218
+ by_id[v.vertex_id] = v
219
+ rrf_scores[v.vertex_id] = rrf_scores.get(v.vertex_id, 0.0) + 1.0 / (
220
+ 60 + rank
221
+ )
222
+
223
+ sorted_ids = sorted(
224
+ rrf_scores.keys(), key=lambda vid: rrf_scores[vid], reverse=True
225
+ )
226
+ top_seeds = [by_id[vid] for vid in sorted_ids[:limit]]
227
+ seen_ids = {v.vertex_id for v in top_seeds}
228
+ for pv in pinned_user_seeds:
229
+ if pv.vertex_id not in seen_ids:
230
+ top_seeds.append(pv)
231
+ seen_ids.add(pv.vertex_id)
232
+ return top_seeds
233
+
234
+ async def search_episodes(
235
+ self,
236
+ ctx: PlatformTenantContext,
237
+ query_embedding: list[float],
238
+ limit: int = 4,
239
+ ) -> list[EpisodicRecord]:
240
+ r = await self._get_redis()
241
+ raw_eps = await r.hvals(self._k_episodes(ctx.tenant_id))
242
+ eps: list[EpisodicRecord] = []
243
+ for raw in raw_eps:
244
+ ep = EpisodicRecord.model_validate_json(raw)
245
+ if ep.app_name == ctx.app_name and ep.user_id == ctx.user_id:
246
+ eps.append(ep)
247
+ eps.sort(key=lambda e: self._cosine(e.embedding, query_embedding), reverse=True)
248
+ return eps[:limit]
249
+
250
+ async def fetch_pending_outbox(self, batch_size: int = 25) -> list[OutboxOperation]:
251
+ r = await self._get_redis()
252
+ raw_ops = await r.hvals(self._k_outbox())
253
+ pending: list[OutboxOperation] = []
254
+ for raw in raw_ops:
255
+ op = OutboxOperation.model_validate_json(raw)
256
+ if op.status in ("PENDING", "RETRY") and op.attempts < 5:
257
+ pending.append(op)
258
+ return pending[:batch_size]
259
+
260
+ async def mark_outbox_processed(self, outbox_id: str) -> None:
261
+ r = await self._get_redis()
262
+ raw = await r.hget(self._k_outbox(), outbox_id)
263
+ if raw:
264
+ op = OutboxOperation.model_validate_json(raw)
265
+ op.status = "PROCESSED"
266
+ await r.hset(self._k_outbox(), outbox_id, op.model_dump_json())
267
+
268
+ async def mark_outbox_failed(self, outbox_id: str, error_msg: str) -> None:
269
+ r = await self._get_redis()
270
+ raw = await r.hget(self._k_outbox(), outbox_id)
271
+ if raw:
272
+ op = OutboxOperation.model_validate_json(raw)
273
+ op.attempts += 1
274
+ op.status = "DEAD_LETTER" if op.attempts >= 5 else "RETRY"
275
+ await r.hset(self._k_outbox(), outbox_id, op.model_dump_json())
276
+
277
+ async def purge_user_data(self, ctx: PlatformTenantContext) -> list[str]:
278
+ r = await self._get_redis()
279
+ raw_map = await r.hgetall(self._k_episodes(ctx.tenant_id))
280
+ purged_ids: list[str] = []
281
+ for ep_id, raw in raw_map.items():
282
+ ep = EpisodicRecord.model_validate_json(raw)
283
+ if ep.user_id == ctx.user_id:
284
+ purged_ids.append(ep_id)
285
+ if purged_ids:
286
+ await r.hdel(self._k_episodes(ctx.tenant_id), *purged_ids)
287
+ return purged_ids
288
+
289
+
290
+ class RedisGraphAdapter(RedisPlaybookAndWorkingAdapter, GraphStoreAdapter):
291
+ """
292
+ Standalone Redis implementation of `GraphStoreAdapter` (Bi-Temporal Semantic Graph
293
+ with functional edge invalidation & Tier 3 Procedural Reasoning Traces).
294
+ Can be paired with PostgresPgVectorAdapter, OpenSearchSeedAdapter, or RedisEpisodicAndSeedAdapter.
295
+ """
296
+
297
+ def _k_vertices(self, tenant_id: str) -> str:
298
+ return f"{self.key_prefix}:vertices:{tenant_id}"
299
+
300
+ def _k_edges(self, tenant_id: str) -> str:
301
+ return f"{self.key_prefix}:edges:{tenant_id}"
302
+
303
+ def _k_traces(self, tenant_id: str) -> str:
304
+ return f"{self.key_prefix}:traces:{tenant_id}"
305
+
306
+ async def upsert_vertices_and_bitemporal_edges(
307
+ self,
308
+ tenant_id: str,
309
+ vertices: Sequence[GraphVertexUpsert],
310
+ edges: Sequence[GraphEdgeMutation],
311
+ ) -> None:
312
+ r = await self._get_redis()
313
+ for v in vertices:
314
+ existing_raw = await r.hget(self._k_vertices(tenant_id), v.vertex_id)
315
+ if existing_raw and not v.embedding:
316
+ old_v = GraphVertexUpsert.model_validate_json(existing_raw)
317
+ v.embedding = old_v.embedding
318
+ await r.hset(self._k_vertices(tenant_id), v.vertex_id, v.model_dump_json())
319
+
320
+ raw_edges = await r.hgetall(self._k_edges(tenant_id))
321
+ existing_edges: dict[str, GraphEdgeMutation] = {
322
+ eid: GraphEdgeMutation.model_validate_json(raw)
323
+ for eid, raw in raw_edges.items()
324
+ }
325
+
326
+ for new_e in edges:
327
+ if new_e.is_functional:
328
+ for eid, cur_e in existing_edges.items():
329
+ if (
330
+ cur_e.source_vertex_id == new_e.source_vertex_id
331
+ and cur_e.predicate == new_e.predicate
332
+ and cur_e.namespace == new_e.namespace
333
+ and cur_e.t_invalid == "ACTIVE"
334
+ and cur_e.target_vertex_id != new_e.target_vertex_id
335
+ ):
336
+ cur_e.t_invalid = new_e.t_valid
337
+ await r.hset(
338
+ self._k_edges(tenant_id), eid, cur_e.model_dump_json()
339
+ )
340
+
341
+ matched_dup = False
342
+ for eid, cur_e in existing_edges.items():
343
+ if (
344
+ cur_e.source_vertex_id == new_e.source_vertex_id
345
+ and cur_e.predicate == new_e.predicate
346
+ and cur_e.target_vertex_id == new_e.target_vertex_id
347
+ and cur_e.namespace == new_e.namespace
348
+ and cur_e.t_invalid == "ACTIVE"
349
+ ):
350
+ cur_e.fact_summary = new_e.fact_summary
351
+ cur_e.reinforcement_count += 1
352
+ cur_e.confidence = min(1.0, cur_e.confidence + 0.03)
353
+ await r.hset(self._k_edges(tenant_id), eid, cur_e.model_dump_json())
354
+ matched_dup = True
355
+ break
356
+
357
+ if not matched_dup:
358
+ existing_edges[new_e.edge_id] = new_e
359
+ await r.hset(
360
+ self._k_edges(tenant_id),
361
+ new_e.edge_id,
362
+ new_e.model_dump_json(),
363
+ )
364
+
365
+ async def expand_active_subgraph(
366
+ self,
367
+ ctx: PlatformTenantContext,
368
+ seed_vertex_ids: Sequence[str],
369
+ as_of_iso: str | None = None,
370
+ max_hops: int = 2,
371
+ limit: int = 25,
372
+ ) -> list[GraphFactReadResult]:
373
+ if not seed_vertex_ids:
374
+ return []
375
+ r = await self._get_redis()
376
+ raw_vertices = await r.hgetall(self._k_vertices(ctx.tenant_id))
377
+ vertices = {
378
+ vid: GraphVertexUpsert.model_validate_json(raw)
379
+ for vid, raw in raw_vertices.items()
380
+ }
381
+ raw_edges = await r.hvals(self._k_edges(ctx.tenant_id))
382
+ all_edges = [GraphEdgeMutation.model_validate_json(raw) for raw in raw_edges]
383
+
384
+ frontier = set(seed_vertex_ids)
385
+ visited_vertices = set(seed_vertex_ids)
386
+ seen_edge_ids: set[str] = set()
387
+ results: list[GraphFactReadResult] = []
388
+
389
+ for _hop in range(max(1, max_hops)):
390
+ if not frontier or len(results) >= limit:
391
+ break
392
+ next_frontier: set[str] = set()
393
+ for e in all_edges:
394
+ if e.edge_id in seen_edge_ids:
395
+ continue
396
+ if e.namespace not in ctx.read_namespaces:
397
+ continue
398
+ if (
399
+ e.source_vertex_id not in frontier
400
+ and e.target_vertex_id not in frontier
401
+ ):
402
+ continue
403
+ if as_of_iso is None:
404
+ if e.t_invalid != "ACTIVE":
405
+ continue
406
+ else:
407
+ if not (
408
+ e.t_valid <= as_of_iso
409
+ and (e.t_invalid == "ACTIVE" or e.t_invalid > as_of_iso)
410
+ ):
411
+ continue
412
+
413
+ src_v = vertices.get(e.source_vertex_id)
414
+ tgt_v = vertices.get(e.target_vertex_id)
415
+ if not src_v or not tgt_v:
416
+ continue
417
+ if (
418
+ src_v.namespace not in ctx.read_namespaces
419
+ or tgt_v.namespace not in ctx.read_namespaces
420
+ ):
421
+ continue
422
+
423
+ seen_edge_ids.add(e.edge_id)
424
+ results.append(
425
+ GraphFactReadResult(
426
+ source_name=src_v.canonical_name,
427
+ source_class=src_v.entity_class,
428
+ predicate=e.predicate,
429
+ target_name=tgt_v.canonical_name,
430
+ target_class=tgt_v.entity_class,
431
+ fact_summary=e.fact_summary,
432
+ t_valid=e.t_valid,
433
+ t_invalid=e.t_invalid,
434
+ namespace=e.namespace,
435
+ source_episode_id=e.source_episode_id,
436
+ confidence=e.confidence,
437
+ reinforcement_count=e.reinforcement_count,
438
+ )
439
+ )
440
+ for vid in (e.source_vertex_id, e.target_vertex_id):
441
+ if vid not in visited_vertices:
442
+ visited_vertices.add(vid)
443
+ next_frontier.add(vid)
444
+ if len(results) >= limit:
445
+ break
446
+ frontier = next_frontier
447
+
448
+ return results[:limit]
449
+
450
+ async def record_reasoning_trace(
451
+ self,
452
+ ctx: PlatformTenantContext,
453
+ run_id: str,
454
+ session_id: str,
455
+ steps: Sequence[ReasoningTraceStep],
456
+ ) -> None:
457
+ r = await self._get_redis()
458
+ payload = {
459
+ "run_id": run_id,
460
+ "session_id": session_id,
461
+ "app_name": ctx.app_name,
462
+ "namespace": ctx.app_private_namespace,
463
+ "steps": [s.model_dump(mode="json") for s in steps],
464
+ }
465
+ await r.hset(self._k_traces(ctx.tenant_id), run_id, json.dumps(payload))
466
+
467
+ async def purge_episode_lineage(
468
+ self, tenant_id: str, episode_ids: Sequence[str], user_id: str
469
+ ) -> None:
470
+ r = await self._get_redis()
471
+ raw_edges = await r.hgetall(self._k_edges(tenant_id))
472
+ to_delete: list[str] = []
473
+ ep_set = set(episode_ids)
474
+ for eid, raw in raw_edges.items():
475
+ e = GraphEdgeMutation.model_validate_json(raw)
476
+ if e.source_episode_id in ep_set or e.user_id == user_id:
477
+ to_delete.append(eid)
478
+ if to_delete:
479
+ await r.hdel(self._k_edges(tenant_id), *to_delete)
480
+
481
+
482
+ class RedisUnifiedAdapter(
483
+ RedisRegistryAdapter,
484
+ RedisEpisodicAndSeedAdapter,
485
+ RedisGraphAdapter,
486
+ ):
487
+ """
488
+ Single-Stack Pure Redis Unified Adapter (`RegistryStoreAdapter` +
489
+ `EpisodicAndSeedStoreAdapter` + `GraphStoreAdapter` + `PlaybookStoreAdapter`).
490
+ """