superlocalmemory 3.4.18 → 3.4.21

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 (172) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +3 -2
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +219 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/embeddings.py +8 -2
  31. package/src/superlocalmemory/core/engine.py +38 -2
  32. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  33. package/src/superlocalmemory/core/ram_lock.py +111 -0
  34. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  35. package/src/superlocalmemory/core/recall_worker.py +8 -3
  36. package/src/superlocalmemory/core/security_primitives.py +635 -0
  37. package/src/superlocalmemory/core/shadow_router.py +319 -0
  38. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  39. package/src/superlocalmemory/core/slmignore.py +125 -0
  40. package/src/superlocalmemory/core/topic_signature.py +143 -0
  41. package/src/superlocalmemory/core/worker_pool.py +14 -3
  42. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  43. package/src/superlocalmemory/evolution/budget.py +321 -0
  44. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  45. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  46. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  47. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  48. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  49. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  50. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  51. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  52. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  53. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  54. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  55. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  56. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  57. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  58. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  59. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  60. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  61. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  62. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  63. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  64. package/src/superlocalmemory/infra/backup.py +3 -3
  65. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  66. package/src/superlocalmemory/infra/event_bus.py +2 -2
  67. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  68. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  69. package/src/superlocalmemory/learning/bandit.py +526 -0
  70. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  71. package/src/superlocalmemory/learning/behavioral.py +53 -1
  72. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  73. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  74. package/src/superlocalmemory/learning/database.py +256 -0
  75. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  76. package/src/superlocalmemory/learning/ensemble.py +300 -0
  77. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  78. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  79. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  80. package/src/superlocalmemory/learning/labeler.py +87 -0
  81. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  82. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  83. package/src/superlocalmemory/learning/model_cache.py +269 -0
  84. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  85. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  86. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  87. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  88. package/src/superlocalmemory/learning/ranker.py +225 -81
  89. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  91. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  92. package/src/superlocalmemory/learning/reward.py +777 -0
  93. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  94. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  96. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  97. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  98. package/src/superlocalmemory/learning/signals.py +314 -0
  99. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  100. package/src/superlocalmemory/mcp/server.py +5 -5
  101. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  102. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  103. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  104. package/src/superlocalmemory/retrieval/engine.py +52 -0
  105. package/src/superlocalmemory/retrieval/reranker.py +4 -2
  106. package/src/superlocalmemory/server/api.py +2 -2
  107. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  108. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  109. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  110. package/src/superlocalmemory/server/routes/backup.py +36 -13
  111. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  112. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  113. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  114. package/src/superlocalmemory/server/routes/events.py +2 -2
  115. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  116. package/src/superlocalmemory/server/routes/learning.py +192 -7
  117. package/src/superlocalmemory/server/routes/memories.py +189 -1
  118. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  119. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  120. package/src/superlocalmemory/server/routes/token.py +88 -0
  121. package/src/superlocalmemory/server/routes/ws.py +5 -5
  122. package/src/superlocalmemory/server/security_middleware.py +13 -7
  123. package/src/superlocalmemory/server/ui.py +2 -2
  124. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  125. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  126. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  127. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  128. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  129. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  130. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  131. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  132. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  133. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  134. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  135. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  136. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  137. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  138. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  139. package/src/superlocalmemory/storage/models.py +4 -0
  140. package/src/superlocalmemory/ui/css/brain.css +409 -0
  141. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  142. package/src/superlocalmemory/ui/index.html +459 -1345
  143. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  144. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  145. package/src/superlocalmemory/ui/js/init.js +48 -39
  146. package/src/superlocalmemory/ui/js/memories.js +88 -2
  147. package/src/superlocalmemory/ui/js/modal.js +71 -1
  148. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  149. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  153. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  154. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  155. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  157. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  159. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  160. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  161. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  162. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  163. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  164. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  165. package/src/superlocalmemory/ui/js/learning.js +0 -435
  166. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  167. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  168. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  169. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  170. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  171. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  172. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -11,6 +11,8 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import hashlib
15
+ import hmac
14
16
  import logging
15
17
  from typing import TYPE_CHECKING, Any
16
18
 
@@ -19,11 +21,130 @@ if TYPE_CHECKING:
19
21
  from superlocalmemory.core.hooks import HookRegistry
20
22
  from superlocalmemory.storage.database import DatabaseManager
21
23
 
24
+ from superlocalmemory.core.security_primitives import ensure_install_token
22
25
  from superlocalmemory.storage.models import Mode, RecallResponse
23
26
 
24
27
  logger = logging.getLogger(__name__)
25
28
 
26
29
 
30
+ # ---------------------------------------------------------------------------
31
+ # LLD-00 §3 — HMAC fact-id markers (P0.4, SEC-C-01 fix)
32
+ # ---------------------------------------------------------------------------
33
+ #
34
+ # Every fact surfaced in a recall response is tagged with
35
+ # slm:fact:<fact_id>:<hmac8>
36
+ # where hmac8 is the first 8 hex chars of HMAC-SHA256(install_token, fact_id).
37
+ #
38
+ # post_tool_outcome_hook (LLD-09) scans only for this prefix and validates
39
+ # the HMAC. Unverified markers are ignored — this closes the tool-output
40
+ # injection attack where attacker-controlled output could forge engagement
41
+ # signals by spelling a known fact_id.
42
+
43
+ _HMAC_MARKER_PREFIX = "slm:fact:"
44
+ _HMAC_LEN = 8
45
+
46
+
47
+ def _emit_marker(fact_id: str) -> str:
48
+ """Tag ``fact_id`` with its HMAC so downstream hooks can validate.
49
+
50
+ Deterministic per install: a given (install_token, fact_id) pair always
51
+ produces the same marker. Token rotation invalidates old markers.
52
+ """
53
+ token = ensure_install_token()
54
+ digest = hmac.new(
55
+ token.encode("utf-8"), fact_id.encode("utf-8"), hashlib.sha256
56
+ ).hexdigest()[:_HMAC_LEN]
57
+ return f"{_HMAC_MARKER_PREFIX}{fact_id}:{digest}"
58
+
59
+
60
+ def _validate_marker(marker: str) -> str | None:
61
+ """Return ``fact_id`` if ``marker`` is a valid HMAC marker, else None.
62
+
63
+ Uses constant-time compare. Never raises.
64
+ """
65
+ if not isinstance(marker, str) or not marker.startswith(_HMAC_MARKER_PREFIX):
66
+ return None
67
+ rest = marker[len(_HMAC_MARKER_PREFIX):]
68
+ fact_id, sep, presented = rest.rpartition(":")
69
+ if not sep or not fact_id or len(presented) != _HMAC_LEN:
70
+ return None
71
+ try:
72
+ token = ensure_install_token()
73
+ except Exception: # pragma: no cover — install-token I/O failure
74
+ return None
75
+ expected = hmac.new(
76
+ token.encode("utf-8"), fact_id.encode("utf-8"), hashlib.sha256
77
+ ).hexdigest()[:_HMAC_LEN]
78
+ if hmac.compare_digest(presented, expected):
79
+ return fact_id
80
+ return None
81
+
82
+
83
+ def _apply_markers_to_response(response: RecallResponse) -> None:
84
+ """Populate ``result.marker`` on every result in ``response``, in place.
85
+
86
+ Called as the last step of :func:`run_recall` before returning. Empty
87
+ responses pass through untouched.
88
+
89
+ # L-P-06: audit flagged ``dataclasses.replace`` as a cheaper path.
90
+ # Verified: ``RecallResult`` is NOT frozen, so the direct in-place
91
+ # attribute assignment below is the O(1) mutation path — no dataclass
92
+ # reconstruction happens. ``replace`` would ALLOCATE a fresh instance
93
+ # per result (strictly slower). Keep the in-place mutation.
94
+ """
95
+ for r in response.results:
96
+ r.marker = _emit_marker(r.fact.fact_id)
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Stage 8 SB-1 — feed shadow_router from recall-settled signals.
101
+ #
102
+ # LLD-10 Track A.3 needs live-recall A/B observations to feed ShadowTest
103
+ # (pre-promotion) and ModelRollback (post-promotion). The ndcg_at_10
104
+ # signal materialises when ``EngagementRewardModel.finalize_outcome``
105
+ # settles a row — that is the natural call site for this helper.
106
+ #
107
+ # This is a THIN wrapper over ``core.shadow_router.get_shadow_router``
108
+ # so the finalize-outcome path does not need to import shadow_router
109
+ # directly. Fail-soft on every error — recall pipeline integrity comes
110
+ # first.
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ def feed_recall_settled(
115
+ *,
116
+ memory_db: str,
117
+ learning_db: str,
118
+ profile_id: str,
119
+ query_id: str,
120
+ ndcg_at_10: float,
121
+ ) -> None:
122
+ """Route a settled recall's NDCG@10 into the shadow router.
123
+
124
+ The arm is recomputed from ``query_id`` so callers don't need to
125
+ persist arm assignment anywhere — the router's determinism
126
+ guarantees the same arm decision at settle-time that was used at
127
+ recall-time.
128
+
129
+ Called from ``EngagementRewardModel.finalize_outcome`` (LLD-08 §4.2)
130
+ after the reward row is committed. Cheap on the hot path: one
131
+ singleton-cache read + one paired-list append.
132
+ """
133
+ try:
134
+ from superlocalmemory.core import shadow_router as _sr
135
+ router = _sr.get_shadow_router(
136
+ memory_db=memory_db,
137
+ learning_db=learning_db,
138
+ profile_id=profile_id,
139
+ )
140
+ arm = router.route_query(query_id)
141
+ router.on_recall_settled(
142
+ query_id=query_id, arm=arm, ndcg_at_10=float(ndcg_at_10),
143
+ )
144
+ except Exception as exc: # pragma: no cover — defence in depth
145
+ logger.debug("feed_recall_settled error: %s", exc)
146
+
147
+
27
148
  # ---------------------------------------------------------------------------
28
149
  # V3.3.16: Module-level singletons for recall hot-path objects.
29
150
  # Prevents creating new BehavioralTracker / ForgettingScheduler per recall
@@ -54,6 +175,80 @@ def _get_forgetting_scheduler(db: Any, config: Any) -> Any:
54
175
  return _forgetting_scheduler_cache[key]
55
176
 
56
177
 
178
+ # ---------------------------------------------------------------------------
179
+ # S8-ARC-04 (v3.4.21): unified ranking entry point.
180
+ # ---------------------------------------------------------------------------
181
+
182
+ _RANKING_MODES: frozenset[str] = frozenset({"off", "v1", "v2", "v2-ensemble"})
183
+
184
+
185
+ def _resolve_ranking_mode(env: "dict[str, str] | os._Environ[str]") -> str:
186
+ """Map the ``SLM_RANKING`` env var to a canonical mode.
187
+
188
+ Legacy ``SLM_V2_PIPELINE_DISABLED=1`` and ``SLM_BANDIT_DISABLED=1``
189
+ are honoured for one-release back-compat. Explicit ``SLM_RANKING``
190
+ wins if both are set.
191
+ """
192
+ raw = (env.get("SLM_RANKING", "") or "").strip().lower()
193
+ if raw in _RANKING_MODES:
194
+ return raw
195
+ if (env.get("SLM_V2_PIPELINE_DISABLED", "0") or "0").strip() == "1":
196
+ # v2 disabled → fall back to v1 adaptive only.
197
+ return "v1"
198
+ if (env.get("SLM_BANDIT_DISABLED", "0") or "0").strip() == "1":
199
+ # Bandit disabled → v2 without ensemble.
200
+ return "v2"
201
+ return "v2-ensemble"
202
+
203
+
204
+ def apply_ranking(
205
+ response: "RecallResponse",
206
+ query: str,
207
+ profile_id: str,
208
+ query_id: str,
209
+ *,
210
+ config: Any = None,
211
+ pipeline_version: str = "v2-ensemble",
212
+ ) -> "RecallResponse":
213
+ """Run the ranking pipeline at the requested version.
214
+
215
+ Modes:
216
+ - ``off``: identity — no ranking passes run at all.
217
+ - ``v1``: v3.1 Active-Memory adaptive rerank only.
218
+ - ``v2``: v1 + v3.4.21 lambdarank rerank + signal enqueue.
219
+ - ``v2-ensemble`` (default): v2 + v3.4.21 contextual-bandit ensemble.
220
+
221
+ Each underlying pass is already defensive (catches its own exceptions),
222
+ so this wrapper adds an outer try/except to guarantee the caller
223
+ always gets a response back. Previously three separate call sites in
224
+ run_recall chained these; collapsing keeps precedence explicit.
225
+ """
226
+ if pipeline_version == "off":
227
+ return response
228
+ try:
229
+ response = apply_adaptive_ranking(response, query, profile_id,
230
+ config=config)
231
+ except Exception as exc: # pragma: no cover — defensive
232
+ logger.debug("apply_ranking v1 step skipped: %s", exc)
233
+ if pipeline_version == "v1":
234
+ return response
235
+ try:
236
+ response = apply_v2_adaptive_ranking(
237
+ response, query, profile_id, query_id,
238
+ )
239
+ except Exception as exc: # pragma: no cover — defensive
240
+ logger.debug("apply_ranking v2 step skipped: %s", exc)
241
+ if pipeline_version == "v2":
242
+ return response
243
+ try:
244
+ response = apply_v2_bandit_ensemble(
245
+ response, query, profile_id, query_id,
246
+ )
247
+ except Exception as exc: # pragma: no cover — defensive
248
+ logger.debug("apply_ranking ensemble step skipped: %s", exc)
249
+ return response
250
+
251
+
57
252
  # ---------------------------------------------------------------------------
58
253
  # apply_adaptive_ranking (was MemoryEngine._apply_adaptive_ranking)
59
254
  # ---------------------------------------------------------------------------
@@ -118,6 +313,227 @@ def apply_adaptive_ranking(
118
313
  )
119
314
 
120
315
 
316
+ # ---------------------------------------------------------------------------
317
+ # apply_v2_adaptive_ranking (LLD-02 §4.3)
318
+ # ---------------------------------------------------------------------------
319
+ #
320
+ # Opt-in v3.4.21 path: load active model from learning.db with SHA-256
321
+ # verification, re-rank via native Booster, enqueue signals async. The
322
+ # existing ``apply_adaptive_ranking`` above stays for 3.4.20 callers.
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ def apply_v2_adaptive_ranking(
327
+ response: RecallResponse,
328
+ query: str,
329
+ profile_id: str,
330
+ query_id: str,
331
+ *,
332
+ learning_db_path: Any = None,
333
+ ) -> RecallResponse:
334
+ """LLD-02 §4.3 — load verified model, rerank, enqueue signals.
335
+
336
+ Never raises. On any error, returns ``response`` unchanged.
337
+ """
338
+ try:
339
+ from pathlib import Path as _P
340
+
341
+ from superlocalmemory.learning.database import LearningDatabase
342
+ from superlocalmemory.learning.model_cache import load_active
343
+ from superlocalmemory.learning.ranker import AdaptiveRanker
344
+ from superlocalmemory.learning.signals import (
345
+ SignalBatch,
346
+ SignalCandidate,
347
+ enqueue,
348
+ )
349
+
350
+ db_path = (_P(learning_db_path) if learning_db_path
351
+ else _P.home() / ".superlocalmemory" / "learning.db")
352
+ if not db_path.exists():
353
+ return response
354
+
355
+ db = LearningDatabase(db_path)
356
+ signal_count = db.count_signals(profile_id)
357
+ active = load_active(db, profile_id)
358
+
359
+ ranker = AdaptiveRanker(
360
+ signal_count=signal_count,
361
+ active_model=active,
362
+ )
363
+
364
+ # Build result-dict shape expected by the ranker's rerank() path.
365
+ result_dicts: list[dict] = []
366
+ for r in response.results:
367
+ result_dicts.append({
368
+ "fact_id": r.fact.fact_id,
369
+ "score": r.score,
370
+ "cross_encoder_score": r.score,
371
+ "trust_score": r.trust_score,
372
+ "channel_scores": r.channel_scores or {},
373
+ "fact": {
374
+ "age_days": 0,
375
+ "access_count": r.fact.access_count,
376
+ },
377
+ "_original": r,
378
+ })
379
+
380
+ query_context = {
381
+ "query_type": response.query_type,
382
+ "profile_id": profile_id,
383
+ }
384
+ reranked_dicts = ranker.rerank(result_dicts, query_context)
385
+ new_results = [d["_original"] for d in reranked_dicts
386
+ if "_original" in d]
387
+
388
+ # S8-SK-04 fix: signal enqueue is OWNED by ``apply_v2_bandit_ensemble``
389
+ # (see below), not this function. Previously both emitted a batch
390
+ # under the same query_id which doubled ``learning_signals`` and
391
+ # tripped the phase-transition threshold at half the intended
392
+ # signal count. This function now just re-ranks; the ensemble path
393
+ # is the single source of signal events.
394
+
395
+ return RecallResponse(
396
+ query=response.query,
397
+ mode=response.mode,
398
+ results=new_results,
399
+ query_type=response.query_type,
400
+ channel_weights=response.channel_weights,
401
+ total_candidates=response.total_candidates,
402
+ retrieval_time_ms=response.retrieval_time_ms,
403
+ )
404
+ except Exception as exc: # pragma: no cover — defensive
405
+ logger.debug("apply_v2_adaptive_ranking skipped: %s", exc)
406
+ return response
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # apply_v2_bandit_ensemble (LLD-03 §5.5)
411
+ # ---------------------------------------------------------------------------
412
+ #
413
+ # Contextual Thompson bandit chooses channel weights. If an LGBM model is
414
+ # active, a D8-blended ensemble re-ranks the reweighted candidates. Never
415
+ # raises; honours ``SLM_BANDIT_DISABLED=1`` as a kill switch.
416
+ # ---------------------------------------------------------------------------
417
+
418
+
419
+ def apply_v2_bandit_ensemble(
420
+ response: RecallResponse,
421
+ query: str,
422
+ profile_id: str,
423
+ query_id: str,
424
+ *,
425
+ learning_db_path: Any = None,
426
+ ) -> RecallResponse:
427
+ """Apply contextual bandit + optional LGBM ensemble rerank. Safe on error."""
428
+ import os as _os
429
+
430
+ if _os.environ.get("SLM_BANDIT_DISABLED", "0") == "1":
431
+ return response
432
+ if not response.results:
433
+ return response
434
+
435
+ try:
436
+ from datetime import datetime as _dt
437
+ from pathlib import Path as _P
438
+
439
+ from superlocalmemory.learning.bandit import ContextualBandit
440
+ from superlocalmemory.learning.ensemble import (
441
+ choose_ensemble,
442
+ ensemble_rerank,
443
+ )
444
+ from superlocalmemory.learning.signals import (
445
+ SignalBatch,
446
+ SignalCandidate,
447
+ enqueue,
448
+ )
449
+ from superlocalmemory.retrieval.engine import apply_channel_weights
450
+
451
+ db_path = (_P(learning_db_path) if learning_db_path
452
+ else _P.home() / ".superlocalmemory" / "learning.db")
453
+ if not db_path.exists():
454
+ return response
455
+
456
+ # --- 1. bandit.choose ---------------------------------------------
457
+ entity_count = 0
458
+ # Use query_context hints if available on the engine — cheap fallback.
459
+ bandit = ContextualBandit(db_path, profile_id)
460
+ choice = bandit.choose(
461
+ {
462
+ "query_type": response.query_type,
463
+ "entity_count": entity_count,
464
+ },
465
+ query_id,
466
+ )
467
+
468
+ # --- 2. apply channel weights -------------------------------------
469
+ weighted = apply_channel_weights(list(response.results), choice.weights)
470
+
471
+ # --- 3. choose ensemble + load model (optional) -------------------
472
+ active_model = None
473
+ signal_count = 0
474
+ try:
475
+ from superlocalmemory.learning.database import LearningDatabase
476
+ from superlocalmemory.learning.model_cache import load_active
477
+ db = LearningDatabase(db_path)
478
+ signal_count = db.count_signals(profile_id)
479
+ active_model = load_active(db, profile_id)
480
+ except Exception as exc:
481
+ logger.debug("v2 bandit: model/signal load skipped: %s", exc)
482
+
483
+ weights = choose_ensemble(signal_count, active_model)
484
+
485
+ # --- 4. ensemble rerank -------------------------------------------
486
+ query_context = {
487
+ "query_type": response.query_type,
488
+ "profile_id": profile_id,
489
+ "query_id": query_id,
490
+ "bandit_play_id": choice.play_id,
491
+ }
492
+ try:
493
+ final_results = ensemble_rerank(
494
+ weighted, choice, active_model, weights, query_context,
495
+ )
496
+ except Exception as exc:
497
+ logger.debug("v2 bandit ensemble_rerank skipped: %s", exc)
498
+ final_results = weighted
499
+
500
+ # --- 5. enqueue signals (non-blocking) ----------------------------
501
+ try:
502
+ top20 = final_results[:20]
503
+ candidates = tuple(
504
+ SignalCandidate(
505
+ fact_id=r.fact.fact_id,
506
+ channel_scores=dict(r.channel_scores or {}),
507
+ cross_encoder_score=None,
508
+ result_dict={"fact_id": r.fact.fact_id,
509
+ "score": r.score},
510
+ )
511
+ for r in top20
512
+ )
513
+ enqueue(SignalBatch(
514
+ profile_id=profile_id,
515
+ query_id=query_id,
516
+ query_text=query,
517
+ candidates=candidates,
518
+ query_context=query_context,
519
+ ))
520
+ except Exception as exc:
521
+ logger.debug("v2 bandit signal enqueue skipped: %s", exc)
522
+
523
+ return RecallResponse(
524
+ query=response.query,
525
+ mode=response.mode,
526
+ results=final_results,
527
+ query_type=response.query_type,
528
+ channel_weights=response.channel_weights,
529
+ total_candidates=response.total_candidates,
530
+ retrieval_time_ms=response.retrieval_time_ms,
531
+ )
532
+ except Exception as exc: # pragma: no cover — defensive top-level
533
+ logger.debug("apply_v2_bandit_ensemble skipped: %s", exc)
534
+ return response
535
+
536
+
121
537
  # ---------------------------------------------------------------------------
122
538
  # run_recall (was MemoryEngine.recall)
123
539
  # ---------------------------------------------------------------------------
@@ -278,11 +694,21 @@ def run_recall(
278
694
  except Exception as exc:
279
695
  logger.debug("Hebbian strengthening: %s", exc)
280
696
 
281
- # Adaptive re-ranking (V3.1 Active Memory)
697
+ # S8-ARC-04 (v3.4.21): unified ranking entry point. Single env-var
698
+ # (SLM_RANKING=off|v1|v2|v2-ensemble) controls the pipeline. Legacy
699
+ # SLM_V2_PIPELINE_DISABLED + SLM_BANDIT_DISABLED still honoured for
700
+ # one-release back-compat. Identity when no active model.
282
701
  try:
283
- response = apply_adaptive_ranking(response, query, profile_id, config=config)
702
+ import os as _os
703
+ import uuid as _uuid
704
+ query_id = _uuid.uuid4().hex
705
+ mode = _resolve_ranking_mode(_os.environ)
706
+ response = apply_ranking(
707
+ response, query, profile_id, query_id,
708
+ config=config, pipeline_version=mode,
709
+ )
284
710
  except Exception as exc:
285
- logger.debug("Adaptive ranking skipped: %s", exc)
711
+ logger.debug("Ranking pipeline skipped: %s", exc)
286
712
 
287
713
  # Reconsolidation: access updates trust + count (neuroscience principle)
288
714
  if trust_scorer:
@@ -321,4 +747,8 @@ def run_recall(
321
747
  hook_ctx["query_type"] = response.query_type
322
748
  hooks.run_post("recall", hook_ctx)
323
749
 
750
+ # LLD-00 §3 — stamp HMAC markers on every result so post_tool_outcome_hook
751
+ # can validate fact_ids observed in downstream tool output.
752
+ _apply_markers_to_response(response)
753
+
324
754
  return response
@@ -72,9 +72,11 @@ def _get_engine():
72
72
  return _engine
73
73
 
74
74
 
75
- def _handle_recall(query: str, limit: int) -> dict:
75
+ def _handle_recall(query: str, limit: int, session_id: str = "") -> dict:
76
76
  engine = _get_engine()
77
- response = engine.recall(query, limit=limit)
77
+ response = engine.recall(
78
+ query, limit=limit, session_id=session_id or None,
79
+ )
78
80
 
79
81
  # Batch-fetch original memory text for all results
80
82
  memory_ids = list({r.fact.memory_id for r in response.results[:limit] if r.fact.memory_id})
@@ -288,7 +290,10 @@ def _worker_main() -> None:
288
290
 
289
291
  try:
290
292
  if cmd == "recall":
291
- result = _handle_recall(req.get("query", ""), req.get("limit", 10))
293
+ result = _handle_recall(
294
+ req.get("query", ""), req.get("limit", 10),
295
+ req.get("session_id", ""),
296
+ )
292
297
  _respond(result)
293
298
  elif cmd == "store":
294
299
  result = _handle_store(req.get("content", ""), req.get("metadata", {}))