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
@@ -0,0 +1,300 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-03 §5.4
4
+
5
+ """Bandit / LightGBM ensemble blender.
6
+
7
+ LLD reference: ``.backup/active-brain/lld/LLD-03-contextual-bandit-and-ensemble.md``
8
+ Section 5.4.
9
+
10
+ D8 blend policy (``choose_ensemble``):
11
+ - 0..199 signals OR model is None → ``EnsembleWeights(1.0, 0.0)`` (bandit-only).
12
+ - 200..499 signals + model → ``EnsembleWeights(0.4, 0.6)`` (warm blend).
13
+ - 500+ signals + model → ``EnsembleWeights(0.2, 0.8)`` (mature).
14
+
15
+ Hard rules:
16
+ - E1: ``bandit + lgbm == 1.0`` — asserted at construction.
17
+ - E2: ``booster.predict`` called exactly ONCE per rerank (batched).
18
+ - E3: no predict call when ``lgbm_weight == 0.0`` or ``model is None``.
19
+ - E4: both score streams normalised to [0, 1] before blending.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import os
26
+ from dataclasses import dataclass
27
+ from typing import Any, Sequence
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # Thresholds come from env for ops-time override; defaults match LLD-03 §10.
33
+ _MIN_SIGNALS = int(os.environ.get("SLM_ENSEMBLE_LGBM_MIN_SIGNALS", "200"))
34
+ _DOMINANT_SIGNALS = int(
35
+ os.environ.get("SLM_ENSEMBLE_DOMINANT_MIN_SIGNALS", "500")
36
+ )
37
+
38
+
39
+ def _parse_blend(value: str, fallback: tuple[float, float]) -> tuple[float, float]:
40
+ """Parse 'bandit:lgbm' env var into a (bandit, lgbm) tuple."""
41
+ try:
42
+ a_s, b_s = value.split(":", 1)
43
+ a, b = float(a_s), float(b_s)
44
+ if abs((a + b) - 1.0) > 1e-6:
45
+ return fallback
46
+ return (a, b)
47
+ except (ValueError, AttributeError):
48
+ return fallback
49
+
50
+
51
+ _WARM = _parse_blend(
52
+ os.environ.get("SLM_ENSEMBLE_BLEND_WARM", "0.4:0.6"), (0.4, 0.6),
53
+ )
54
+ _MATURE = _parse_blend(
55
+ os.environ.get("SLM_ENSEMBLE_BLEND_MATURE", "0.2:0.8"), (0.2, 0.8),
56
+ )
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # EnsembleWeights
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class EnsembleWeights:
66
+ """Blend weights for the bandit/LGBM ensemble.
67
+
68
+ E1: ``bandit + lgbm`` must equal 1.0 (±1e-6 float tolerance).
69
+ """
70
+
71
+ bandit: float
72
+ lgbm: float
73
+
74
+ def __post_init__(self) -> None:
75
+ total = self.bandit + self.lgbm
76
+ if abs(total - 1.0) > 1e-6:
77
+ raise AssertionError(
78
+ f"EnsembleWeights must sum to 1.0, got {total}"
79
+ )
80
+ if self.bandit < 0.0 or self.lgbm < 0.0:
81
+ raise AssertionError(
82
+ f"EnsembleWeights must be non-negative, got "
83
+ f"bandit={self.bandit}, lgbm={self.lgbm}"
84
+ )
85
+
86
+
87
+ def choose_ensemble(
88
+ signal_count: int,
89
+ model: Any | None,
90
+ ) -> EnsembleWeights:
91
+ """Select bandit/LGBM blend per D8.
92
+
93
+ ``model`` is typed ``Any`` to avoid importing ``ActiveModel`` at module
94
+ load; in practice it's an ``ActiveModel | None``. Only ``model is None``
95
+ is checked.
96
+ """
97
+ try:
98
+ count = int(signal_count)
99
+ except (TypeError, ValueError):
100
+ count = 0
101
+ if model is None or count < _MIN_SIGNALS:
102
+ return EnsembleWeights(1.0, 0.0)
103
+ if count < _DOMINANT_SIGNALS:
104
+ return EnsembleWeights(_WARM[0], _WARM[1])
105
+ return EnsembleWeights(_MATURE[0], _MATURE[1])
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Scoring helpers
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ def _softmax_unit(scores: Sequence[float]) -> list[float]:
114
+ """Normalise a score stream to [0, 1] via softmax, numerically stable.
115
+
116
+ Preserves ordering. Returns uniform 1/N when all scores are identical.
117
+ """
118
+ if not scores:
119
+ return []
120
+ xs = list(scores)
121
+ n = len(xs)
122
+ m = max(xs)
123
+ # Subtract max for numerical stability before exp.
124
+ exps = []
125
+ for v in xs:
126
+ try:
127
+ exps.append(pow(2.718281828459045, v - m))
128
+ except OverflowError: # pragma: no cover — m subtraction avoids this
129
+ exps.append(0.0)
130
+ total = sum(exps)
131
+ if total <= 0.0: # pragma: no cover — defensive
132
+ return [1.0 / n] * n
133
+ return [e / total for e in exps]
134
+
135
+
136
+ def _apply_weights_score(candidate: Any, weights: dict[str, float]) -> float:
137
+ """Compute a scalar bandit score for a candidate under the arm weights.
138
+
139
+ Input shape: candidate has either ``.channel_scores`` attr OR ``score``.
140
+ For v3.4.21 the bandit-only path simply uses the already-weighted ordering
141
+ from ``apply_channel_weights``; this helper only matters when we blend.
142
+ """
143
+ # Prefer pre-weighted score on the object.
144
+ score = getattr(candidate, "score", None)
145
+ if score is None and isinstance(candidate, dict):
146
+ score = candidate.get("score")
147
+ if score is None:
148
+ # Fallback: sum channel contributions × weights.
149
+ cs = getattr(candidate, "channel_scores", None)
150
+ if cs is None and isinstance(candidate, dict):
151
+ cs = candidate.get("channel_scores", {}) or {}
152
+ cs = cs or {}
153
+ score = sum(
154
+ float(cs.get(name, 0.0)) * float(weights.get(name, 1.0))
155
+ for name in ("semantic", "bm25", "entity_graph", "temporal")
156
+ )
157
+ ce = None
158
+ if hasattr(candidate, "cross_encoder_score"):
159
+ ce = getattr(candidate, "cross_encoder_score", None)
160
+ elif isinstance(candidate, dict):
161
+ ce = candidate.get("cross_encoder_score")
162
+ if ce is not None:
163
+ score += float(ce) * float(
164
+ weights.get("cross_encoder_bias", 1.0)
165
+ )
166
+ try:
167
+ return float(score)
168
+ except (TypeError, ValueError):
169
+ return 0.0
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # ensemble_rerank
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ def ensemble_rerank(
178
+ candidates: list[Any],
179
+ bandit_choice: Any,
180
+ model: Any | None,
181
+ weights: EnsembleWeights,
182
+ query_context: dict[str, Any],
183
+ ) -> list[Any]:
184
+ """Blend bandit + LGBM scores and reorder candidates.
185
+
186
+ E2: ``booster.predict`` called at most ONCE, via a single batched input.
187
+ E3: short-circuits when ``weights.lgbm == 0.0`` or ``model is None``.
188
+ E4: softmax-unit normalisation per stream before blending.
189
+
190
+ Never raises. On error (import / predict), returns input unchanged.
191
+ """
192
+ if not candidates:
193
+ return candidates
194
+
195
+ # E3: short-circuit.
196
+ if weights.lgbm == 0.0 or model is None:
197
+ return list(candidates)
198
+
199
+ try:
200
+ import numpy as np # noqa: PLC0415 — optional heavy dep
201
+ except ImportError: # pragma: no cover — optional
202
+ logger.debug("ensemble_rerank: numpy unavailable; bandit-only path")
203
+ return list(candidates)
204
+
205
+ # Lazy import so the unit tests don't require lightgbm at import time.
206
+ try:
207
+ from superlocalmemory.learning.features import FeatureExtractor
208
+ except ImportError: # pragma: no cover — defensive
209
+ return list(candidates)
210
+
211
+ # Build batch feature matrix ONCE. PERF-v2-02: also stash a
212
+ # ``{fact_id: features_json}`` dict on ``query_context`` under the
213
+ # reserved key ``_precomputed_features_json`` so the downstream
214
+ # signal_worker (which would otherwise call ``FeatureExtractor.extract``
215
+ # again when recording signals) can reuse this work. No schema change;
216
+ # purely a caller-opt-in cache the signal writer probes.
217
+ try:
218
+ import json as _json # noqa: PLC0415 — local import keeps hot-path clean
219
+
220
+ rows = []
221
+ feats_cache: dict[str, str] = {}
222
+ for c in candidates:
223
+ result = _candidate_to_result(c)
224
+ fv = FeatureExtractor.extract(result, query_context)
225
+ rows.append(fv.to_list())
226
+ fid = getattr(c, "fact_id", None) or result.get("fact_id", "")
227
+ if fid:
228
+ feats_cache[fid] = _json.dumps(
229
+ fv.features, separators=(",", ":"),
230
+ )
231
+ X = np.asarray(rows, dtype=np.float32)
232
+ if isinstance(query_context, dict) and feats_cache:
233
+ # Merge into caller's dict; do not clobber a pre-existing cache.
234
+ existing = query_context.get("_precomputed_features_json") or {}
235
+ if isinstance(existing, dict):
236
+ merged = {**existing, **feats_cache}
237
+ query_context["_precomputed_features_json"] = merged
238
+ except Exception as exc:
239
+ logger.debug("ensemble_rerank: feature build failed: %s", exc)
240
+ return list(candidates)
241
+
242
+ # E2: single batched predict call.
243
+ booster = getattr(model, "booster", None)
244
+ if booster is None or not hasattr(booster, "predict"):
245
+ return list(candidates)
246
+ try:
247
+ lgbm_scores = booster.predict(X)
248
+ except Exception as exc:
249
+ logger.warning("ensemble_rerank: predict failed: %s", exc)
250
+ return list(candidates)
251
+ try:
252
+ lgbm_scores = list(map(float, lgbm_scores))
253
+ except (TypeError, ValueError): # pragma: no cover — defensive
254
+ return list(candidates)
255
+
256
+ arm_weights = (
257
+ bandit_choice.weights if hasattr(bandit_choice, "weights") else {}
258
+ )
259
+ bandit_scores = [
260
+ _apply_weights_score(c, arm_weights) for c in candidates
261
+ ]
262
+
263
+ # E4: normalise each stream to [0, 1] via softmax before blending.
264
+ n_lgbm = _softmax_unit(lgbm_scores)
265
+ n_bandit = _softmax_unit(bandit_scores)
266
+
267
+ blended = [
268
+ weights.bandit * b + weights.lgbm * l
269
+ for b, l in zip(n_bandit, n_lgbm)
270
+ ]
271
+
272
+ # Stable-sort descending so equal scores preserve original order.
273
+ indexed = list(enumerate(candidates))
274
+ indexed.sort(key=lambda pair: -blended[pair[0]])
275
+ return [c for _, c in indexed]
276
+
277
+
278
+ def _candidate_to_result(c: Any) -> dict[str, Any]:
279
+ """Coerce a candidate (dict / dataclass / ORM row) to a feature result."""
280
+ if isinstance(c, dict):
281
+ return c
282
+ if hasattr(c, "to_result_dict") and callable(c.to_result_dict):
283
+ try:
284
+ return c.to_result_dict()
285
+ except Exception: # pragma: no cover — defensive
286
+ pass
287
+ # Last resort: assemble from common attributes.
288
+ return {
289
+ "fact_id": getattr(c, "fact_id", ""),
290
+ "score": getattr(c, "score", 0.0),
291
+ "channel_scores": getattr(c, "channel_scores", {}) or {},
292
+ "cross_encoder_score": getattr(c, "cross_encoder_score", None),
293
+ }
294
+
295
+
296
+ __all__ = (
297
+ "EnsembleWeights",
298
+ "choose_ensemble",
299
+ "ensemble_rerank",
300
+ )
@@ -0,0 +1,207 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — F4.A Stage-8 H-03/H-06 fix
4
+
5
+ """Parameterised JSON1-backed join helpers for ``action_outcomes``.
6
+
7
+ Replaces the fragile ``fact_ids_json LIKE '%"<fid>"%'`` pattern that five
8
+ call sites depended on. Substring matching on serialised JSON leaks
9
+ false positives across overlapping fact_id prefixes — see Stage-8
10
+ skeptic-H06 for the exact failure mode.
11
+
12
+ This module centralises the correct lookup:
13
+
14
+ SELECT outcome_id, ... FROM action_outcomes
15
+ WHERE profile_id = ?
16
+ AND EXISTS (
17
+ SELECT 1 FROM json_each(fact_ids_json) WHERE value = ?
18
+ )
19
+
20
+ SQLite ships JSON1 enabled by default since 3.38 (February 2022) and the
21
+ minimum Python supported by SLM is 3.9 — which ships SQLite ≥ 3.31. We
22
+ defensively fall back to a ``LIKE`` probe only when JSON1 is missing at
23
+ runtime, with a one-off warning.
24
+
25
+ Callers:
26
+ - ``learning/hnsw_dedup.py`` — ``apply_strong_memory_boost``,
27
+ ``select_high_reward_fact_ids``, ``run_reward_gated_archive``.
28
+ - ``learning/forgetting_scheduler.py`` — ``_has_recent_positive_reward``.
29
+
30
+ Contract refs:
31
+ - Stage 8 H-03 (architect-H3) + H-06 (skeptic-H06).
32
+ - LLD-12 §5 — reward-gated archive.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import logging
38
+ import sqlite3
39
+ from typing import Iterable
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ __all__ = (
44
+ "iter_outcomes_for_fact",
45
+ "has_recent_positive_reward",
46
+ "aggregate_reward_for_fact",
47
+ )
48
+
49
+
50
+ # Columns returned — mirror what the legacy LIKE callers read.
51
+ # NB: callers that need extra columns can pass ``columns=``.
52
+ _DEFAULT_COLUMNS = "outcome_id, profile_id, fact_ids_json, reward, settled_at"
53
+
54
+
55
+ def _json1_available(conn: sqlite3.Connection) -> bool:
56
+ """Return True iff SQLite ``json_each`` is usable on ``conn``.
57
+
58
+ Result is intentionally not cached across connections — JSON1 is a
59
+ compile-time flag and runtime-swapping SQLite libraries is a rare
60
+ edge case but we stay defensive.
61
+ """
62
+ try:
63
+ conn.execute("SELECT value FROM json_each('[\"x\"]') LIMIT 1").fetchall()
64
+ return True
65
+ except sqlite3.OperationalError:
66
+ return False
67
+
68
+
69
+ def iter_outcomes_for_fact(
70
+ conn: sqlite3.Connection,
71
+ profile_id: str,
72
+ fact_id: str,
73
+ *,
74
+ columns: str = _DEFAULT_COLUMNS,
75
+ extra_where: str = "",
76
+ extra_params: tuple = (),
77
+ ) -> Iterable[tuple]:
78
+ """Yield action_outcomes rows whose fact_ids_json contains ``fact_id``.
79
+
80
+ Scoped strictly to ``profile_id``; SQL parameters are always bound,
81
+ never string-interpolated. Returns a materialised list so the caller
82
+ can close the connection immediately.
83
+
84
+ Args:
85
+ conn: SQLite connection pointing at the database holding the
86
+ ``action_outcomes`` table (usually ``memory.db``).
87
+ profile_id: Profile scope.
88
+ fact_id: Exact fact_id to find.
89
+ columns: Comma-separated column list projected into the
90
+ SELECT. Defaults to (outcome_id, profile_id, fact_ids_json,
91
+ reward, settled_at).
92
+ extra_where: Optional extra predicate — must start with 'AND'
93
+ and use '?' placeholders. E.g.
94
+ ``"AND reward IS NOT NULL AND reward > ?"``.
95
+ extra_params: Bound parameters for ``extra_where``.
96
+
97
+ Returns:
98
+ List of sqlite3.Row-compatible tuples (or sqlite3.Row objects if
99
+ the caller set ``conn.row_factory = sqlite3.Row``).
100
+ """
101
+ if not profile_id or not fact_id:
102
+ return []
103
+
104
+ if _json1_available(conn):
105
+ sql = (
106
+ f"SELECT {columns} FROM action_outcomes "
107
+ f"WHERE profile_id = ? "
108
+ f" AND EXISTS ("
109
+ f" SELECT 1 FROM json_each(fact_ids_json) WHERE value = ?"
110
+ f" ) "
111
+ f"{extra_where}"
112
+ )
113
+ params = (profile_id, fact_id, *extra_params)
114
+ else:
115
+ # Fallback: prefix-LIKE. Accurate ONLY for simple ids.
116
+ # Logged once per process to flag that JSON1 is missing.
117
+ _warn_fallback_once()
118
+ sql = (
119
+ f"SELECT {columns} FROM action_outcomes "
120
+ f"WHERE profile_id = ? AND fact_ids_json LIKE ? "
121
+ f"{extra_where}"
122
+ )
123
+ params = (profile_id, f'%"{fact_id}"%', *extra_params)
124
+
125
+ cursor = conn.execute(sql, params)
126
+ return cursor.fetchall()
127
+
128
+
129
+ def has_recent_positive_reward(
130
+ conn: sqlite3.Connection,
131
+ profile_id: str,
132
+ fact_id: str,
133
+ *,
134
+ min_reward: float = 0.3,
135
+ window_days: int = 60,
136
+ ) -> bool:
137
+ """True if ``fact_id`` has any outcome with reward > ``min_reward``
138
+ settled in the last ``window_days`` days.
139
+ """
140
+ extra = (
141
+ "AND reward IS NOT NULL AND reward > ? "
142
+ f"AND COALESCE(settled_at, '') >= datetime('now', '-{int(window_days)} days') "
143
+ "LIMIT 1"
144
+ )
145
+ rows = iter_outcomes_for_fact(
146
+ conn, profile_id, fact_id,
147
+ columns="1",
148
+ extra_where=extra,
149
+ extra_params=(float(min_reward),),
150
+ )
151
+ return bool(rows)
152
+
153
+
154
+ def aggregate_reward_for_fact(
155
+ conn: sqlite3.Connection,
156
+ profile_id: str,
157
+ fact_id: str,
158
+ ) -> tuple[int, float]:
159
+ """Return ``(count, mean_reward)`` for a single fact_id.
160
+
161
+ Count is the number of outcomes with reward IS NOT NULL; mean is
162
+ ``AVG(reward)`` across that same subset. Returns ``(0, 0.0)`` when
163
+ the fact has no outcomes.
164
+ """
165
+ if not profile_id or not fact_id:
166
+ return 0, 0.0
167
+
168
+ if _json1_available(conn):
169
+ sql = (
170
+ "SELECT COUNT(*), AVG(reward) FROM action_outcomes "
171
+ "WHERE profile_id = ? "
172
+ " AND reward IS NOT NULL "
173
+ " AND EXISTS ("
174
+ " SELECT 1 FROM json_each(fact_ids_json) WHERE value = ?"
175
+ " )"
176
+ )
177
+ row = conn.execute(sql, (profile_id, fact_id)).fetchone()
178
+ else:
179
+ _warn_fallback_once()
180
+ sql = (
181
+ "SELECT COUNT(*), AVG(reward) FROM action_outcomes "
182
+ "WHERE profile_id = ? "
183
+ " AND reward IS NOT NULL "
184
+ " AND fact_ids_json LIKE ?"
185
+ )
186
+ row = conn.execute(sql, (profile_id, f'%"{fact_id}"%')).fetchone()
187
+
188
+ if row is None:
189
+ return 0, 0.0
190
+ count, mean = row
191
+ return int(count or 0), float(mean or 0.0)
192
+
193
+
194
+ _FALLBACK_WARNED = False
195
+
196
+
197
+ def _warn_fallback_once() -> None:
198
+ """Log the JSON1-missing fallback exactly once per process."""
199
+ global _FALLBACK_WARNED
200
+ if _FALLBACK_WARNED:
201
+ return
202
+ _FALLBACK_WARNED = True
203
+ logger.warning(
204
+ "fact_outcome_joins: SQLite JSON1 unavailable — falling back to "
205
+ "prefix-LIKE. Expect substring false positives on overlapping "
206
+ "fact_id prefixes. Upgrade SQLite to ≥3.38 for correct matches.",
207
+ )
@@ -306,10 +306,65 @@ class ForgettingScheduler:
306
306
  def _soft_delete_with_audit(self, fact_id: str, profile_id: str) -> None:
307
307
  """Soft-delete a forgotten fact with compliance audit trail.
308
308
 
309
+ v3.4.21 (LLD-12 §4): reward-gated. If the fact has any positive
310
+ reward (>0.3) in the last 60 days, it is considered "still
311
+ useful" and kept live — consolidation will retry next cycle.
312
+
309
313
  HR-04: Never physically deletes.
310
314
  """
315
+ if self._has_recent_positive_reward(fact_id, profile_id):
316
+ logger.debug(
317
+ "forgetting_scheduler: fact_id=%s kept live (recent reward)",
318
+ fact_id,
319
+ )
320
+ return
311
321
  logger.info(
312
322
  "Soft-deleting forgotten fact: fact_id=%s, profile_id=%s",
313
323
  fact_id, profile_id,
314
324
  )
315
325
  self._db.soft_delete_fact(fact_id, profile_id)
326
+
327
+ def _has_recent_positive_reward(
328
+ self, fact_id: str, profile_id: str,
329
+ ) -> bool:
330
+ """True if fact has an outcome_reward > 0.3 in the last 60 days.
331
+
332
+ v3.4.21 (Stage 8 H-06): routes through the JSON1-backed
333
+ ``fact_outcome_joins.has_recent_positive_reward`` helper —
334
+ eliminates the substring-LIKE false-positive class.
335
+
336
+ Resilient to schema drift: if ``action_outcomes`` or its columns
337
+ are unavailable we return False (no gating), preserving legacy
338
+ behaviour.
339
+ """
340
+ try:
341
+ # ``DatabaseManager`` is the owner of a persistent sqlite
342
+ # connection; the JSON1 helper needs a raw connection. We
343
+ # fall through to the legacy execute-path if the DB wrapper
344
+ # does not expose a ``.conn`` handle.
345
+ raw_conn = getattr(self._db, "conn", None) or getattr(
346
+ self._db, "_conn", None,
347
+ )
348
+ if raw_conn is not None:
349
+ from superlocalmemory.learning.fact_outcome_joins import (
350
+ has_recent_positive_reward,
351
+ )
352
+ return has_recent_positive_reward(
353
+ raw_conn, profile_id, fact_id,
354
+ min_reward=0.3, window_days=60,
355
+ )
356
+ # Fallback: use the DB wrapper with JSON1 SQL inline.
357
+ rows = self._db.execute(
358
+ "SELECT 1 FROM action_outcomes "
359
+ "WHERE profile_id = ? "
360
+ " AND reward IS NOT NULL AND reward > 0.3 "
361
+ " AND EXISTS ("
362
+ " SELECT 1 FROM json_each(fact_ids_json) WHERE value = ?"
363
+ " ) "
364
+ " AND COALESCE(settled_at, '') >= datetime('now', '-60 days') "
365
+ "LIMIT 1",
366
+ (profile_id, fact_id),
367
+ )
368
+ return bool(rows)
369
+ except Exception:
370
+ return False
@@ -0,0 +1,69 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — F4.A Stage-8 H-03/H-17/H-18 shim
4
+
5
+ """HNSW dedup + reward-gated archive + strong-memory boost — shim.
6
+
7
+ As of v3.4.21 (Stage 8 H-03/H-17/H-18 fixes), the 535-LOC god-module
8
+ was split into three cohesive files:
9
+
10
+ - ``dedup_hnsw.py`` — :class:`HnswDeduplicator` + fallback counter.
11
+ - ``reward_archive.py`` — :func:`run_reward_gated_archive`.
12
+ - ``reward_boost.py`` — :func:`apply_strong_memory_boost`,
13
+ :func:`select_high_reward_fact_ids`.
14
+
15
+ Outcome lookups that used to issue ``fact_ids_json LIKE`` now go
16
+ through :mod:`superlocalmemory.learning.fact_outcome_joins` which wraps
17
+ SQLite JSON1 so overlapping fact_id prefixes cannot collide (H-06).
18
+
19
+ This shim re-exports the original surface so that existing imports
20
+ continue to work unchanged.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+
27
+ from superlocalmemory.core.ram_lock import ram_reservation # noqa: F401
28
+
29
+ from superlocalmemory.learning.dedup_hnsw import ( # noqa: F401
30
+ HnswDeduplicator,
31
+ get_hnsw_degraded_count,
32
+ reset_hnsw_degraded_count,
33
+ _cosine,
34
+ _jaccard,
35
+ _parse_embedding,
36
+ _pick_canonical,
37
+ )
38
+ from superlocalmemory.learning.reward_archive import ( # noqa: F401
39
+ ARCHIVE_REWARD_THRESHOLD,
40
+ REWARD_WINDOW_DAYS,
41
+ run_reward_gated_archive,
42
+ )
43
+ from superlocalmemory.learning.reward_boost import ( # noqa: F401
44
+ STRONG_BOOST_CAP,
45
+ STRONG_BOOST_INCREMENT,
46
+ STRONG_BOOST_MIN_MEAN,
47
+ STRONG_BOOST_MIN_OUTCOMES,
48
+ apply_strong_memory_boost,
49
+ select_high_reward_fact_ids,
50
+ )
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ __all__ = (
56
+ "HnswDeduplicator",
57
+ "run_reward_gated_archive",
58
+ "apply_strong_memory_boost",
59
+ "select_high_reward_fact_ids",
60
+ "get_hnsw_degraded_count",
61
+ "reset_hnsw_degraded_count",
62
+ "REWARD_WINDOW_DAYS",
63
+ "ARCHIVE_REWARD_THRESHOLD",
64
+ "STRONG_BOOST_INCREMENT",
65
+ "STRONG_BOOST_CAP",
66
+ "STRONG_BOOST_MIN_OUTCOMES",
67
+ "STRONG_BOOST_MIN_MEAN",
68
+ "ram_reservation",
69
+ )