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,553 @@
1
+ """Deterministic, ephemeral explanations for one existing pipeline execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from typing import Any
8
+ from uuid import UUID, uuid4
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from contextos.core.enums import MemoryStatus, RetrievalMode, TemporalScope
13
+ from contextos.core.models import (
14
+ CompilationConfig,
15
+ ContextBudget,
16
+ Memory,
17
+ ProviderDispatchEvidence,
18
+ RetrievalQuery,
19
+ RetrievalResult,
20
+ )
21
+ from contextos.services.retrieval import HybridRetrievalEngine
22
+
23
+
24
+ class ExplanationRequest(BaseModel):
25
+ query: str = Field(min_length=1, max_length=10_000, repr=False)
26
+ mode: RetrievalMode = RetrievalMode.HYBRID
27
+ budget: int = Field(default=1000, ge=1, le=8_000)
28
+ limit: int = Field(default=25, ge=1, le=100)
29
+ graph: bool = True
30
+ include_content: bool = False
31
+ target_memory_id: UUID | None = None
32
+ temporal_scope: TemporalScope = TemporalScope.CURRENT
33
+
34
+
35
+ class ExplanationTrace(BaseModel):
36
+ trace_id: str
37
+ query_id: str
38
+ strategy: str
39
+ stages: list[dict]
40
+ candidates: list[dict]
41
+ selected: list[str]
42
+ excluded: list[dict]
43
+ final_context: dict
44
+ content: str | None = None
45
+ evidence_scope: str = "retrieved candidates only"
46
+ requested_memory: dict | None = None
47
+ provider_dispatch: dict[str, Any] = Field(
48
+ default_factory=lambda: {
49
+ "state": "NOT_ATTEMPTED",
50
+ "status_message": "prepared by ContextOS; no provider dispatch attempted",
51
+ }
52
+ )
53
+ execution_ms: float = 0.0
54
+ measured_pipeline_ms: float = 0.0
55
+ explanation_overhead_ms: float = 0.0
56
+
57
+
58
+ _UNSAFE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x1b\x07]*(?:\x07|\x1b\\)|.)")
59
+ _BIDI = {chr(value) for value in (*range(0x202A, 0x202F), *range(0x2066, 0x206A))}
60
+ _SAFE_SOURCE_TYPES = {
61
+ "cli_input", "cli", "mcp", "api", "file", "local_file", "json_import",
62
+ "fake", "system", "benchmark", "manual",
63
+ }
64
+
65
+
66
+ def safe_text(value: object, limit: int = 160) -> str:
67
+ text = _UNSAFE.sub("", str(value or ""))
68
+ return "".join(char for char in text if char.isprintable() and char not in _BIDI)[:limit]
69
+
70
+
71
+ def safe_source_type(value: str) -> str:
72
+ if value in _SAFE_SOURCE_TYPES:
73
+ return value
74
+ if value.startswith("connector:") and value.removeprefix("connector:") in {"local_file", "json_import", "fake"}:
75
+ return value
76
+ return "unknown"
77
+
78
+
79
+ class TemporalEvidenceResolver:
80
+ """Resolve structured, non-inferred temporal evidence for memories and relations."""
81
+
82
+ def __init__(self, memory_repo: Any, relation_repo: Any = None) -> None:
83
+ self.memory_repo = memory_repo
84
+ self.relation_repo = relation_repo
85
+
86
+ async def resolve_memory_evidence(
87
+ self,
88
+ memory: Memory,
89
+ retrieval_request: RetrievalQuery | None = None,
90
+ ) -> dict[str, Any]:
91
+ eligible = (
92
+ HybridRetrievalEngine._eligible(memory, retrieval_request)
93
+ if retrieval_request is not None
94
+ else (memory.status == MemoryStatus.ACTIVE)
95
+ )
96
+ reason_code = self._reason_code(memory, eligible)
97
+
98
+ relations_evidence: list[dict[str, Any]] = []
99
+ if self.relation_repo is not None:
100
+ raw_relations = await self.relation_repo.get_relations(memory.id, direction="both")
101
+ for rel in raw_relations[:50]:
102
+ related_id = (
103
+ rel.target_memory_id
104
+ if rel.source_memory_id == memory.id
105
+ else rel.source_memory_id
106
+ )
107
+ related_mem = await self.memory_repo.get(related_id) if self.memory_repo else None
108
+ target_mem = await self.memory_repo.get(rel.target_memory_id) if self.memory_repo else None
109
+ related_state = (
110
+ "missing" if related_mem is None else
111
+ "deleted" if related_mem.status in {MemoryStatus.DELETED, MemoryStatus.PURGED} else
112
+ "present"
113
+ )
114
+ relations_evidence.append({
115
+ "relation_id": str(rel.id),
116
+ "relation_type": rel.relation_type.value,
117
+ "source_memory_id": str(rel.source_memory_id),
118
+ "target_memory_id": str(rel.target_memory_id),
119
+ "related_memory_id": str(related_id),
120
+ "confidence": rel.confidence,
121
+ "created_at": rel.created_at.isoformat(),
122
+ "related_memory_state": related_state,
123
+ "target_deleted": target_mem is None or target_mem.status in {
124
+ MemoryStatus.DELETED, MemoryStatus.PURGED,
125
+ },
126
+ "acceptance_rationale": None,
127
+ })
128
+
129
+ return {
130
+ "status": memory.temporal_status.value,
131
+ "lifecycle": memory.status.value,
132
+ "temporal_status": memory.temporal_status.value,
133
+ "eligible": eligible,
134
+ "reason_code": reason_code,
135
+ "reason": "eligible_by_retrieval_policy" if eligible else "temporal_policy_ineligible",
136
+ "replacement_memory_id": str(memory.superseded_by) if memory.superseded_by else None,
137
+ "observed_at": memory.observed_at.isoformat() if memory.observed_at else None,
138
+ "valid_from": memory.valid_from.isoformat() if memory.valid_from else None,
139
+ "valid_to": memory.valid_to.isoformat() if memory.valid_to else None,
140
+ "relations": relations_evidence,
141
+ "acceptance_rationale": None,
142
+ }
143
+
144
+ @staticmethod
145
+ def _reason_code(memory: Memory, eligible: bool) -> str:
146
+ if memory.status == MemoryStatus.SUPERSEDED:
147
+ return "REPLACED_BY_CURRENT_STATE"
148
+ if memory.status == MemoryStatus.HISTORICAL:
149
+ return "HISTORICAL_RECORD"
150
+ if memory.status == MemoryStatus.CONTRADICTED:
151
+ return "CONTRADICTED_STATE"
152
+ if memory.status == MemoryStatus.EXPIRED:
153
+ return "LIFECYCLE_EXPIRED"
154
+ if memory.status in {MemoryStatus.DELETED, MemoryStatus.PURGED}:
155
+ return "LIFECYCLE_DELETED"
156
+ if memory.status == MemoryStatus.ACTIVE:
157
+ return "CURRENT_STATE" if eligible else "ACTIVE_INELIGIBLE_FOR_QUERY"
158
+ return "UNKNOWN_STATE"
159
+
160
+
161
+ class ExplainabilityService:
162
+ """Collect explanations from the same retrieval/optimization/compile results."""
163
+
164
+ def __init__(self, services: dict) -> None:
165
+ self.services = services
166
+ self.token_counter = services.get("token_counter") or getattr(
167
+ services.get("optimizer"), "_token_counter", None
168
+ )
169
+ self.temporal_resolver = TemporalEvidenceResolver(
170
+ services.get("memory_repo"), services.get("relation_repo")
171
+ )
172
+
173
+ async def explain(self, request: ExplanationRequest) -> ExplanationTrace:
174
+ trace, _, _, _ = await self.execute(request)
175
+ return trace
176
+
177
+ async def execute(self, request: ExplanationRequest) -> tuple[ExplanationTrace, RetrievalResult, Any, Any]:
178
+ """Return the trace and its original pipeline objects for bounded inspection."""
179
+ started = time.perf_counter()
180
+ mode = request.mode
181
+ if not request.graph and mode in {RetrievalMode.GRAPH, RetrievalMode.HYBRID_GRAPH}:
182
+ mode = RetrievalMode.HYBRID
183
+ if request.graph and mode == RetrievalMode.HYBRID:
184
+ mode = RetrievalMode.HYBRID_GRAPH
185
+ retrieval_request = RetrievalQuery(
186
+ text=request.query,
187
+ mode=mode,
188
+ k=request.limit,
189
+ include_trace=True,
190
+ temporal_scope=request.temporal_scope,
191
+ )
192
+ retrieved = await self.services["retrieval"].retrieve(retrieval_request)
193
+ optimizer_started = time.perf_counter()
194
+ selection = self.services["optimizer"].optimize(
195
+ request.query, retrieved.memories, ContextBudget(max_tokens=request.budget)
196
+ )
197
+ optimizer_ms = (time.perf_counter() - optimizer_started) * 1000
198
+ compilation_started = time.perf_counter()
199
+ compiled = await self.services["compilation"].compile(
200
+ request.query, selection, CompilationConfig(budget=request.budget)
201
+ )
202
+ compilation_ms = (time.perf_counter() - compilation_started) * 1000
203
+ pipeline_ms = retrieved.trace.total_latency_ms + optimizer_ms + compilation_ms
204
+
205
+ trace = await self.build_trace(
206
+ request=request,
207
+ retrieval_request=retrieval_request,
208
+ retrieved=retrieved,
209
+ selection=selection,
210
+ compiled=compiled,
211
+ dispatch_evidence=None,
212
+ started_at=started,
213
+ pipeline_ms=pipeline_ms,
214
+ )
215
+ return trace, retrieved, selection, compiled
216
+
217
+ async def build_trace(
218
+ self,
219
+ request: ExplanationRequest,
220
+ retrieved: RetrievalResult,
221
+ selection: Any,
222
+ compiled: Any,
223
+ retrieval_request: RetrievalQuery | None = None,
224
+ dispatch_evidence: ProviderDispatchEvidence | None = None,
225
+ started_at: float | None = None,
226
+ pipeline_ms: float | None = None,
227
+ ) -> ExplanationTrace:
228
+ trace_id = str(uuid4())
229
+ if retrieval_request is None:
230
+ retrieval_request = RetrievalQuery(
231
+ text=request.query,
232
+ mode=request.mode,
233
+ k=request.limit,
234
+ include_trace=True,
235
+ temporal_scope=request.temporal_scope,
236
+ )
237
+
238
+ decisions = {item.memory_id: item for item in selection.trace.decisions}
239
+ source_content = {str(item.memory.id): item.memory.content for item in retrieved.memories}
240
+ compiled_memory_ids = {str(value) for value in compiled.included_memory_ids}
241
+ facts_by_memory: dict[str, list[dict]] = {}
242
+ for fact in compiled.facts[:200]:
243
+ for memory_id in fact.source_memory_ids:
244
+ facts_by_memory.setdefault(str(memory_id), []).append({
245
+ "fact_id": fact.fact_id,
246
+ "action": (
247
+ "MERGED" if len(fact.source_memory_ids) > 1 else
248
+ "RESCUED_FROM_OVERSIZED_MEMORY" if fact.input_kind.value == "oversized_rescue" else
249
+ "RAW_INCLUDED" if fact.text == source_content.get(str(memory_id)) else "UNKNOWN"
250
+ ),
251
+ "token_cost": fact.token_cost,
252
+ "source_memory_ids": [str(value) for value in fact.source_memory_ids[:20]],
253
+ "provenance_event_ids": [str(value) for value in fact.provenance_event_ids[:20]],
254
+ "provenance_preserved": bool(fact.source_memory_ids),
255
+ })
256
+ for fact in compiled.excluded_facts[:200]:
257
+ for memory_id in fact.source_memory_ids:
258
+ facts_by_memory.setdefault(str(memory_id), []).append({
259
+ "fact_id": fact.fact_id,
260
+ "action": "DEDUPLICATED" if fact.reason.value == "duplicate" else "DROPPED",
261
+ "reason": fact.reason.value,
262
+ "token_cost": fact.token_cost,
263
+ "source_memory_ids": [str(value) for value in fact.source_memory_ids[:20]],
264
+ "provenance_event_ids": [],
265
+ "provenance_preserved": bool(fact.source_memory_ids),
266
+ })
267
+
268
+ candidate_rows: list[dict] = []
269
+ excluded: list[dict] = []
270
+ mode = retrieval_request.mode
271
+
272
+ for item in retrieved.memories[:request.limit]:
273
+ memory = item.memory
274
+ key = str(memory.id)
275
+ decision = decisions.get(memory.id)
276
+ selected = bool(decision and decision.selected)
277
+
278
+ graph_paths_data = []
279
+ for path in item.graph_paths[:5]:
280
+ p_nodes = [
281
+ {
282
+ "node_id": str(n.node_id),
283
+ "node_type": n.node_type.value,
284
+ "label": safe_text(n.label, 120) if n.label else None,
285
+ "project_scope": safe_text(n.project_scope, 64) if n.project_scope else None,
286
+ }
287
+ for n in getattr(path, "path_nodes", [])[:4]
288
+ ]
289
+ p_edges = [
290
+ {
291
+ "edge_type": e.edge_type.value,
292
+ "confidence": e.confidence,
293
+ "supporting_memory_ids": [str(x) for x in e.supporting_memory_ids[:20]],
294
+ "project_scope": safe_text(e.project_scope, 64) if e.project_scope else None,
295
+ }
296
+ for e in getattr(path, "path_edges", [])[:3]
297
+ ]
298
+ graph_paths_data.append({
299
+ "seed_node_ids": [str(value) for value in path.seed_node_ids[:20]],
300
+ "node_ids": [str(value) for value in path.node_ids[:4]],
301
+ "node_types": [value.value for value in path.node_types[:4]],
302
+ "hop_count": path.hop_count,
303
+ "relation_types": [value.value for value in path.edge_types[:3]],
304
+ "supporting_memory_ids": [str(value) for value in path.source_memory_ids[:20]],
305
+ "score": path.graph_contribution,
306
+ "path_nodes": p_nodes,
307
+ "path_edges": p_edges,
308
+ "scope_match": getattr(path, "scope_match", None),
309
+ })
310
+
311
+ temporal_data = await self.temporal_resolver.resolve_memory_evidence(memory, retrieval_request)
312
+
313
+ row = {
314
+ "memory_id": key,
315
+ "rank": item.rank,
316
+ "status": memory.status.value,
317
+ "confidence": memory.confidence,
318
+ "importance": memory.importance,
319
+ "token_cost": decision.token_cost if decision else None,
320
+ "content_tokens": decision.content_tokens if decision else None,
321
+ "retrieval": {
322
+ "origin": (
323
+ "graph_expanded"
324
+ if "graph" in item.retrieval_sources and not ("lexical" in item.retrieval_sources or "dense" in item.retrieval_sources)
325
+ else "direct"
326
+ ),
327
+ "mode": mode.value,
328
+ "lexical_rank": item.lexical_rank,
329
+ "lexical_bm25_score": item.bm25_score,
330
+ "dense_rank": item.dense_rank,
331
+ "dense_score": item.vector_score,
332
+ "lexical_rrf_contribution": (
333
+ ((1.0 / (60 + item.lexical_rank)) if item.lexical_rank else 0.0)
334
+ if mode not in {RetrievalMode.GRAPH, RetrievalMode.HYBRID_GRAPH} else None
335
+ ),
336
+ "dense_rrf_contribution": (
337
+ ((1.0 / (60 + item.dense_rank)) if item.dense_rank else 0.0)
338
+ if mode not in {RetrievalMode.GRAPH, RetrievalMode.HYBRID_GRAPH} else None
339
+ ),
340
+ "base_rank": item.rrf_rank if mode in {RetrievalMode.GRAPH, RetrievalMode.HYBRID_GRAPH} else None,
341
+ "base_rrf_contribution": (1.0 / (60 + item.rrf_rank)) if item.rrf_rank else None,
342
+ "graph_rrf_contribution": (1.0 / (60 + item.graph_rank)) if item.graph_rank else 0.0,
343
+ "graph_score": item.graph_score,
344
+ "graph_rank": item.graph_rank,
345
+ "fused_score": item.final_score,
346
+ "metadata_adjustment": item.metadata_adjustment,
347
+ "sources": list(item.retrieval_sources),
348
+ },
349
+ "graph": graph_paths_data,
350
+ "temporal": temporal_data,
351
+ "optimizer": {
352
+ "selected": decision.selected if decision else False,
353
+ "reason": (
354
+ decision.exclusion_reason.value
355
+ if decision and decision.exclusion_reason
356
+ else ("selected" if selected else "not_available")
357
+ ),
358
+ "utility": decision.marginal_utility if decision else None,
359
+ "redundancy": decision.redundancy if decision else None,
360
+ "redundant_with": str(decision.redundant_with) if decision and decision.redundant_with else None,
361
+ "budget": selection.trace.available_tokens,
362
+ "decision": decision.model_dump(mode="json") if decision else None,
363
+ },
364
+ "compiler": facts_by_memory.get(key, []),
365
+ "compiler_transformations": [fact["action"] for fact in facts_by_memory.get(key, [])],
366
+ "selected": selected,
367
+ "compiler_included": key in compiled_memory_ids,
368
+ "provenance": {
369
+ "source_type": safe_source_type(memory.source_type),
370
+ "event_id": str(memory.provenance_event_id) if memory.provenance_event_id else None,
371
+ },
372
+ }
373
+ if request.include_content:
374
+ row["content"] = safe_text(memory.content, 1000)
375
+ candidate_rows.append(row)
376
+ if not selected:
377
+ excluded.append({"memory_id": key, "reason": row["optimizer"]["reason"]})
378
+
379
+ selected_ids = [str(item.memory.id) for item in selection.selected_memories[:request.limit]]
380
+
381
+ requested_memory = None
382
+ if request.target_memory_id is not None:
383
+ requested_id = str(request.target_memory_id)
384
+ matched = next((row for row in candidate_rows if row["memory_id"] == requested_id), None)
385
+ if matched is not None:
386
+ if matched["selected"]:
387
+ if requested_id in compiled_memory_ids:
388
+ requested_memory = {
389
+ "memory_id": requested_id,
390
+ "status": "retrieved_and_selected",
391
+ "reason_code": "SELECTED",
392
+ "reason": "selected_and_compiled",
393
+ }
394
+ else:
395
+ requested_memory = {
396
+ "memory_id": requested_id,
397
+ "status": "optimizer_selected_not_compiled",
398
+ "reason_code": "COMPILER_EXCLUDED",
399
+ "reason": "not_in_compiled_context",
400
+ }
401
+ else:
402
+ requested_memory = {
403
+ "memory_id": requested_id,
404
+ "status": "retrieved_but_excluded",
405
+ "reason_code": "RETRIEVED_BUT_OPTIMIZER_EXCLUDED",
406
+ "reason": matched["optimizer"]["reason"],
407
+ }
408
+ elif requested_id in getattr(retrieved.trace, "pre_limit_candidate_ids", []):
409
+ requested_memory = {
410
+ "memory_id": requested_id,
411
+ "status": "retrieval_excluded",
412
+ "reason_code": "RESULT_LIMIT",
413
+ "reason": "result_limit_exceeded",
414
+ }
415
+ elif (
416
+ getattr(retrieved.trace, "pre_limit_candidates_truncated", False)
417
+ or getattr(retrieved.trace, "channel_candidates_truncated", False)
418
+ ):
419
+ requested_memory = {
420
+ "memory_id": requested_id,
421
+ "status": "not_available",
422
+ "reason_code": "NOT_AVAILABLE",
423
+ "reason": "candidate_evidence_truncated",
424
+ }
425
+ else:
426
+ repo = self.services.get("memory_repo")
427
+ memory = await repo.get(request.target_memory_id) if repo is not None else None
428
+ if memory is None:
429
+ requested_memory = {
430
+ "memory_id": requested_id,
431
+ "status": "not_available",
432
+ "reason_code": "NOT_AVAILABLE",
433
+ "reason": "not_retrieved_or_not_observed",
434
+ }
435
+ elif not HybridRetrievalEngine._eligible(memory, retrieval_request):
436
+ requested_memory = {
437
+ "memory_id": requested_id,
438
+ "status": "not_retrieved",
439
+ "reason_code": "TEMPORALLY_INELIGIBLE",
440
+ "reason": "temporal_policy_ineligible",
441
+ "lifecycle": memory.status.value,
442
+ "temporal_status": memory.temporal_status.value,
443
+ }
444
+ else:
445
+ lexical_idx = self.services.get("lexical_index") or self.services.get("bm25_index")
446
+ vector_st = self.services.get("vector_store")
447
+ in_lexical = bool(
448
+ lexical_idx and (
449
+ lexical_idx.contains(requested_id)
450
+ if hasattr(lexical_idx, "contains")
451
+ else (requested_id in getattr(lexical_idx, "_documents", {}))
452
+ )
453
+ )
454
+ in_vector = bool(
455
+ vector_st and (
456
+ vector_st.contains(requested_id)
457
+ if hasattr(vector_st, "contains")
458
+ else (requested_id in getattr(vector_st, "_ids", []))
459
+ )
460
+ )
461
+ if (
462
+ not in_lexical and not in_vector
463
+ and retrieval_request.mode not in {
464
+ RetrievalMode.GRAPH, RetrievalMode.HYBRID_GRAPH,
465
+ }
466
+ ):
467
+ requested_memory = {
468
+ "memory_id": requested_id,
469
+ "status": "not_retrieved",
470
+ "reason_code": "INDEX_NOT_PRESENT",
471
+ "reason": "absent_from_retrieval_index",
472
+ }
473
+ elif retrieval_request.mode in {
474
+ RetrievalMode.GRAPH, RetrievalMode.HYBRID_GRAPH,
475
+ } and not in_lexical and not in_vector:
476
+ requested_memory = {
477
+ "memory_id": requested_id,
478
+ "status": "not_available",
479
+ "reason_code": "NOT_AVAILABLE",
480
+ "reason": "graph_channel_membership_not_proven",
481
+ }
482
+ else:
483
+ requested_memory = {
484
+ "memory_id": requested_id,
485
+ "status": "not_retrieved",
486
+ "reason_code": "CHANNEL_NOT_RETRIEVED",
487
+ "reason": "channel_not_retrieved",
488
+ }
489
+
490
+ provider_dispatch_data: dict[str, Any]
491
+ if dispatch_evidence is not None:
492
+ provider_dispatch_data = dispatch_evidence.model_dump(mode="json")
493
+ else:
494
+ provider_dispatch_data = {
495
+ "state": "NOT_ATTEMPTED",
496
+ "status_message": "prepared by ContextOS; no provider dispatch attempted",
497
+ }
498
+
499
+ measured_pipeline = pipeline_ms if pipeline_ms is not None else (
500
+ retrieved.trace.total_latency_ms + selection.trace.latency_ms + getattr(compiled, "total_tokens", 0)
501
+ )
502
+ execution_ms = (time.perf_counter() - started_at) * 1000 if started_at else 0.0
503
+
504
+ return ExplanationTrace(
505
+ trace_id=trace_id,
506
+ query_id=trace_id,
507
+ strategy=mode.value,
508
+ stages=[
509
+ {"name": safe_text(stage.stage_name, 64), "input_count": stage.input_count,
510
+ "output_count": stage.output_count, "latency_ms": stage.latency_ms}
511
+ for stage in retrieved.trace.stages[:20]
512
+ ] + [
513
+ {"name": "optimizer", "input_count": selection.trace.candidate_count,
514
+ "output_count": selection.trace.selected_count,
515
+ "latency_ms": selection.trace.latency_ms,
516
+ "tokens_used": selection.trace.tokens_used,
517
+ "token_budget": selection.trace.available_tokens},
518
+ *[{"name": safe_text(stage.stage_name, 64), "input_count": stage.input_count,
519
+ "output_count": stage.output_count, "latency_ms": stage.latency_ms,
520
+ "input_tokens": stage.input_tokens, "output_tokens": stage.output_tokens}
521
+ for stage in compiled.trace.stages[:15]],
522
+ ][:20],
523
+ candidates=candidate_rows,
524
+ selected=selected_ids,
525
+ excluded=excluded[:request.limit],
526
+ final_context={
527
+ "candidate_count": retrieved.trace.total_candidates,
528
+ "candidate_tokens": sum(row["content_tokens"] or 0 for row in candidate_rows),
529
+ "optimized_tokens": selection.total_tokens,
530
+ "compiled_tokens": compiled.total_tokens,
531
+ "token_budget": compiled.budget,
532
+ "token_measurement_source": (
533
+ self.token_counter.measurement_source.value if self.token_counter else "unknown"
534
+ ),
535
+ "tokenizer": self.token_counter.encoding_name if self.token_counter else "unknown",
536
+ "facts_emitted": len(compiled.facts),
537
+ "memories_contributing": [str(value) for value in compiled.included_memory_ids[:request.limit]],
538
+ "graph_contribution_count": sum(row["retrieval"]["origin"] == "graph_expanded" for row in candidate_rows),
539
+ "temporal_filter_count": next(
540
+ (stage.input_count - stage.output_count
541
+ for stage in retrieved.trace.stages
542
+ if stage.stage_name == "eligibility_filter"),
543
+ 0,
544
+ ),
545
+ },
546
+ content=compiled.context_text[:20_000] if request.include_content else None,
547
+ evidence_scope="retrieved candidates only",
548
+ requested_memory=requested_memory,
549
+ provider_dispatch=provider_dispatch_data,
550
+ execution_ms=execution_ms,
551
+ measured_pipeline_ms=measured_pipeline,
552
+ explanation_overhead_ms=max(0.0, execution_ms - measured_pipeline),
553
+ )