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,439 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Any, Sequence
5
+
6
+ from ..governance import EntityResolutionGuard
7
+ from ..models import (
8
+ CompiledTBox,
9
+ EpisodicRecord,
10
+ GraphEdgeMutation,
11
+ GraphFactReadResult,
12
+ GraphVertexUpsert,
13
+ OutboxOperation,
14
+ PlatformTenantContext,
15
+ PlaybookRecord,
16
+ ReasoningTraceStep,
17
+ )
18
+ from ..ports import (
19
+ EpisodicAndSeedStoreAdapter,
20
+ GraphStoreAdapter,
21
+ PlaybookStoreAdapter,
22
+ RegistryStoreAdapter,
23
+ )
24
+
25
+
26
+ class InMemoryUnifiedAdapter(
27
+ RegistryStoreAdapter,
28
+ EpisodicAndSeedStoreAdapter,
29
+ GraphStoreAdapter,
30
+ PlaybookStoreAdapter,
31
+ ):
32
+ """
33
+ Zero-dependency in-memory reference adapter enforcing the exact same
34
+ RLS namespace isolation, hybrid RRF search, bi-temporal functional edge invalidation,
35
+ reasoning trace storage, transactional outbox, procedural playbooks, and GDPR lineage purge rules.
36
+ """
37
+
38
+ def __init__(self):
39
+ self.registry: dict[tuple[str, str], tuple[list[str], str, CompiledTBox]] = {}
40
+ self.episodes: list[EpisodicRecord] = []
41
+ self.seeds: dict[tuple[str, str], GraphVertexUpsert] = {}
42
+ self.edges: list[dict[str, Any]] = []
43
+ self.traces: dict[tuple[str, str], dict[str, Any]] = {}
44
+ self.outbox: dict[str, OutboxOperation] = {}
45
+ self.playbooks: dict[tuple[str, str], PlaybookRecord] = {}
46
+
47
+ @staticmethod
48
+ def _cosine(a: list[float], b: list[float]) -> float:
49
+ if not a or not b or len(a) != len(b):
50
+ return 0.0
51
+ dot = sum(x * y for x, y in zip(a, b))
52
+ na = math.sqrt(sum(x * x for x in a))
53
+ nb = math.sqrt(sum(y * y for y in b))
54
+ return dot / (na * nb) if (na and nb) else 0.0
55
+
56
+ async def register_app(
57
+ self,
58
+ tenant_id: str,
59
+ app_name: str,
60
+ read_namespaces: list[str],
61
+ write_namespace: str,
62
+ tbox: CompiledTBox,
63
+ ) -> None:
64
+ self.registry[(tenant_id, app_name)] = (
65
+ list(read_namespaces),
66
+ write_namespace,
67
+ tbox,
68
+ )
69
+
70
+ async def resolve_context(
71
+ self, tenant_id: str, app_name: str, user_id: str
72
+ ) -> PlatformTenantContext:
73
+ app_ns = f"app:{tenant_id}:{app_name}"
74
+ user_ns = f"user:{tenant_id}:{user_id}"
75
+ if (tenant_id, app_name) not in self.registry:
76
+ # Zero-config fallback: auto-provision Tier 0 isolated context + cross-app user namespace
77
+ return PlatformTenantContext(
78
+ tenant_id=tenant_id,
79
+ app_name=app_name,
80
+ user_id=user_id,
81
+ app_private_namespace=app_ns,
82
+ write_namespace=app_ns,
83
+ read_namespaces=[app_ns, user_ns, f"global:{tenant_id}:core"],
84
+ tbox=CompiledTBox(),
85
+ )
86
+ read_ns, write_ns, tbox = self.registry[(tenant_id, app_name)]
87
+ merged_read_ns = list(dict.fromkeys([*read_ns, user_ns]))
88
+ return PlatformTenantContext(
89
+ tenant_id=tenant_id,
90
+ app_name=app_name,
91
+ user_id=user_id,
92
+ app_private_namespace=app_ns,
93
+ write_namespace=write_ns,
94
+ read_namespaces=merged_read_ns,
95
+ tbox=tbox,
96
+ )
97
+
98
+ async def resolve_existing_vertex_id(
99
+ self,
100
+ ctx: PlatformTenantContext,
101
+ entity_class: str,
102
+ canonical_name: str,
103
+ embedding: list[float],
104
+ similarity_threshold: float = 0.91,
105
+ ) -> str | None:
106
+ best_vid: str | None = None
107
+ best_sim: float = -1.0
108
+ for (t_id, v_id), seed in self.seeds.items():
109
+ if t_id != ctx.tenant_id or seed.namespace not in ctx.read_namespaces:
110
+ continue
111
+ if seed.entity_class == entity_class:
112
+ sim = self._cosine(seed.embedding, embedding)
113
+ if EntityResolutionGuard.can_merge(
114
+ seed.canonical_name, canonical_name, sim, similarity_threshold
115
+ ):
116
+ if (
117
+ EntityResolutionGuard.normalize_name(seed.canonical_name)
118
+ == EntityResolutionGuard.normalize_name(canonical_name)
119
+ ):
120
+ return v_id
121
+ if sim > best_sim:
122
+ best_sim = sim
123
+ best_vid = v_id
124
+ return best_vid
125
+
126
+ async def commit_episodes_and_outbox(
127
+ self,
128
+ ctx: PlatformTenantContext,
129
+ episodes: Sequence[EpisodicRecord],
130
+ vertex_upserts: Sequence[GraphVertexUpsert],
131
+ outbox_op: OutboxOperation | None,
132
+ ) -> None:
133
+ allowed_write_ns = {
134
+ ctx.write_namespace,
135
+ ctx.app_private_namespace,
136
+ ctx.user_private_namespace,
137
+ }
138
+ for ep in episodes:
139
+ if ep.tenant_id != ctx.tenant_id or ep.app_name != ctx.app_name:
140
+ raise PermissionError("Episodic RLS violation: mismatched tenant_id or app_name")
141
+ self.episodes.append(ep)
142
+
143
+ for v in vertex_upserts:
144
+ if v.tenant_id != ctx.tenant_id or v.namespace not in allowed_write_ns:
145
+ raise PermissionError(
146
+ f"Seed write RLS violation: {v.namespace} not in {allowed_write_ns}"
147
+ )
148
+ self.seeds[(ctx.tenant_id, v.vertex_id)] = v
149
+
150
+ if outbox_op is not None:
151
+ self.outbox[outbox_op.outbox_id] = outbox_op
152
+
153
+ async def hybrid_seed_search(
154
+ self,
155
+ ctx: PlatformTenantContext,
156
+ query_text: str,
157
+ query_embedding: list[float],
158
+ limit: int = 8,
159
+ ) -> list[GraphVertexUpsert]:
160
+ candidates = [
161
+ s
162
+ for (t_id, _), s in self.seeds.items()
163
+ if t_id == ctx.tenant_id and s.namespace in ctx.read_namespaces
164
+ ]
165
+ if not candidates:
166
+ return []
167
+
168
+ # Reciprocal Rank Fusion (RRF) combining vector cosine rank and lexical BM25-like overlap rank
169
+ vec_ranked = sorted(
170
+ candidates,
171
+ key=lambda s: self._cosine(s.embedding, query_embedding),
172
+ reverse=True,
173
+ )
174
+ q_terms = {
175
+ w.strip(".,!?;:()[]\"'").lower()
176
+ for w in query_text.split()
177
+ if w.strip(".,!?;:()[]\"'")
178
+ }
179
+ lex_ranked = sorted(
180
+ candidates,
181
+ key=lambda s: len(
182
+ q_terms.intersection(
183
+ {
184
+ w.strip(".,!?;:()[]\"'").lower()
185
+ for w in (s.canonical_name + " " + s.summary).split()
186
+ }
187
+ )
188
+ ),
189
+ reverse=True,
190
+ )
191
+
192
+ rrf_scores: dict[str, float] = {}
193
+ by_id: dict[str, GraphVertexUpsert] = {}
194
+ for rank_idx, s in enumerate(vec_ranked, start=1):
195
+ rrf_scores[s.vertex_id] = rrf_scores.get(s.vertex_id, 0.0) + 1.0 / (60 + rank_idx)
196
+ by_id[s.vertex_id] = s
197
+ for rank_idx, s in enumerate(lex_ranked, start=1):
198
+ rrf_scores[s.vertex_id] = rrf_scores.get(s.vertex_id, 0.0) + 1.0 / (60 + rank_idx)
199
+ by_id[s.vertex_id] = s
200
+
201
+ sorted_ids = sorted(rrf_scores.keys(), key=lambda vid: rrf_scores[vid], reverse=True)
202
+ return [by_id[vid] for vid in sorted_ids[:limit]]
203
+
204
+ async def search_episodes(
205
+ self,
206
+ ctx: PlatformTenantContext,
207
+ query_embedding: list[float],
208
+ limit: int = 4,
209
+ ) -> list[EpisodicRecord]:
210
+ scoped = [
211
+ e
212
+ for e in self.episodes
213
+ if e.tenant_id == ctx.tenant_id
214
+ and e.app_name == ctx.app_name
215
+ and e.user_id == ctx.user_id
216
+ ]
217
+ scoped.sort(key=lambda e: self._cosine(e.embedding, query_embedding), reverse=True)
218
+ return scoped[:limit]
219
+
220
+ async def fetch_pending_outbox(self, batch_size: int = 25) -> list[OutboxOperation]:
221
+ pending = [op for op in self.outbox.values() if op.attempts < 5]
222
+ return pending[:batch_size]
223
+
224
+ async def mark_outbox_processed(self, outbox_id: str) -> None:
225
+ self.outbox.pop(outbox_id, None)
226
+
227
+ async def mark_outbox_failed(self, outbox_id: str, error_msg: str) -> None:
228
+ if outbox_id in self.outbox:
229
+ self.outbox[outbox_id].attempts += 1
230
+
231
+ async def upsert_vertices_and_bitemporal_edges(
232
+ self,
233
+ tenant_id: str,
234
+ vertices: Sequence[GraphVertexUpsert],
235
+ edges: Sequence[GraphEdgeMutation],
236
+ ) -> None:
237
+ for v in vertices:
238
+ existing = self.seeds.get((tenant_id, v.vertex_id))
239
+ if existing is None:
240
+ self.seeds[(tenant_id, v.vertex_id)] = v
241
+ else:
242
+ # Preserve existing embedding if outbox stripped it
243
+ merged = v.model_copy()
244
+ if not merged.embedding and existing.embedding:
245
+ merged.embedding = existing.embedding
246
+ self.seeds[(tenant_id, v.vertex_id)] = merged
247
+
248
+ for e in edges:
249
+ if e.is_functional:
250
+ for existing in self.edges:
251
+ if (
252
+ existing["tenant_id"] == tenant_id
253
+ and existing["source_vertex_id"] == e.source_vertex_id
254
+ and existing["predicate"] == e.predicate
255
+ and existing["namespace"] == e.namespace
256
+ and existing["t_invalid"] == "ACTIVE"
257
+ and existing["target_vertex_id"] != e.target_vertex_id
258
+ ):
259
+ existing["t_invalid"] = e.t_valid
260
+
261
+ # Idempotent check on (source_vertex_id, predicate, target_vertex_id, namespace, ACTIVE)
262
+ # HippoRAG-inspired Associative Reinforcement: re-confirming an active edge boosts salience!
263
+ already_active = False
264
+ for existing in self.edges:
265
+ if (
266
+ existing["tenant_id"] == tenant_id
267
+ and existing["source_vertex_id"] == e.source_vertex_id
268
+ and existing["target_vertex_id"] == e.target_vertex_id
269
+ and existing["predicate"] == e.predicate
270
+ and existing["namespace"] == e.namespace
271
+ and existing["t_invalid"] == "ACTIVE"
272
+ ):
273
+ existing["fact_summary"] = e.fact_summary
274
+ existing["reinforcement_count"] = int(existing.get("reinforcement_count", 1)) + 1
275
+ existing["confidence"] = min(
276
+ 1.0, max(float(existing.get("confidence", 0.9)), e.confidence) + 0.03
277
+ )
278
+ already_active = True
279
+ break
280
+
281
+ if not already_active:
282
+ self.edges.append(
283
+ {**e.model_dump(), "t_invalid": "ACTIVE", "reinforcement_count": 1}
284
+ )
285
+
286
+ async def expand_active_subgraph(
287
+ self,
288
+ ctx: PlatformTenantContext,
289
+ seed_vertex_ids: Sequence[str],
290
+ as_of_iso: str | None = None,
291
+ max_hops: int = 2,
292
+ limit: int = 25,
293
+ ) -> list[GraphFactReadResult]:
294
+ # Letta-inspired Core Persona Fast-Path: automatically include the user's cross-app
295
+ # `user:{tenant}:{user_id}` preference vertices in the initial frontier even if the
296
+ # current query vector is orthogonal to their persona preferences!
297
+ user_persona_vids = [
298
+ vid
299
+ for (t_id, vid), s in self.seeds.items()
300
+ if t_id == ctx.tenant_id and s.namespace == ctx.user_private_namespace
301
+ ]
302
+ combined_seeds = list(dict.fromkeys([*seed_vertex_ids, *user_persona_vids]))
303
+ if not combined_seeds:
304
+ return []
305
+ frontier = set(combined_seeds)
306
+ visited_vertices = set(combined_seeds)
307
+ seen_edge_keys: set[tuple[str, str, str, str, str]] = set()
308
+ out: list[GraphFactReadResult] = []
309
+
310
+ for _hop in range(max(1, max_hops)):
311
+ if not frontier or len(out) >= limit:
312
+ break
313
+ next_frontier: set[str] = set()
314
+ for r in self.edges:
315
+ if r["tenant_id"] != ctx.tenant_id or r["namespace"] not in ctx.read_namespaces:
316
+ continue
317
+ src_id = r["source_vertex_id"]
318
+ tgt_id = r["target_vertex_id"]
319
+ if src_id not in frontier and tgt_id not in frontier:
320
+ continue
321
+ if as_of_iso is None:
322
+ if r["t_invalid"] != "ACTIVE":
323
+ continue
324
+ else:
325
+ if not (
326
+ r["t_valid"] <= as_of_iso
327
+ and (r["t_invalid"] == "ACTIVE" or r["t_invalid"] > as_of_iso)
328
+ ):
329
+ continue
330
+
331
+ edge_key = (src_id, r["predicate"], tgt_id, r["namespace"], r["t_valid"])
332
+ if edge_key in seen_edge_keys:
333
+ continue
334
+
335
+ src_node = self.seeds.get((ctx.tenant_id, src_id))
336
+ tgt_node = self.seeds.get((ctx.tenant_id, tgt_id))
337
+ if src_node and tgt_node:
338
+ if (
339
+ src_node.namespace not in ctx.read_namespaces
340
+ or tgt_node.namespace not in ctx.read_namespaces
341
+ ):
342
+ continue
343
+ seen_edge_keys.add(edge_key)
344
+ out.append(
345
+ GraphFactReadResult(
346
+ source_name=src_node.canonical_name,
347
+ source_class=src_node.entity_class,
348
+ predicate=r["predicate"],
349
+ target_name=tgt_node.canonical_name,
350
+ target_class=tgt_node.entity_class,
351
+ fact_summary=r["fact_summary"],
352
+ t_valid=r["t_valid"],
353
+ t_invalid=r["t_invalid"],
354
+ namespace=r["namespace"],
355
+ source_episode_id=r.get("source_episode_id", ""),
356
+ confidence=r["confidence"],
357
+ reinforcement_count=int(r.get("reinforcement_count", 1)),
358
+ )
359
+ )
360
+ if src_id not in visited_vertices:
361
+ next_frontier.add(src_id)
362
+ if tgt_id not in visited_vertices:
363
+ next_frontier.add(tgt_id)
364
+ if len(out) >= limit:
365
+ break
366
+ visited_vertices.update(next_frontier)
367
+ frontier = next_frontier
368
+
369
+ return out[:limit]
370
+
371
+ async def record_reasoning_trace(
372
+ self,
373
+ ctx: PlatformTenantContext,
374
+ run_id: str,
375
+ session_id: str,
376
+ steps: Sequence[ReasoningTraceStep],
377
+ ) -> None:
378
+ self.traces[(ctx.tenant_id, run_id)] = {
379
+ "tenant_id": ctx.tenant_id,
380
+ "app_name": ctx.app_name,
381
+ "session_id": session_id,
382
+ "namespace": ctx.app_private_namespace,
383
+ "steps": [s.model_dump() for s in steps],
384
+ }
385
+
386
+ async def purge_user_data(self, ctx: PlatformTenantContext) -> list[str]:
387
+ purged = [
388
+ e.episode_id
389
+ for e in self.episodes
390
+ if e.user_id == ctx.user_id and e.tenant_id == ctx.tenant_id
391
+ ]
392
+ purged_set = set(purged)
393
+ self.episodes = [e for e in self.episodes if e.episode_id not in purged_set]
394
+ return purged
395
+
396
+ async def purge_episode_lineage(
397
+ self, tenant_id: str, episode_ids: Sequence[str], user_id: str
398
+ ) -> None:
399
+ ep_set = set(episode_ids)
400
+ self.edges = [
401
+ r
402
+ for r in self.edges
403
+ if not (
404
+ r["tenant_id"] == tenant_id
405
+ and (r["source_episode_id"] in ep_set or r["user_id"] == user_id)
406
+ )
407
+ ]
408
+
409
+ async def upsert_playbook(self, playbook: PlaybookRecord) -> PlaybookRecord:
410
+ for (t_id, pb_id), existing in list(self.playbooks.items()):
411
+ if (
412
+ t_id == playbook.tenant_id
413
+ and existing.namespace == playbook.namespace
414
+ and existing.trigger_intent.strip().lower()
415
+ == playbook.trigger_intent.strip().lower()
416
+ ):
417
+ playbook.playbook_id = pb_id
418
+ playbook.success_count = existing.success_count + 1
419
+ self.playbooks[(t_id, pb_id)] = playbook
420
+ return playbook
421
+ self.playbooks[(playbook.tenant_id, playbook.playbook_id)] = playbook
422
+ return playbook
423
+
424
+ async def match_playbooks(
425
+ self,
426
+ tenant_id: str,
427
+ read_namespaces: list[str],
428
+ query_embedding: list[float],
429
+ similarity_threshold: float = 0.85,
430
+ limit: int = 2,
431
+ ) -> list[PlaybookRecord]:
432
+ scored: list[tuple[float, PlaybookRecord]] = []
433
+ for (t_id, _), pb in self.playbooks.items():
434
+ if t_id == tenant_id and pb.namespace in read_namespaces:
435
+ sim = self._cosine(pb.embedding, query_embedding)
436
+ if sim >= similarity_threshold:
437
+ scored.append((sim, pb))
438
+ scored.sort(key=lambda item: (item[0], item[1].success_count), reverse=True)
439
+ return [pb for _, pb in scored[:limit]]
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..auth import CloudAuthProvider, EnterpriseTlsConfig
6
+ from .neptune_cypher import NeptuneOpenCypherAdapter
7
+
8
+
9
+ class Neo4jGraphAdapter(NeptuneOpenCypherAdapter):
10
+ """
11
+ Drop-in Neo4j Bolt replacement for `NeptuneOpenCypherAdapter`.
12
+ Reuses the exact openCypher queries while rewriting Neptune's ``~id`` token to
13
+ standard ``vertex_id`` and executing over `AsyncGraphDatabase`.
14
+ Supports Basic Auth, Neo4j Enterprise / Aura SSO Bearer Token (`bearer_token`),
15
+ dynamic `CloudAuthProvider`, and `EnterpriseTlsConfig`.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ uri: str,
21
+ auth: tuple[str, str] | None = None,
22
+ database: str = "neo4j",
23
+ auth_provider: CloudAuthProvider | None = None,
24
+ bearer_token: str | None = None,
25
+ tls_config: EnterpriseTlsConfig | None = None,
26
+ ):
27
+ self.uri = uri
28
+ self.auth = auth
29
+ self.database = database
30
+ self.auth_provider = auth_provider
31
+ self.bearer_token = bearer_token
32
+ self.tls_config = tls_config
33
+ self._driver: Any = None
34
+
35
+ async def close(self) -> None:
36
+ if self._driver is not None:
37
+ await self._driver.close()
38
+ self._driver = None
39
+
40
+ async def _run_cypher(
41
+ self, tenant_id: str, query: str, params: dict[str, Any]
42
+ ) -> list[dict[str, Any]]:
43
+ if self._driver is None:
44
+ import neo4j
45
+ from neo4j import AsyncGraphDatabase
46
+
47
+ resolved_auth: Any = self.auth
48
+ if self.auth_provider is not None:
49
+ user, pwd = await self.auth_provider.get_credentials()
50
+ resolved_auth = (user, pwd)
51
+ elif self.bearer_token is not None:
52
+ resolved_auth = neo4j.bearer_auth(self.bearer_token)
53
+
54
+ driver_kwargs: dict[str, Any] = {"auth": resolved_auth}
55
+ if (
56
+ self.tls_config is not None
57
+ and self.tls_config.enabled
58
+ and not self.uri.startswith(("neo4j+s://", "bolt+s://", "neo4j+ssc://", "bolt+ssc://"))
59
+ ):
60
+ driver_kwargs["encrypted"] = True
61
+ self._driver = AsyncGraphDatabase.driver(self.uri, **driver_kwargs)
62
+ neo_query = query.replace("`~id`", "vertex_id")
63
+ async with self._driver.session(database=self.database) as session:
64
+ result = await session.run(neo_query, parameters=params)
65
+ return [dict(record) async for record in result]