contextos-memory-runtime 1.0.0rc2__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.
Files changed (93) hide show
  1. contextos/__init__.py +3 -0
  2. contextos/__main__.py +6 -0
  3. contextos/api/__init__.py +1 -0
  4. contextos/api/routes/__init__.py +1 -0
  5. contextos/api/routes/desktop.py +322 -0
  6. contextos/api/routes/ingest.py +17 -0
  7. contextos/api/routes/memories.py +84 -0
  8. contextos/api/routes/models.py +81 -0
  9. contextos/api/routes/retrieval.py +89 -0
  10. contextos/api/routes/system.py +216 -0
  11. contextos/api/server.py +195 -0
  12. contextos/benchmarks/__init__.py +1 -0
  13. contextos/benchmarks/compilation.py +245 -0
  14. contextos/benchmarks/connectors.py +423 -0
  15. contextos/benchmarks/explainability.py +103 -0
  16. contextos/benchmarks/final.py +406 -0
  17. contextos/benchmarks/graph.py +310 -0
  18. contextos/benchmarks/graph_adversarial.py +525 -0
  19. contextos/benchmarks/mcp.py +324 -0
  20. contextos/benchmarks/model_routing.py +203 -0
  21. contextos/benchmarks/optimization.py +305 -0
  22. contextos/benchmarks/rescue_integration.py +127 -0
  23. contextos/benchmarks/retrieval.py +266 -0
  24. contextos/benchmarks/temporal.py +377 -0
  25. contextos/benchmarks/temporal_hotpath.py +76 -0
  26. contextos/benchmarks/terminal.py +62 -0
  27. contextos/cli/__init__.py +1 -0
  28. contextos/cli/app.py +932 -0
  29. contextos/cli/dashboard.py +174 -0
  30. contextos/cli/formatters.py +299 -0
  31. contextos/config/__init__.py +1 -0
  32. contextos/config/settings.py +160 -0
  33. contextos/connectors/__init__.py +6 -0
  34. contextos/connectors/fake.py +11 -0
  35. contextos/connectors/json_import.py +125 -0
  36. contextos/connectors/local_files.py +102 -0
  37. contextos/connectors/manager.py +293 -0
  38. contextos/connectors/models.py +62 -0
  39. contextos/connectors/protocols.py +11 -0
  40. contextos/core/__init__.py +103 -0
  41. contextos/core/enums.py +489 -0
  42. contextos/core/exceptions.py +293 -0
  43. contextos/core/models.py +1147 -0
  44. contextos/core/protocols.py +549 -0
  45. contextos/daemon/__init__.py +1 -0
  46. contextos/daemon/manager.py +510 -0
  47. contextos/daemon/state.py +127 -0
  48. contextos/daemon/wiring.py +296 -0
  49. contextos/demo.py +217 -0
  50. contextos/embedding/__init__.py +1 -0
  51. contextos/embedding/deterministic.py +76 -0
  52. contextos/embedding/sentence_transformers.py +80 -0
  53. contextos/mcp/__init__.py +5 -0
  54. contextos/mcp/server.py +269 -0
  55. contextos/providers/__init__.py +13 -0
  56. contextos/providers/fake.py +217 -0
  57. contextos/providers/ollama.py +297 -0
  58. contextos/providers/openai_compatible.py +337 -0
  59. contextos/services/__init__.py +1 -0
  60. contextos/services/compilation.py +535 -0
  61. contextos/services/explainability.py +553 -0
  62. contextos/services/extraction.py +311 -0
  63. contextos/services/graph.py +524 -0
  64. contextos/services/graph_retrieval.py +143 -0
  65. contextos/services/ingestion.py +143 -0
  66. contextos/services/inspection.py +174 -0
  67. contextos/services/memory.py +291 -0
  68. contextos/services/model_service.py +409 -0
  69. contextos/services/optimization.py +426 -0
  70. contextos/services/privacy.py +331 -0
  71. contextos/services/retrieval.py +302 -0
  72. contextos/services/retrieval_index.py +88 -0
  73. contextos/services/router.py +302 -0
  74. contextos/services/secret_scanner.py +207 -0
  75. contextos/services/telemetry_query.py +102 -0
  76. contextos/services/temporal.py +500 -0
  77. contextos/services/token_counter.py +222 -0
  78. contextos/storage/__init__.py +1 -0
  79. contextos/storage/connector_repo.py +67 -0
  80. contextos/storage/database.py +497 -0
  81. contextos/storage/event_repo.py +137 -0
  82. contextos/storage/graph_repo.py +228 -0
  83. contextos/storage/lexical/__init__.py +1 -0
  84. contextos/storage/lexical/bm25.py +134 -0
  85. contextos/storage/memory_repo.py +589 -0
  86. contextos/storage/relation_repo.py +80 -0
  87. contextos/storage/telemetry_repo.py +481 -0
  88. contextos/storage/vector/__init__.py +1 -0
  89. contextos/storage/vector/in_memory.py +162 -0
  90. contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
  91. contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
  92. contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
  93. contextos_memory_runtime-1.0.0rc2.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,331 @@
1
+ """Two-stage local privacy gate for raw input and extracted candidates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import re
9
+ import secrets
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from contextos.core.enums import (
14
+ PrivacyClassification,
15
+ PrivacyDecision,
16
+ PrivacySeverity,
17
+ SecretDetectionMode,
18
+ SecretType,
19
+ SourceRole,
20
+ SourceTrust,
21
+ )
22
+ from contextos.core.models import (
23
+ CandidateMemory,
24
+ PrivacyAssessment,
25
+ PrivacyFinding,
26
+ SecretMatch,
27
+ )
28
+ from contextos.core.protocols import SecretScanner
29
+
30
+
31
+ _CRITICAL_TYPES = {
32
+ SecretType.PRIVATE_KEY,
33
+ SecretType.SSH_PRIVATE_KEY,
34
+ SecretType.PASSWORD,
35
+ SecretType.CONNECTION_STRING,
36
+ SecretType.AUTHORIZATION_HEADER,
37
+ SecretType.SESSION_COOKIE,
38
+ SecretType.ACCESS_TOKEN,
39
+ }
40
+ _CREDENTIAL_LANGUAGE = re.compile(
41
+ r"\b(?:api key|password|passwd|otp|pin|token|authorization|cookie|session|"
42
+ r"private key|secret key|connection string)\b",
43
+ re.I,
44
+ )
45
+ _UNTRUSTED_INSTRUCTION = re.compile(
46
+ r"\b(?:ignore (?:all |the )?(?:previous|prior) instructions|system instruction|"
47
+ r"developer instruction|save .{0,40}(?:password|token|secret).{0,20}(?:memory|forever)|"
48
+ r"permanently remember)\b",
49
+ re.I,
50
+ )
51
+ _FINGERPRINT_KEY = secrets.token_bytes(32)
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class GatedInput:
56
+ assessment: PrivacyAssessment
57
+ content: str
58
+ source_type: str
59
+ source_uri: str | None
60
+ tags: list[str]
61
+
62
+
63
+ def _hash(value: str) -> str:
64
+ return hmac.new(_FINGERPRINT_KEY, value.encode("utf-8"), hashlib.sha256).hexdigest()
65
+
66
+
67
+ def classify_source(source_type: str, source_role: SourceRole) -> SourceTrust:
68
+ """Classify source trust as metadata, never as proof that content is true."""
69
+ normalized = source_type.casefold().replace("-", "_")
70
+ if source_role in {SourceRole.ASSISTANT, SourceRole.SYSTEM}:
71
+ return SourceTrust.MODEL_OUTPUT
72
+ if any(marker in normalized for marker in ("web", "browser", "url")):
73
+ return SourceTrust.EXTERNAL_WEBPAGE
74
+ if any(marker in normalized for marker in ("document", "file", "import")):
75
+ return SourceTrust.IMPORTED_DOCUMENT
76
+ if "tool" in normalized:
77
+ return SourceTrust.TOOL_OUTPUT
78
+ if "connector" in normalized:
79
+ return SourceTrust.LOCAL_TRUSTED_CONNECTOR
80
+ return SourceTrust.DIRECT_USER
81
+
82
+
83
+ class PrivacyGate:
84
+ """Deterministic pre-ingest and post-extraction security policy."""
85
+
86
+ def __init__(self, scanner: SecretScanner) -> None:
87
+ self._scanner = scanner
88
+
89
+ def gate_input(
90
+ self,
91
+ content: str,
92
+ *,
93
+ source_type: str,
94
+ source_uri: str | None,
95
+ tags: list[str],
96
+ source_role: SourceRole,
97
+ mode: SecretDetectionMode,
98
+ ) -> GatedInput:
99
+ safe_content, findings = self._scan_text(content, "content")
100
+ safe_source_type, source_type_findings = self._scan_text(
101
+ source_type, "source_type"
102
+ )
103
+ findings.extend(source_type_findings)
104
+ safe_source_uri: str | None = None
105
+ if source_uri is not None:
106
+ safe_source_uri, source_uri_findings = self._scan_text(
107
+ source_uri, "source_uri"
108
+ )
109
+ findings.extend(source_uri_findings)
110
+ safe_tags: list[str] = []
111
+ for index, tag in enumerate(tags):
112
+ safe_tag, tag_findings = self._scan_text(tag, f"tags[{index}]")
113
+ safe_tags.append(safe_tag)
114
+ findings.extend(tag_findings)
115
+
116
+ trust = classify_source(safe_source_type, source_role)
117
+ untrusted_instruction = (
118
+ trust not in {SourceTrust.DIRECT_USER, SourceTrust.LOCAL_TRUSTED_CONNECTOR}
119
+ and bool(_UNTRUSTED_INSTRUCTION.search(safe_content))
120
+ )
121
+ decision, classification = self._policy(findings, mode)
122
+ raw_envelope = json.dumps(
123
+ [content, source_type, source_uri, tags], ensure_ascii=False, separators=(",", ":")
124
+ )
125
+ assessment = PrivacyAssessment(
126
+ decision=decision,
127
+ classification=classification,
128
+ source_trust=trust,
129
+ findings=findings,
130
+ sanitized_text=safe_content,
131
+ scanned_length=sum(
132
+ len(value) for value in [content, source_type, source_uri or "", *tags]
133
+ ),
134
+ input_hash=_hash(raw_envelope),
135
+ untrusted_instruction_detected=untrusted_instruction,
136
+ )
137
+ return GatedInput(
138
+ assessment=assessment,
139
+ content=safe_content,
140
+ source_type=safe_source_type,
141
+ source_uri=safe_source_uri,
142
+ tags=safe_tags,
143
+ )
144
+
145
+ def assess_input(
146
+ self,
147
+ text: str,
148
+ *,
149
+ source_type: str,
150
+ source_role: SourceRole,
151
+ mode: SecretDetectionMode,
152
+ ) -> PrivacyAssessment:
153
+ """Compatibility helper for callers assessing only a content string."""
154
+ return self.gate_input(
155
+ text,
156
+ source_type=source_type,
157
+ source_uri=None,
158
+ tags=[],
159
+ source_role=source_role,
160
+ mode=mode,
161
+ ).assessment
162
+
163
+ def assess_candidate(
164
+ self,
165
+ candidate: CandidateMemory,
166
+ *,
167
+ source_trust: SourceTrust,
168
+ ) -> tuple[CandidateMemory | None, PrivacyAssessment]:
169
+ content, findings = self._scan_text(candidate.content, "candidate.content")
170
+ evidence, evidence_findings = self._scan_text(
171
+ candidate.evidence, "candidate.evidence"
172
+ )
173
+ findings.extend(evidence_findings)
174
+ source_type, source_type_findings = self._scan_text(
175
+ candidate.source_type, "candidate.source_type"
176
+ )
177
+ findings.extend(source_type_findings)
178
+ source_uri = None
179
+ if candidate.source_uri is not None:
180
+ source_uri, source_uri_findings = self._scan_text(
181
+ candidate.source_uri, "candidate.source_uri"
182
+ )
183
+ findings.extend(source_uri_findings)
184
+ tags, tag_findings = self._sanitize_structure(candidate.tags, "candidate.tags")
185
+ metadata, metadata_findings = self._sanitize_structure(
186
+ candidate.metadata, "candidate.metadata"
187
+ )
188
+ findings.extend(tag_findings)
189
+ findings.extend(metadata_findings)
190
+
191
+ contains_redaction = "[REDACTED:" in content or "[REDACTED:" in evidence
192
+ credential_only = contains_redaction and bool(_CREDENTIAL_LANGUAGE.search(content))
193
+ combined = f"{content}\n{evidence}"
194
+ untrusted_instruction = (
195
+ source_trust not in {
196
+ SourceTrust.DIRECT_USER,
197
+ SourceTrust.LOCAL_TRUSTED_CONNECTOR,
198
+ }
199
+ and bool(_UNTRUSTED_INSTRUCTION.search(combined))
200
+ )
201
+ blocked = credential_only or untrusted_instruction
202
+ assessment = PrivacyAssessment(
203
+ decision=PrivacyDecision.REJECT if blocked else (
204
+ PrivacyDecision.REDACT if findings else PrivacyDecision.ALLOW
205
+ ),
206
+ classification=PrivacyClassification.BLOCKED if blocked else (
207
+ PrivacyClassification.SECRET if findings else PrivacyClassification.SAFE
208
+ ),
209
+ source_trust=source_trust,
210
+ findings=findings,
211
+ sanitized_text=combined,
212
+ scanned_length=len(combined),
213
+ input_hash=_hash(json.dumps(
214
+ candidate.model_dump(mode="json", exclude={"privacy_assessment"}),
215
+ sort_keys=True,
216
+ separators=(",", ":"),
217
+ )),
218
+ untrusted_instruction_detected=untrusted_instruction,
219
+ )
220
+ if blocked:
221
+ return None, assessment
222
+ safe_candidate = candidate.model_copy(update={
223
+ "content": content,
224
+ "evidence": evidence,
225
+ "source_type": source_type,
226
+ "source_uri": source_uri,
227
+ "tags": tags,
228
+ "metadata": metadata,
229
+ "privacy_assessment": assessment,
230
+ })
231
+ return safe_candidate, assessment
232
+
233
+ def gate_candidates(
234
+ self,
235
+ candidates: list[CandidateMemory],
236
+ *,
237
+ source_trust: SourceTrust,
238
+ ) -> tuple[list[CandidateMemory], list[PrivacyAssessment]]:
239
+ safe: list[CandidateMemory] = []
240
+ blocked: list[PrivacyAssessment] = []
241
+ for candidate in candidates:
242
+ gated, assessment = self.assess_candidate(candidate, source_trust=source_trust)
243
+ if gated is None:
244
+ blocked.append(assessment)
245
+ else:
246
+ safe.append(gated)
247
+ return safe, blocked
248
+
249
+ def _scan_text(self, text: str, location: str) -> tuple[str, list[PrivacyFinding]]:
250
+ scan = self._scanner.scan(text)
251
+ findings = self._safe_findings(text, scan.matches, location)
252
+ return self._redact_from_findings(text, findings), findings
253
+
254
+ def _sanitize_structure(
255
+ self, value: Any, location: str
256
+ ) -> tuple[Any, list[PrivacyFinding]]:
257
+ if isinstance(value, str):
258
+ return self._scan_text(value, location)
259
+ if isinstance(value, list):
260
+ safe_list: list[Any] = []
261
+ findings: list[PrivacyFinding] = []
262
+ for index, item in enumerate(value):
263
+ safe_item, item_findings = self._sanitize_structure(
264
+ item, f"{location}[{index}]"
265
+ )
266
+ safe_list.append(safe_item)
267
+ findings.extend(item_findings)
268
+ return safe_list, findings
269
+ if isinstance(value, dict):
270
+ safe_dict: dict[str, Any] = {}
271
+ findings = []
272
+ for key, item in value.items():
273
+ safe_key, key_findings = self._scan_text(str(key), f"{location}.key")
274
+ safe_item, item_findings = self._sanitize_structure(
275
+ item, f"{location}.{safe_key}"
276
+ )
277
+ safe_dict[safe_key] = safe_item
278
+ findings.extend(key_findings)
279
+ findings.extend(item_findings)
280
+ return safe_dict, findings
281
+ return value, []
282
+
283
+ @staticmethod
284
+ def _policy(
285
+ findings: list[PrivacyFinding], mode: SecretDetectionMode
286
+ ) -> tuple[PrivacyDecision, PrivacyClassification]:
287
+ if not findings:
288
+ return PrivacyDecision.ALLOW, PrivacyClassification.SAFE
289
+ if mode == SecretDetectionMode.STRICT:
290
+ return PrivacyDecision.REJECT, PrivacyClassification.BLOCKED
291
+ if mode == SecretDetectionMode.WARN:
292
+ return PrivacyDecision.QUARANTINE, PrivacyClassification.BLOCKED
293
+ return PrivacyDecision.REDACT, PrivacyClassification.SECRET
294
+
295
+ @staticmethod
296
+ def _safe_findings(
297
+ text: str, matches: list[SecretMatch], location: str
298
+ ) -> list[PrivacyFinding]:
299
+ findings: list[PrivacyFinding] = []
300
+ for match in matches:
301
+ value = text[match.start:match.end]
302
+ severity = (
303
+ PrivacySeverity.CRITICAL
304
+ if match.secret_type in _CRITICAL_TYPES
305
+ else PrivacySeverity.HIGH
306
+ if match.secret_type != SecretType.HIGH_ENTROPY
307
+ else PrivacySeverity.MEDIUM
308
+ )
309
+ findings.append(PrivacyFinding(
310
+ category=match.secret_type,
311
+ severity=severity,
312
+ start=match.start,
313
+ end=match.end,
314
+ detector="pattern-secret-scanner",
315
+ location=location,
316
+ confidence=match.confidence,
317
+ safe_preview=f"[REDACTED:{match.secret_type.value}]",
318
+ fingerprint=_hash(value),
319
+ ))
320
+ return findings
321
+
322
+ @staticmethod
323
+ def _redact_from_findings(text: str, findings: list[PrivacyFinding]) -> str:
324
+ redacted = text
325
+ for finding in sorted(findings, key=lambda item: item.start, reverse=True):
326
+ redacted = (
327
+ redacted[:finding.start]
328
+ + finding.safe_preview
329
+ + redacted[finding.end:]
330
+ )
331
+ return redacted
@@ -0,0 +1,302 @@
1
+ """Side-effect-free lexical, dense, and hybrid memory retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Sequence
7
+ from uuid import UUID
8
+
9
+ from contextos.core.enums import (
10
+ CandidateTemporalStatus,
11
+ MemoryStatus,
12
+ RetrievalMode,
13
+ TemporalScope,
14
+ )
15
+ from contextos.core.models import (
16
+ LexicalResult,
17
+ Memory,
18
+ RetrievalConfig,
19
+ RetrievalQuery,
20
+ RetrievalResult,
21
+ RetrievalTrace,
22
+ ScoredMemory,
23
+ StageTrace,
24
+ VectorResult,
25
+ )
26
+ from contextos.core.protocols import EmbeddingService, LexicalIndex, MemoryRepository, VectorStore
27
+ from contextos.services.retrieval_index import RetrievalIndexSynchronizer
28
+
29
+
30
+ class HybridRetrievalEngine:
31
+ """Retrieve with BM25, cosine similarity, or Reciprocal Rank Fusion.
32
+
33
+ RRF combines ranks rather than adding incomparable BM25 and cosine scores.
34
+ A bounded metadata factor can increase the relevance score by at most 5%.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ memory_repo: MemoryRepository,
41
+ vector_store: VectorStore,
42
+ lexical_index: LexicalIndex,
43
+ embedding_service: EmbeddingService,
44
+ index_synchronizer: RetrievalIndexSynchronizer | None = None,
45
+ ) -> None:
46
+ self._memory_repo = memory_repo
47
+ self._vector_store = vector_store
48
+ self._lexical_index = lexical_index
49
+ self._embedding_service = embedding_service
50
+ self._index_synchronizer = index_synchronizer
51
+
52
+ async def retrieve(
53
+ self,
54
+ query: str | RetrievalQuery,
55
+ config: RetrievalConfig | None = None,
56
+ ) -> RetrievalResult:
57
+ request = self._coerce_query(query, config)
58
+ started = time.perf_counter()
59
+ stages: list[StageTrace] = []
60
+
61
+ if self._index_synchronizer is not None:
62
+ sync_started = time.perf_counter()
63
+ rebuilt = await self._index_synchronizer.ensure_current()
64
+ stages.append(self._stage(
65
+ "index_sync", 0, 0, sync_started, {"rebuilt": rebuilt}
66
+ ))
67
+
68
+ corpus_size = max(
69
+ await self._lexical_index.count(),
70
+ await self._vector_store.count(),
71
+ )
72
+ candidate_limit = max(corpus_size, request.k)
73
+ lexical: list[LexicalResult] = []
74
+ dense: list[VectorResult] = []
75
+
76
+ if request.mode in {
77
+ RetrievalMode.LEXICAL,
78
+ RetrievalMode.HYBRID,
79
+ RetrievalMode.GRAPH,
80
+ RetrievalMode.HYBRID_GRAPH,
81
+ }:
82
+ stage_started = time.perf_counter()
83
+ lexical = await self._lexical_index.search(request.text, candidate_limit)
84
+ stages.append(self._stage(
85
+ "lexical_search", 1, len(lexical), stage_started, {"top_k": candidate_limit}
86
+ ))
87
+
88
+ if request.mode in {
89
+ RetrievalMode.DENSE,
90
+ RetrievalMode.HYBRID,
91
+ RetrievalMode.GRAPH,
92
+ RetrievalMode.HYBRID_GRAPH,
93
+ }:
94
+ stage_started = time.perf_counter()
95
+ vector = await self._embedding_service.embed_query(request.text)
96
+ dense = await self._vector_store.search(vector, candidate_limit)
97
+ stages.append(self._stage(
98
+ "dense_search", 1, len(dense), stage_started, {"top_k": candidate_limit}
99
+ ))
100
+
101
+ resolution_started = time.perf_counter()
102
+ ids = {item.id for item in lexical} | {item.id for item in dense}
103
+ memories = await self._eligible_memories(ids, request)
104
+ lexical = [item for item in lexical if item.id in memories]
105
+ dense = [item for item in dense if item.id in memories]
106
+ stages.append(self._stage(
107
+ "eligibility_filter",
108
+ len(ids),
109
+ len(memories),
110
+ resolution_started,
111
+ {"temporal_scope": request.temporal_scope.value},
112
+ ))
113
+
114
+ rank_started = time.perf_counter()
115
+ scored = self._rank(request, memories, lexical, dense, rrf_k=(config.rrf_k if config else 60))
116
+ minimum = config.min_score if config else 0.0
117
+ pre_limit_scored = [item for item in scored if item.final_score >= minimum]
118
+ pre_limit_ids = [str(item.memory.id) for item in pre_limit_scored]
119
+ scored = pre_limit_scored[:request.k]
120
+ for rank, item in enumerate(scored, 1):
121
+ item.rank = rank
122
+ stages.append(self._stage(
123
+ "fusion_rerank",
124
+ len(set(item.id for item in lexical) | set(item.id for item in dense)),
125
+ len(scored),
126
+ rank_started,
127
+ {"mode": request.mode.value, "method": "rrf", "metadata_cap": 0.05},
128
+ ))
129
+
130
+ total_latency = (time.perf_counter() - started) * 1000
131
+ trace = RetrievalTrace(
132
+ stages=stages if request.include_trace else [],
133
+ total_latency_ms=total_latency,
134
+ total_candidates=len(ids),
135
+ total_results=len(scored),
136
+ lexical_candidate_ids=[str(item.id) for item in lexical[:100]],
137
+ dense_candidate_ids=[str(item.id) for item in dense[:100]],
138
+ pre_limit_candidate_ids=pre_limit_ids[:200],
139
+ channel_candidates_truncated=len(lexical) > 100 or len(dense) > 100,
140
+ pre_limit_candidates_truncated=len(pre_limit_ids) > 200,
141
+ )
142
+ strategy_results = {
143
+ name: [item for item in scored if name in item.retrieval_sources]
144
+ for name in ("lexical", "dense")
145
+ if any(name in item.retrieval_sources for item in scored)
146
+ }
147
+ return RetrievalResult(
148
+ query=request.text,
149
+ memories=scored,
150
+ strategy_results=strategy_results,
151
+ trace=trace,
152
+ )
153
+
154
+ @staticmethod
155
+ def _coerce_query(
156
+ query: str | RetrievalQuery, config: RetrievalConfig | None
157
+ ) -> RetrievalQuery:
158
+ if isinstance(query, RetrievalQuery):
159
+ return query
160
+ if config is None:
161
+ return RetrievalQuery(text=query)
162
+ statuses = {MemoryStatus.ACTIVE}
163
+ if config.include_superseded:
164
+ statuses.add(MemoryStatus.SUPERSEDED)
165
+ if config.include_contradicted:
166
+ statuses.add(MemoryStatus.CONTRADICTED)
167
+ if config.include_expired:
168
+ statuses.add(MemoryStatus.EXPIRED)
169
+ return RetrievalQuery(text=query, k=config.max_results, allowed_statuses=statuses)
170
+
171
+ async def _eligible_memories(
172
+ self, ids: set[str], request: RetrievalQuery
173
+ ) -> dict[str, Memory]:
174
+ get_many = getattr(self._memory_repo, "get_many", None)
175
+ if callable(get_many):
176
+ found = await get_many(ids)
177
+ return {
178
+ raw_id: memory for raw_id, memory in found.items()
179
+ if self._eligible(memory, request)
180
+ }
181
+ eligible: dict[str, Memory] = {}
182
+ for raw_id in sorted(ids):
183
+ try:
184
+ memory = await self._memory_repo.get(UUID(raw_id))
185
+ except ValueError:
186
+ continue
187
+ if memory is not None and self._eligible(memory, request):
188
+ eligible[raw_id] = memory
189
+ return eligible
190
+
191
+ @staticmethod
192
+ def _eligible(memory: Memory, query: RetrievalQuery) -> bool:
193
+ if memory.status in {MemoryStatus.DELETED, MemoryStatus.PURGED, MemoryStatus.MERGED}:
194
+ return False
195
+ if query.allowed_statuses is not None:
196
+ if memory.status not in query.allowed_statuses:
197
+ return False
198
+ else:
199
+ statuses = {
200
+ TemporalScope.CURRENT: {MemoryStatus.ACTIVE},
201
+ TemporalScope.HISTORICAL: {
202
+ MemoryStatus.HISTORICAL,
203
+ MemoryStatus.SUPERSEDED,
204
+ },
205
+ TemporalScope.ALL: {
206
+ MemoryStatus.ACTIVE,
207
+ MemoryStatus.HISTORICAL,
208
+ MemoryStatus.SUPERSEDED,
209
+ MemoryStatus.CONTRADICTED,
210
+ MemoryStatus.EXPIRED,
211
+ },
212
+ }[query.temporal_scope]
213
+ if memory.status not in statuses:
214
+ return False
215
+ if query.allowed_statuses is None:
216
+ if (
217
+ query.temporal_scope == TemporalScope.CURRENT
218
+ and memory.temporal_status in {
219
+ CandidateTemporalStatus.FUTURE,
220
+ CandidateTemporalStatus.HISTORICAL,
221
+ }
222
+ ):
223
+ return False
224
+ if (
225
+ query.temporal_scope == TemporalScope.HISTORICAL
226
+ and memory.temporal_status == CandidateTemporalStatus.FUTURE
227
+ ):
228
+ return False
229
+ if query.allowed_memory_types and memory.type not in query.allowed_memory_types:
230
+ return False
231
+ if query.source_types and memory.source_type not in query.source_types:
232
+ return False
233
+ if query.tags and not query.tags.issubset(memory.tags):
234
+ return False
235
+ if query.created_after and memory.created_at < query.created_after:
236
+ return False
237
+ if query.created_before and memory.created_at > query.created_before:
238
+ return False
239
+ if query.min_confidence is not None and memory.confidence < query.min_confidence:
240
+ return False
241
+ return not (
242
+ query.min_importance is not None and memory.importance < query.min_importance
243
+ )
244
+
245
+ @staticmethod
246
+ def _rank(
247
+ query: RetrievalQuery,
248
+ memories: dict[str, Memory],
249
+ lexical: Sequence[LexicalResult],
250
+ dense: Sequence[VectorResult],
251
+ *,
252
+ rrf_k: int,
253
+ ) -> list[ScoredMemory]:
254
+ lexical_ranks = {item.id: rank for rank, item in enumerate(lexical, 1)}
255
+ dense_ranks = {item.id: rank for rank, item in enumerate(dense, 1)}
256
+ lexical_scores = {item.id: item.score for item in lexical}
257
+ dense_scores = {item.id: item.score for item in dense}
258
+ identifiers = set(lexical_ranks) | set(dense_ranks)
259
+ results: list[ScoredMemory] = []
260
+ for identifier in identifiers:
261
+ sources: list[str] = []
262
+ base_score = 0.0
263
+ if identifier in lexical_ranks:
264
+ sources.append("lexical")
265
+ base_score += 1.0 / (rrf_k + lexical_ranks[identifier])
266
+ if identifier in dense_ranks:
267
+ sources.append("dense")
268
+ base_score += 1.0 / (rrf_k + dense_ranks[identifier])
269
+ memory = memories[identifier]
270
+ adjustment = (
271
+ base_score * 0.05 * ((memory.confidence + memory.importance) / 2.0)
272
+ if query.apply_metadata_rerank
273
+ else 0.0
274
+ )
275
+ results.append(ScoredMemory(
276
+ memory=memory,
277
+ final_score=base_score + adjustment,
278
+ vector_score=dense_scores.get(identifier),
279
+ bm25_score=lexical_scores.get(identifier),
280
+ lexical_rank=lexical_ranks.get(identifier),
281
+ dense_rank=dense_ranks.get(identifier),
282
+ metadata_adjustment=adjustment,
283
+ retrieval_sources=sources,
284
+ ))
285
+ results.sort(key=lambda item: (-item.final_score, str(item.memory.id)))
286
+ return results
287
+
288
+ @staticmethod
289
+ def _stage(
290
+ name: str,
291
+ input_count: int,
292
+ output_count: int,
293
+ started: float,
294
+ metadata: dict[str, object] | None = None,
295
+ ) -> StageTrace:
296
+ return StageTrace(
297
+ stage_name=name,
298
+ input_count=input_count,
299
+ output_count=output_count,
300
+ latency_ms=(time.perf_counter() - started) * 1000,
301
+ metadata=metadata or {},
302
+ )