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,713 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ from collections import defaultdict, deque
6
+ from typing import Tuple
7
+
8
+ from .models import (
9
+ AppMemoryManifest,
10
+ CompiledTBox,
11
+ DomainPackSpec,
12
+ EpisodicRecord,
13
+ ExtractedEntity,
14
+ ExtractedFact,
15
+ ExtractionPayload,
16
+ GovernanceTier,
17
+ GraphFactReadResult,
18
+ MemoryScope,
19
+ OntologyAuditLogEntry,
20
+ PlatformTenantContext,
21
+ RelationRule,
22
+ UpperConceptType,
23
+ _default_layer0_relations,
24
+ )
25
+
26
+
27
+ class PIIScrubber:
28
+ """Deterministic pre-embedding and pre-extraction DLP sanitizer."""
29
+
30
+ _PATTERNS = [
31
+ (re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[REDACTED_SSN]"),
32
+ (re.compile(r"\b(?:\d[ -]*?){13,16}\b"), "[REDACTED_PAN]"),
33
+ (
34
+ re.compile(
35
+ r"(?i)\bBearer\s+[A-Za-z0-9_\-\.=]{12,}"
36
+ ),
37
+ "[REDACTED_SECRET]",
38
+ ),
39
+ (
40
+ re.compile(
41
+ r"(?i)(?:api[_-]?key|secret|token|password|Bearer)\s*[:=]\s*['\"]?[A-Za-z0-9_\-\.]{12,}['\"]?"
42
+ ),
43
+ "[REDACTED_SECRET]",
44
+ ),
45
+ (
46
+ re.compile(r"\bsk-[A-Za-z0-9]{16,}\b"),
47
+ "[REDACTED_API_KEY]",
48
+ ),
49
+ ]
50
+
51
+ @classmethod
52
+ def scrub(cls, text: str) -> str:
53
+ scrubbed = text
54
+ for pattern, replacement in cls._PATTERNS:
55
+ scrubbed = pattern.sub(replacement, scrubbed)
56
+ return scrubbed
57
+
58
+
59
+ class EntityResolutionGuard:
60
+ """
61
+ Prevents false-positive vector entity merges between distinct numbered, directional,
62
+ or environment-scoped identifiers (e.g., `us-east-1` vs `us-east-2`, `aurora-01` vs `aurora-02`,
63
+ `prod-db` vs `staging-db`) even when dense embedding cosine similarity is >= 0.91.
64
+ """
65
+
66
+ _DIRECTION_OR_ENV_TOKENS = {
67
+ "east",
68
+ "west",
69
+ "north",
70
+ "south",
71
+ "central",
72
+ "prod",
73
+ "production",
74
+ "stage",
75
+ "staging",
76
+ "dev",
77
+ "qa",
78
+ "primary",
79
+ "replica",
80
+ }
81
+
82
+ @classmethod
83
+ def normalize_name(cls, name: str) -> str:
84
+ return re.sub(r"[\s_\-]+", "-", name.strip().lower())
85
+
86
+ @classmethod
87
+ def extract_distinguishing_tokens(cls, name: str) -> tuple[set[str], set[str]]:
88
+ norm = cls.normalize_name(name)
89
+ # Extract normalized numeric tokens (e.g. '01' -> '1', '2' -> '2')
90
+ num_tokens = {str(int(m)) for m in re.findall(r"\d+", norm)}
91
+ words = set(norm.split("-"))
92
+ env_tokens = words.intersection(cls._DIRECTION_OR_ENV_TOKENS)
93
+ return num_tokens, env_tokens
94
+
95
+ @classmethod
96
+ def can_merge(
97
+ cls,
98
+ existing_name: str,
99
+ candidate_name: str,
100
+ cosine_sim: float,
101
+ threshold: float = 0.91,
102
+ ) -> bool:
103
+ if cls.normalize_name(existing_name) == cls.normalize_name(candidate_name):
104
+ return True
105
+
106
+ # Check for conflicting numbers or directional/environment qualifiers
107
+ nums_a, envs_a = cls.extract_distinguishing_tokens(existing_name)
108
+ nums_b, envs_b = cls.extract_distinguishing_tokens(candidate_name)
109
+
110
+ if (nums_a or nums_b) and nums_a != nums_b:
111
+ return False
112
+ if (envs_a or envs_b) and envs_a != envs_b:
113
+ return False
114
+
115
+ return cosine_sim >= threshold
116
+
117
+
118
+ class SmartMemoryClassifier:
119
+ """
120
+ Distinguishes memory types automatically at ingestion time:
121
+ 1. Ephemeral Chitchat / Noise (`is_ephemeral_turn`) -> Skips graph extraction and flags for TTL cleanup.
122
+ 2. Cross-App User Preferences (`is_user_scoped_entity` / `is_user_scoped_fact`) -> Routes to
123
+ `user:{tenant}:{user_id}` so preferences follow the user across all apps without leaking to other users.
124
+ 3. App-Private vs Domain-Shared vs Tenant-Global -> Routed by `TBoxRuntimeValidator`.
125
+ """
126
+
127
+ _EPHEMERAL_EXACT = {
128
+ "ok",
129
+ "okay",
130
+ "k",
131
+ "thanks",
132
+ "thank you",
133
+ "thx",
134
+ "got it",
135
+ "sounds good",
136
+ "cool",
137
+ "bye",
138
+ "goodbye",
139
+ "hello",
140
+ "hi",
141
+ "hey",
142
+ "ack",
143
+ "noted",
144
+ }
145
+
146
+ _EPHEMERAL_WORDS = {
147
+ "ok",
148
+ "okay",
149
+ "k",
150
+ "thanks",
151
+ "thank",
152
+ "you",
153
+ "thx",
154
+ "got",
155
+ "it",
156
+ "sounds",
157
+ "good",
158
+ "great",
159
+ "cool",
160
+ "bye",
161
+ "goodbye",
162
+ "hello",
163
+ "hi",
164
+ "hey",
165
+ "ack",
166
+ "noted",
167
+ "sure",
168
+ "yes",
169
+ "yep",
170
+ }
171
+
172
+ _USER_PREFERENCE_PREDICATES = {
173
+ "PREFERS",
174
+ "HAS_PREFERENCE",
175
+ "HAS_TIMEZONE",
176
+ "COMMUNICATION_STYLE",
177
+ "SPEAKS_LANGUAGE",
178
+ "WORKS_IN_ROLE",
179
+ }
180
+
181
+ _CONFIRMATION_WORDS = {
182
+ "yes",
183
+ "yep",
184
+ "no",
185
+ "nope",
186
+ "sure",
187
+ "ok",
188
+ "okay",
189
+ "correct",
190
+ "confirmed",
191
+ "approved",
192
+ }
193
+
194
+ @classmethod
195
+ def is_ephemeral_turn(
196
+ cls, text: str, preceding_text: str | None = None
197
+ ) -> bool:
198
+ """
199
+ Context-aware fast gate:
200
+ - Never drops short confirmations/negations ('yes', 'no', 'ok') when following a preceding
201
+ turn/question (`preceding_text`), so the LLM extractor sees the full Q&A context!
202
+ - Only flags unambiguous standalone terminal closings ('thanks!', 'bye') on the fast path,
203
+ while deferring nuanced or multi-sentence chitchat classification to the LLM (`ExtractionPayload.is_ephemeral_chitchat`).
204
+ """
205
+ cleaned = re.sub(r"[.!?,:;\"']+", "", text.strip().lower())
206
+ if not cleaned:
207
+ return True
208
+ words = cleaned.split()
209
+ # If there is preceding context (e.g. an agent question), do NOT drop confirmations like "yes" or "ok"!
210
+ if preceding_text and any(w in cls._CONFIRMATION_WORDS for w in words):
211
+ return False
212
+ if cleaned in cls._EPHEMERAL_EXACT:
213
+ return True
214
+ return len(words) <= 4 and all(w in cls._EPHEMERAL_WORDS for w in words)
215
+
216
+ @classmethod
217
+ def is_user_scoped_entity(cls, ent: ExtractedEntity, tbox: CompiledTBox) -> bool:
218
+ if ent.scope == MemoryScope.USER_GLOBAL:
219
+ return True
220
+ return tbox.is_subclass_of(
221
+ ent.entity_class, UpperConceptType.PREFERENCE.value
222
+ )
223
+
224
+ @classmethod
225
+ def is_user_scoped_fact(
226
+ cls, fact: ExtractedFact, user_preference_entities: set[str]
227
+ ) -> bool:
228
+ if fact.scope == MemoryScope.USER_GLOBAL:
229
+ return True
230
+ if fact.predicate in cls._USER_PREFERENCE_PREDICATES:
231
+ return True
232
+ return fact.target_entity in user_preference_entities
233
+
234
+
235
+ class TBoxRuntimeValidator:
236
+ """
237
+ Closed-world SHACL-equivalent runtime validator implementing Multi-Scope & Two-Bucket Routing.
238
+ - Scope 0 (User-Global `user:{tenant}:{user_id}`): User preferences & persona attributes
239
+ shared across all apps for that specific user, isolated from other users.
240
+ - Bucket 1 (Governed `domain:*` or `app:*`): High-confidence entities and facts conforming
241
+ to TBox classes, RDFS subClassOf hierarchies, disjointness rules, and domain/range constraints.
242
+ - Bucket 2 (Emergent Overflow `app:{tenant}:{app}`): Schema violations or low-confidence facts
243
+ are downgraded to `p:CandidateConcept` / `RELATED_TO` inside the app's private namespace.
244
+ """
245
+
246
+ @staticmethod
247
+ def validate_and_partition(
248
+ ctx: PlatformTenantContext,
249
+ payload: ExtractionPayload,
250
+ ) -> Tuple[list[Tuple[ExtractedEntity, str]], list[Tuple[ExtractedFact, str, bool]]]:
251
+ tbox: CompiledTBox = ctx.tbox
252
+ routed_entities: list[Tuple[ExtractedEntity, str]] = []
253
+ routed_facts: list[Tuple[ExtractedFact, str, bool]] = []
254
+
255
+ # Track resolved classes per canonical entity name (and detect disjointness collisions)
256
+ entity_class_map: dict[str, str] = {}
257
+ user_scoped_entities: set[str] = set()
258
+ disjoint_pairs: set[tuple[str, str]] = set()
259
+ for c1, c2 in tbox.disjoint_classes:
260
+ disjoint_pairs.add((c1, c2))
261
+ disjoint_pairs.add((c2, c1))
262
+
263
+ for ent in payload.entities:
264
+ ent_copy = ent.model_copy()
265
+ if ent_copy.scope == MemoryScope.EPHEMERAL:
266
+ continue
267
+
268
+ is_valid_class = ent_copy.entity_class in tbox.classes
269
+ meets_confidence = ent_copy.confidence >= tbox.min_domain_confidence
270
+
271
+ # Disjointness check if entity was seen earlier in the same batch with a disjoint class
272
+ prior_cls = entity_class_map.get(ent_copy.canonical_name)
273
+ disjoint_violation = (
274
+ prior_cls is not None and (prior_cls, ent_copy.entity_class) in disjoint_pairs
275
+ )
276
+
277
+ if SmartMemoryClassifier.is_user_scoped_entity(ent_copy, tbox):
278
+ if not is_valid_class:
279
+ ent_copy.entity_class = UpperConceptType.PREFERENCE.value
280
+ target_ns = ctx.user_private_namespace
281
+ if tbox.is_subclass_of(
282
+ ent_copy.entity_class, UpperConceptType.PREFERENCE.value
283
+ ):
284
+ user_scoped_entities.add(ent_copy.canonical_name)
285
+ elif ent_copy.scope == MemoryScope.APP_PRIVATE:
286
+ if not is_valid_class:
287
+ ent_copy.entity_class = UpperConceptType.CANDIDATE.value
288
+ target_ns = ctx.app_private_namespace
289
+ elif is_valid_class and meets_confidence and not disjoint_violation:
290
+ target_ns = ctx.write_namespace
291
+ elif tbox.allow_emergent_overflow:
292
+ orig_cls = ent_copy.entity_class
293
+ ent_copy.entity_class = UpperConceptType.CANDIDATE.value
294
+ if orig_cls and orig_cls != UpperConceptType.CANDIDATE.value and "[ProposedClass=" not in ent_copy.summary:
295
+ ent_copy.summary = f"[ProposedClass={orig_cls}] {ent_copy.summary}"
296
+ target_ns = ctx.app_private_namespace
297
+ else:
298
+ continue
299
+
300
+ entity_class_map[ent_copy.canonical_name] = ent_copy.entity_class
301
+ routed_entities.append((ent_copy, target_ns))
302
+
303
+ for fact in payload.facts:
304
+ fact_copy = fact.model_copy()
305
+ if fact_copy.scope == MemoryScope.EPHEMERAL:
306
+ continue
307
+
308
+ if SmartMemoryClassifier.is_user_scoped_fact(fact_copy, user_scoped_entities):
309
+ rel_rule = tbox.relations.get(fact_copy.predicate)
310
+ is_func = rel_rule.is_functional if rel_rule else False
311
+ routed_facts.append((fact_copy, ctx.user_private_namespace, is_func))
312
+ continue
313
+
314
+ if fact_copy.scope == MemoryScope.APP_PRIVATE:
315
+ rel_rule = tbox.relations.get(fact_copy.predicate)
316
+ is_func = rel_rule.is_functional if rel_rule else False
317
+ routed_facts.append((fact_copy, ctx.app_private_namespace, is_func))
318
+ continue
319
+
320
+ rel_rule = tbox.relations.get(fact_copy.predicate)
321
+ src_cls = entity_class_map.get(fact_copy.source_entity, fact_copy.source_class)
322
+ tgt_cls = entity_class_map.get(fact_copy.target_entity, fact_copy.target_class)
323
+
324
+ domain_ok = (
325
+ rel_rule is not None
326
+ and tbox.is_subclass_of(src_cls, rel_rule.domain_class)
327
+ and tbox.is_subclass_of(tgt_cls, rel_rule.range_class)
328
+ )
329
+ confidence_ok = fact_copy.confidence >= tbox.min_domain_confidence
330
+
331
+ if domain_ok and confidence_ok and rel_rule is not None:
332
+ routed_facts.append((fact_copy, ctx.write_namespace, rel_rule.is_functional))
333
+ elif tbox.allow_emergent_overflow:
334
+ # Quarantine to app-private namespace; overflow facts never invalidate functional edges
335
+ orig_pred = fact_copy.predicate
336
+ fact_copy.predicate = "RELATED_TO"
337
+ if orig_pred and orig_pred != "RELATED_TO" and "[ProposedPredicate=" not in fact_copy.fact_summary:
338
+ fact_copy.fact_summary = (
339
+ f"[ProposedPredicate={orig_pred}|{fact_copy.source_class}->{fact_copy.target_class}] "
340
+ f"{fact_copy.fact_summary}"
341
+ )
342
+ routed_facts.append((fact_copy, ctx.app_private_namespace, False))
343
+
344
+ return routed_entities, routed_facts
345
+
346
+
347
+ class PlatformOntologyRegistrar:
348
+ """
349
+ Compiles developer-friendly AppMemoryManifest declarations + Federated Domain Packs
350
+ into a CompiledTBox and registers the ADK app in the platform RegistryStoreAdapter.
351
+ """
352
+
353
+ def __init__(self, registry_adapter):
354
+ self.registry = registry_adapter
355
+ self._domain_packs: dict[str, DomainPackSpec] = {}
356
+ self.audit_log: list[OntologyAuditLogEntry] = []
357
+
358
+ @staticmethod
359
+ def infer_upper_concept_parent(class_name: str) -> str:
360
+ """
361
+ Semantic heuristic + Upper Ontology aligner: maps any unrooted domain class
362
+ to the appropriate Layer 0 `UpperConceptType` (`p:Actor`, `p:Object`, `p:Event`, `p:UserPreference`, `p:Concept`).
363
+ """
364
+ low = class_name.lower()
365
+ if any(k in low for k in ("user", "actor", "agent", "customer", "person", "team", "owner", "vendor", "patient", "investigator")):
366
+ return UpperConceptType.ACTOR.value
367
+ if any(k in low for k in ("event", "incident", "alert", "outage", "trial", "deployment", "transaction", "dispute", "call", "session")):
368
+ return UpperConceptType.EVENT.value
369
+ if any(k in low for k in ("pref", "setting", "style", "persona")):
370
+ return UpperConceptType.PREFERENCE.value
371
+ if any(k in low for k in ("service", "cluster", "database", "db", "tool", "asset", "system", "region", "device", "drug", "compound", "server", "api", "app")):
372
+ return UpperConceptType.OBJECT.value
373
+ return UpperConceptType.CONCEPT.value
374
+
375
+ @staticmethod
376
+ def infer_relation_flags(predicate: str) -> tuple[bool, bool]:
377
+ """
378
+ Infers `(is_functional, is_transitive)` when a service proposes an under-specified relation rule.
379
+ """
380
+ up = re.sub(r"[\s\-]+", "_", predicate.strip().upper())
381
+ is_func = any(
382
+ tok in up
383
+ for tok in (
384
+ "PRIMARY",
385
+ "CURRENT",
386
+ "ACTIVE_TIER",
387
+ "SUBSCRIBED_TO_TIER",
388
+ "HAS_STATUS",
389
+ "ASSIGNED_TO",
390
+ "OWNED_BY",
391
+ "MANAGED_BY",
392
+ "DEPLOYED_IN_PRIMARY",
393
+ )
394
+ )
395
+ is_trans = any(
396
+ tok in up
397
+ for tok in (
398
+ "DEPENDS_ON",
399
+ "PART_OF",
400
+ "SUBCLASS_OF",
401
+ "UPSTREAM_OF",
402
+ "DOWNSTREAM_OF",
403
+ )
404
+ )
405
+ return is_func, is_trans
406
+
407
+ def review_and_refine_domain_pack(
408
+ self,
409
+ pack: DomainPackSpec,
410
+ tenant_id: str = "platform",
411
+ auto_register: bool = True,
412
+ ) -> tuple[DomainPackSpec, OntologyAuditLogEntry]:
413
+ """
414
+ Behind-the-scenes Intelligent Ontology Steward:
415
+ Reviews a proposed `DomainPackSpec` (submitted by an app team or LLM),
416
+ automatically fixes unrooted classes, normalizes predicate names to UPPER_SNAKE_CASE,
417
+ infers missing `is_functional` (bi-temporal conflict invalidation) and `is_transitive` flags,
418
+ repairs broken domain/range class references, and records an immutable `OntologyAuditLogEntry`.
419
+ """
420
+ refined = pack.model_copy(deep=True)
421
+ modifications: list[str] = []
422
+ upper_set = {c.value for c in UpperConceptType} | {"owl:Thing"}
423
+
424
+ # 1. Ensure all classes are rooted in Layer 0 UpperConceptType
425
+ for cls_name, parent_cls in list(refined.classes.items()):
426
+ if not parent_cls or parent_cls in ("Thing", "owl:Thing", "None", ""):
427
+ inferred_parent = self.infer_upper_concept_parent(cls_name)
428
+ refined.classes[cls_name] = inferred_parent
429
+ modifications.append(
430
+ f"Rooted unanchored class '{cls_name}' under Layer 0 '{inferred_parent}'"
431
+ )
432
+ elif parent_cls not in upper_set and parent_cls not in refined.classes:
433
+ inferred_parent = self.infer_upper_concept_parent(parent_cls)
434
+ refined.classes[parent_cls] = inferred_parent
435
+ modifications.append(
436
+ f"Added missing intermediate parent class '{parent_cls}' -> '{inferred_parent}'"
437
+ )
438
+
439
+ # 2. Review and normalize relations
440
+ new_relations: dict[str, RelationRule] = {}
441
+ for raw_key, rel in refined.relations.items():
442
+ norm_pred = re.sub(r"[\s\-]+", "_", (rel.predicate or raw_key).strip().upper())
443
+ rel_copy = rel.model_copy(deep=True)
444
+ if rel_copy.predicate != norm_pred:
445
+ modifications.append(
446
+ f"Normalized predicate '{rel_copy.predicate}' -> '{norm_pred}'"
447
+ )
448
+ rel_copy.predicate = norm_pred
449
+
450
+ # Ensure domain_class and range_class exist in classes or Layer 0
451
+ for attr in ("domain_class", "range_class"):
452
+ cls_val = getattr(rel_copy, attr)
453
+ if cls_val not in upper_set and cls_val not in refined.classes:
454
+ inferred = self.infer_upper_concept_parent(cls_val)
455
+ refined.classes[cls_val] = inferred
456
+ modifications.append(
457
+ f"Auto-registered missing {attr} '{cls_val}' -> '{inferred}' referenced by '{norm_pred}'"
458
+ )
459
+
460
+ inferred_func, inferred_trans = self.infer_relation_flags(norm_pred)
461
+ if inferred_func and not rel_copy.is_functional:
462
+ rel_copy.is_functional = True
463
+ modifications.append(
464
+ f"Enabled bi-temporal functional invalidation (is_functional=True) on singular-state predicate '{norm_pred}'"
465
+ )
466
+ if inferred_trans and not rel_copy.is_transitive:
467
+ rel_copy.is_transitive = True
468
+ modifications.append(
469
+ f"Enabled multi-hop transitive closure (is_transitive=True) on predicate '{norm_pred}'"
470
+ )
471
+
472
+ new_relations[norm_pred] = rel_copy
473
+
474
+ refined.relations = new_relations
475
+
476
+ # Bump patch version if steward modified the proposed schema
477
+ prev_ver = pack.version
478
+ if modifications:
479
+ parts = (pack.version.split(".") + ["0", "0"])[:3]
480
+ try:
481
+ parts[2] = str(int(parts[2]) + 1)
482
+ refined.version = ".".join(parts)
483
+ except ValueError:
484
+ refined.version = f"{pack.version}-reviewed"
485
+
486
+ audit_entry = OntologyAuditLogEntry(
487
+ tenant_id=tenant_id,
488
+ target_id=refined.pack_id,
489
+ action="LLM_REVIEW_AND_REFINE",
490
+ previous_version=prev_ver,
491
+ new_version=refined.version,
492
+ added_or_modified_classes=dict(refined.classes),
493
+ added_or_modified_relations={
494
+ k: v.model_dump() for k, v in refined.relations.items()
495
+ },
496
+ modifications_summary=modifications or ["Verified clean: no schema modifications required"],
497
+ triggered_by="ontology_intelligence_steward",
498
+ )
499
+ self.audit_log.append(audit_entry)
500
+
501
+ if auto_register:
502
+ self.register_domain_pack(refined, record_audit=False)
503
+
504
+ return refined, audit_entry
505
+
506
+ def register_domain_pack(
507
+ self, pack: DomainPackSpec, record_audit: bool = True
508
+ ) -> None:
509
+ """Registers a Layer 1 Federated Domain Pack (e.g. 'payments@v2.1') in the platform catalog."""
510
+ self._domain_packs[pack.pack_id] = pack
511
+ self._domain_packs[pack.domain_name] = pack
512
+ if record_audit:
513
+ self.audit_log.append(
514
+ OntologyAuditLogEntry(
515
+ tenant_id="platform",
516
+ target_id=pack.pack_id,
517
+ action="REGISTER_DOMAIN_PACK",
518
+ previous_version="0.0.0",
519
+ new_version=pack.version,
520
+ added_or_modified_classes=dict(pack.classes),
521
+ added_or_modified_relations={
522
+ k: v.model_dump() for k, v in pack.relations.items()
523
+ },
524
+ modifications_summary=[f"Registered domain pack '{pack.pack_id}'"],
525
+ triggered_by="domain_steward",
526
+ )
527
+ )
528
+
529
+ def compile_manifest(self, manifest: AppMemoryManifest) -> tuple[list[str], str, CompiledTBox]:
530
+ """Computes read/write namespaces and merges Layer 0 + Layer 1 + Layer 2 TBox rules."""
531
+ app_ns = f"app:{manifest.tenant_id}:{manifest.app_name}"
532
+ global_ns = f"global:{manifest.tenant_id}:core"
533
+ domain_namespaces = [
534
+ f"domain:{manifest.tenant_id}:{pack.split('@')[0]}"
535
+ for pack in manifest.subscribed_domain_packs
536
+ ]
537
+
538
+ read_namespaces = [app_ns, global_ns] + domain_namespaces
539
+ # Deduplicate while preserving order
540
+ read_namespaces = list(dict.fromkeys(read_namespaces))
541
+
542
+ if (
543
+ manifest.governance_tier == GovernanceTier.TIER2_DOMAIN_SHARED
544
+ and domain_namespaces
545
+ ):
546
+ write_namespace = domain_namespaces[0]
547
+ else:
548
+ write_namespace = app_ns
549
+
550
+ # Layer 0 Base Classes & Universal Relations
551
+ classes: dict[str, str] = {c.value: "owl:Thing" for c in UpperConceptType}
552
+ disjoint_classes: list[tuple[str, str]] = []
553
+ relations: dict[str, RelationRule] = _default_layer0_relations()
554
+
555
+ # Layer 1 Subscribed Domain Packs
556
+ for pack_key in manifest.subscribed_domain_packs:
557
+ pack = self._domain_packs.get(pack_key) or self._domain_packs.get(
558
+ pack_key.split("@")[0]
559
+ )
560
+ if pack:
561
+ classes.update(pack.classes)
562
+ disjoint_classes.extend(pack.disjoint_classes)
563
+ relations.update(pack.relations)
564
+
565
+ # Layer 2 App-Local Custom Extensions
566
+ for cls_name, parent_cls in manifest.custom_classes.items():
567
+ parent_str = (
568
+ parent_cls.value if isinstance(parent_cls, UpperConceptType) else str(parent_cls)
569
+ )
570
+ classes[cls_name] = parent_str
571
+
572
+ for rel in manifest.custom_relations:
573
+ relations[rel.predicate] = rel
574
+
575
+ tbox = CompiledTBox(
576
+ governance_tier=manifest.governance_tier,
577
+ extraction_mode=manifest.extraction_mode,
578
+ allow_emergent_overflow=manifest.allow_emergent_overflow,
579
+ min_domain_confidence=manifest.min_domain_confidence,
580
+ classes=classes,
581
+ disjoint_classes=disjoint_classes,
582
+ relations=relations,
583
+ )
584
+ return read_namespaces, write_namespace, tbox
585
+
586
+ async def register_adk_app(self, manifest: AppMemoryManifest) -> CompiledTBox:
587
+ """Compiles and persists an ADK app's governance policy and TBox in the RegistryStoreAdapter."""
588
+ read_namespaces, write_namespace, tbox = self.compile_manifest(manifest)
589
+ await self.registry.register_app(
590
+ tenant_id=manifest.tenant_id,
591
+ app_name=manifest.app_name,
592
+ read_namespaces=read_namespaces,
593
+ write_namespace=write_namespace,
594
+ tbox=tbox,
595
+ )
596
+ return tbox
597
+
598
+
599
+ class AsyncCircuitBreaker:
600
+ """Protects the ADK read path from Graph DB outages or latency spikes."""
601
+
602
+ def __init__(self, failure_threshold: int = 3, recovery_timeout_sec: float = 30.0):
603
+ self.failure_threshold = failure_threshold
604
+ self.recovery_timeout_sec = recovery_timeout_sec
605
+ self.failures = 0
606
+ self.last_failure_time = 0.0
607
+ self.state = "CLOSED" # CLOSED, OPEN, HALF_OPEN
608
+
609
+ def allow_request(self) -> bool:
610
+ if self.state == "CLOSED":
611
+ return True
612
+ if self.state == "OPEN":
613
+ if (time.monotonic() - self.last_failure_time) >= self.recovery_timeout_sec:
614
+ self.state = "HALF_OPEN"
615
+ return True
616
+ return False
617
+ return True
618
+
619
+ def record_success(self) -> None:
620
+ self.failures = 0
621
+ self.state = "CLOSED"
622
+
623
+ def record_failure(self) -> None:
624
+ self.failures += 1
625
+ self.last_failure_time = time.monotonic()
626
+ if self.failures >= self.failure_threshold:
627
+ self.state = "OPEN"
628
+
629
+
630
+ class TenantRateLimiter:
631
+ """Per-app sliding window rate limiter preventing runaway agent loops from starving the platform."""
632
+
633
+ def __init__(self, window_sec: float = 60.0):
634
+ self.window_sec = window_sec
635
+ self._hits: dict[tuple[str, str], deque[float]] = defaultdict(deque)
636
+
637
+ def check_and_record(self, tenant_id: str, app_name: str, max_rpm: int = 600) -> bool:
638
+ now = time.monotonic()
639
+ key = (tenant_id, app_name)
640
+ q = self._hits[key]
641
+ while q and (now - q[0]) > self.window_sec:
642
+ q.popleft()
643
+ if len(q) >= max_rpm:
644
+ return False
645
+ q.append(now)
646
+ return True
647
+
648
+
649
+ class TokenBudgetSerializer:
650
+ """
651
+ Packs graph facts (ranked by HippoRAG-inspired associative salience = confidence * reinforcement boost,
652
+ with Letta-inspired pinned user persona facts) and episodic chunks into a bounded token budget (~4 chars/token).
653
+ """
654
+
655
+ @staticmethod
656
+ def compute_salience(fact: GraphFactReadResult) -> float:
657
+ import math
658
+
659
+ is_user_persona = fact.namespace.startswith("user:")
660
+ persona_boost = 0.25 if is_user_persona else 0.0
661
+ reinforcement_boost = 1.0 + 0.15 * math.log1p(max(0, fact.reinforcement_count - 1))
662
+ return (fact.confidence * reinforcement_boost) + persona_boost
663
+
664
+ @staticmethod
665
+ def serialize(
666
+ facts: list[GraphFactReadResult],
667
+ episodes: list[EpisodicRecord],
668
+ max_tokens: int = 1500,
669
+ degraded_mode: bool = False,
670
+ ) -> Tuple[str, list[EpisodicRecord]]:
671
+ max_chars = max_tokens * 4
672
+ used_chars = 0
673
+ fact_lines: list[str] = []
674
+
675
+ if degraded_mode:
676
+ header = "[GRAPH_DEGRADED_FALLBACK_MODE: Serving Verified Seed Summaries]\n"
677
+ else:
678
+ header = "[ACTIVE_ONTOLOGY_GRAPH_FACTS]\n"
679
+ used_chars += len(header)
680
+
681
+ sorted_facts = sorted(
682
+ facts, key=TokenBudgetSerializer.compute_salience, reverse=True
683
+ )
684
+ for f in sorted_facts:
685
+ reinf_tag = (
686
+ f" [verified={f.reinforcement_count}x]"
687
+ if f.reinforcement_count > 1
688
+ else ""
689
+ )
690
+ line = (
691
+ f"- [{f.namespace}] ({f.source_name}) -[{f.predicate}]-> ({f.target_name}): "
692
+ f"{f.fact_summary} [valid_from={f.t_valid}]{reinf_tag}\n"
693
+ )
694
+ if used_chars + len(line) > int(max_chars * 0.65):
695
+ break
696
+ fact_lines.append(line)
697
+ used_chars += len(line)
698
+
699
+ graph_block = (header + "".join(fact_lines)) if fact_lines else ""
700
+
701
+ kept_episodes: list[EpisodicRecord] = []
702
+ for ep in episodes:
703
+ if used_chars + len(ep.content_text) > max_chars:
704
+ remaining = max_chars - used_chars
705
+ if remaining > 120:
706
+ ep_copy = ep.model_copy()
707
+ ep_copy.content_text = ep.content_text[:remaining] + "...[truncated]"
708
+ kept_episodes.append(ep_copy)
709
+ break
710
+ kept_episodes.append(ep)
711
+ used_chars += len(ep.content_text)
712
+
713
+ return graph_block, kept_episodes