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,269 @@
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-02 §4.4
4
+
5
+ """Active-model cache + integrity verification.
6
+
7
+ LLD reference: ``.backup/active-brain/lld/LLD-02-signal-pipeline-and-lightgbm.md``
8
+ Section 4.4 — every model load goes through here.
9
+
10
+ Hard rules enforced:
11
+ M1 — ``pickle.loads`` is FORBIDDEN on ``state_bytes``.
12
+ M2 — SHA-256 verified before ``Booster(model_str=...)``.
13
+ M3 — Feature-name drift is logged, not silently ignored.
14
+
15
+ Cache: LRU size=4 keyed by ``profile_id``. Thread-safe.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import threading
23
+ from collections import OrderedDict
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from superlocalmemory.core.security_primitives import (
29
+ IntegrityError,
30
+ verify_sha256,
31
+ )
32
+ from superlocalmemory.learning.features import FEATURE_NAMES
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ # ``tuple[str, ...]`` for immutability — matches LLD-02 §4.4.
38
+ _CURRENT_FEATURE_NAMES: tuple[str, ...] = tuple(FEATURE_NAMES)
39
+
40
+ _CACHE_MAX = 4
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ActiveModel:
45
+ """Verified, in-memory booster plus provenance."""
46
+
47
+ profile_id: str
48
+ booster: Any # lightgbm.Booster — Any keeps this import-light
49
+ feature_names: tuple[str, ...]
50
+ trained_at: str
51
+ sha256: str
52
+
53
+
54
+ class _LRU:
55
+ """Tiny thread-safe LRU for ``ActiveModel | None`` entries.
56
+
57
+ Values of ``None`` (no active model) are cached too so we don't SELECT
58
+ on every recall when there's nothing to load.
59
+ """
60
+
61
+ def __init__(self, maxsize: int) -> None:
62
+ self._maxsize = maxsize
63
+ self._data: "OrderedDict[str, ActiveModel | None]" = OrderedDict()
64
+ self._lock = threading.Lock()
65
+
66
+ def get(self, key: str) -> tuple[bool, ActiveModel | None]:
67
+ """Return (hit, value) — hit=False means cache miss."""
68
+ with self._lock:
69
+ if key in self._data:
70
+ self._data.move_to_end(key)
71
+ return True, self._data[key]
72
+ return False, None
73
+
74
+ def set(self, key: str, value: ActiveModel | None) -> None:
75
+ with self._lock:
76
+ if key in self._data:
77
+ self._data.move_to_end(key)
78
+ self._data[key] = value
79
+ while len(self._data) > self._maxsize:
80
+ self._data.popitem(last=False)
81
+
82
+ def invalidate(self, key: str) -> None:
83
+ with self._lock:
84
+ self._data.pop(key, None)
85
+
86
+ def clear(self) -> None:
87
+ with self._lock:
88
+ self._data.clear()
89
+
90
+
91
+ _MODEL_CACHE = _LRU(_CACHE_MAX)
92
+
93
+ # Serialise concurrent cache-miss loads per profile so N threads only read
94
+ # from disk once (RP2 in LLD-02 §4.3).
95
+ _load_locks_lock = threading.Lock()
96
+ _load_locks: dict[str, threading.Lock] = {}
97
+
98
+
99
+ def _get_load_lock(profile_id: str) -> threading.Lock:
100
+ with _load_locks_lock:
101
+ lock = _load_locks.get(profile_id)
102
+ if lock is None:
103
+ lock = threading.Lock()
104
+ _load_locks[profile_id] = lock
105
+ return lock
106
+
107
+
108
+ def invalidate(profile_id: str | None = None) -> None:
109
+ """Drop a profile (or all) from the cache — used on shutdown + tests."""
110
+ if profile_id is None:
111
+ _MODEL_CACHE.clear()
112
+ return
113
+ _MODEL_CACHE.invalidate(profile_id)
114
+
115
+
116
+ def load_active(
117
+ db: Any, profile_id: str,
118
+ *,
119
+ use_cache: bool = True,
120
+ ) -> ActiveModel | None:
121
+ """Load the active model for ``profile_id`` with integrity verification.
122
+
123
+ Args:
124
+ db: A ``LearningDatabase`` instance that exposes
125
+ ``load_active_model(profile_id)`` returning a dict with keys
126
+ ``state_bytes``, ``bytes_sha256``, ``feature_names`` (JSON str),
127
+ and ``trained_at``. None if no active row.
128
+ profile_id: Profile key.
129
+ use_cache: If False, bypasses the LRU — used in tests.
130
+
131
+ Returns:
132
+ An ``ActiveModel`` on success, or ``None`` when no active row,
133
+ integrity check fails, or lightgbm is unavailable.
134
+ """
135
+ if use_cache:
136
+ hit, value = _MODEL_CACHE.get(profile_id)
137
+ if hit:
138
+ return value
139
+
140
+ lock = _get_load_lock(profile_id)
141
+ with lock:
142
+ # Double-checked: another thread may have populated the cache.
143
+ if use_cache:
144
+ hit, value = _MODEL_CACHE.get(profile_id)
145
+ if hit:
146
+ return value
147
+
148
+ try:
149
+ row = db.load_active_model(profile_id)
150
+ except Exception as exc:
151
+ logger.warning("model_cache: load_active_model raised: %s", exc)
152
+ if use_cache: # pragma: no cover — covered by two paths in tests
153
+ _MODEL_CACHE.set(profile_id, None)
154
+ return None
155
+
156
+ if row is None:
157
+ if use_cache:
158
+ _MODEL_CACHE.set(profile_id, None)
159
+ return None
160
+
161
+ model = _parse_row(profile_id, row)
162
+ # Tombstone (write-back None) on integrity/parse failure so we don't
163
+ # retry hot. Dashboard phase computation relies on this.
164
+ if use_cache:
165
+ _MODEL_CACHE.set(profile_id, model)
166
+ return model
167
+
168
+
169
+ def _parse_row(profile_id: str, row: dict) -> ActiveModel | None:
170
+ """Verify + deserialise a single model row. Never raises."""
171
+ state_bytes = row.get("state_bytes")
172
+ sha_hex = row.get("bytes_sha256") or ""
173
+ feature_names_json = row.get("feature_names") or "[]"
174
+ trained_at = row.get("trained_at") or ""
175
+
176
+ if not state_bytes:
177
+ logger.warning("model_cache: empty state_bytes for %s", profile_id)
178
+ return None
179
+ if not isinstance(state_bytes, (bytes, bytearray)):
180
+ # Some drivers return buffer-like; coerce once.
181
+ try:
182
+ state_bytes = bytes(state_bytes)
183
+ except Exception as exc: # pragma: no cover — defensive
184
+ logger.error("model_cache: cannot coerce state_bytes: %s", exc)
185
+ return None
186
+
187
+ # M2: SHA-256 verify BEFORE touching LightGBM.
188
+ try:
189
+ verify_sha256(bytes(state_bytes), sha_hex)
190
+ except IntegrityError as exc:
191
+ logger.critical(
192
+ "model_cache: SHA-256 mismatch for %s → tombstone: %s",
193
+ profile_id, exc,
194
+ )
195
+ return None
196
+
197
+ # Parse feature_names for drift reporting (M3).
198
+ try:
199
+ names = tuple(json.loads(feature_names_json))
200
+ except (ValueError, TypeError) as exc:
201
+ logger.warning(
202
+ "model_cache: bad feature_names JSON for %s: %s",
203
+ profile_id, exc,
204
+ )
205
+ names = ()
206
+
207
+ if names and names != _CURRENT_FEATURE_NAMES:
208
+ logger.info(
209
+ "feature-drift: active model for %s has %d names; current has %d",
210
+ profile_id, len(names), len(_CURRENT_FEATURE_NAMES),
211
+ )
212
+
213
+ # Native LightGBM — NOT pickle (M1).
214
+ try:
215
+ import lightgbm as lgb # noqa: PLC0415 — optional dep
216
+ except ImportError: # pragma: no cover — optional dep
217
+ logger.info(
218
+ "model_cache: lightgbm unavailable; phase 3 disabled for %s",
219
+ profile_id,
220
+ )
221
+ return None
222
+
223
+ try:
224
+ booster = lgb.Booster(model_str=bytes(state_bytes).decode("utf-8"))
225
+ except Exception as exc: # pragma: no cover — corrupt decode
226
+ logger.critical(
227
+ "model_cache: Booster parse failed for %s → tombstone: %s",
228
+ profile_id, exc,
229
+ )
230
+ return None
231
+
232
+ return ActiveModel(
233
+ profile_id=profile_id,
234
+ booster=booster,
235
+ feature_names=names or _CURRENT_FEATURE_NAMES,
236
+ trained_at=str(trained_at),
237
+ sha256=sha_hex.lower(),
238
+ )
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Feature-drift policy (M3 — LLD-02 §4.5)
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def drift_mode(model: ActiveModel) -> str:
247
+ """Classify feature-name drift for a loaded model.
248
+
249
+ Returns one of:
250
+ ``"aligned"`` — names match FEATURE_NAMES exactly.
251
+ ``"subset"`` — active names ⊆ current → pad zeros at inference.
252
+ ``"unknown"`` — active names have entries not in current → refuse.
253
+ """
254
+ active = tuple(model.feature_names)
255
+ current = _CURRENT_FEATURE_NAMES
256
+ if active == current:
257
+ return "aligned"
258
+ current_set = set(current)
259
+ if all(n in current_set for n in active):
260
+ return "subset"
261
+ return "unknown"
262
+
263
+
264
+ __all__ = (
265
+ "ActiveModel",
266
+ "load_active",
267
+ "invalidate",
268
+ "drift_mode",
269
+ )
@@ -0,0 +1,278 @@
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 — Track A.3 (LLD-10 §5)
4
+
5
+ """Post-promotion watch + auto-rollback (LLD-10 §5).
6
+
7
+ After promotion flips ``is_active`` to the new row, the next 200
8
+ recalls are measured against the pre-promotion baseline. If mean
9
+ NDCG@10 drops by ≥ REGRESSION_THRESHOLD (2%), auto-rollback fires:
10
+
11
+ * Current active → ``is_rollback=1``, ``is_active=0``,
12
+ ``rollback_reason=<reason>``.
13
+ * Former ``is_previous`` → ``is_active=1``, ``is_previous=0``.
14
+ * ``metadata_json.retrain_disabled_until`` set to now+24h; counter
15
+ reset to 0.
16
+
17
+ All three flag flips happen inside one ``BEGIN IMMEDIATE`` transaction.
18
+ The partial unique indexes ``idx_model_active_one`` and
19
+ ``idx_model_candidate_one`` (M009) enforce single-active and
20
+ single-candidate per profile.
21
+
22
+ Failure-mode handling (LLD-10 §5.4 "missing is_previous"):
23
+ If the is_previous row is absent, we do NOT demote the current
24
+ active — that would leave the profile with no active model.
25
+ Instead we log an error, set ``metadata_json.safe_mode=1`` on the
26
+ active row, and let AdaptiveRanker fall back to Phase-2 heuristic.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import sqlite3
34
+ from datetime import datetime, timedelta, timezone
35
+ from typing import Final
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ _WATCH_WINDOW: Final[int] = 200
41
+ _REGRESSION_THRESHOLD: Final[float] = 0.02
42
+ _RETRAIN_DISABLED_HOURS: Final[int] = 24
43
+
44
+ #: Baseline floor below which the ratio ``(baseline - current) / baseline``
45
+ #: is numerically meaningless (division explodes; a 1pp drop on a 0.5%
46
+ #: baseline looks like 200% regression). Below this floor we switch to
47
+ #: an ABSOLUTE-drop comparison against ``_REGRESSION_THRESHOLD``. Stage 8
48
+ #: F4.B H-04 fix — previously baseline ≤ 0 silently disarmed the
49
+ #: watchdog, which meant a freshly-promoted-but-broken model with a
50
+ #: sparse-data baseline of 0 would never auto-rollback.
51
+ _BASELINE_RATIO_FLOOR: Final[float] = 0.05
52
+
53
+
54
+ def _iso_now() -> str:
55
+ return datetime.now(timezone.utc).isoformat()
56
+
57
+
58
+ def _iso_in_hours(hours: int) -> str:
59
+ return (
60
+ datetime.now(timezone.utc) + timedelta(hours=hours)
61
+ ).isoformat()
62
+
63
+
64
+ class ModelRollback:
65
+ """Watch post-promotion NDCG@10 and flip lineage on regression."""
66
+
67
+ WATCH_WINDOW: Final[int] = _WATCH_WINDOW
68
+ REGRESSION_THRESHOLD: Final[float] = _REGRESSION_THRESHOLD
69
+ RETRAIN_DISABLED_HOURS: Final[int] = _RETRAIN_DISABLED_HOURS
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ learning_db_path: str,
75
+ profile_id: str,
76
+ baseline_ndcg: float,
77
+ ) -> None:
78
+ self._db = str(learning_db_path)
79
+ self._profile_id = profile_id
80
+ self._baseline = float(baseline_ndcg)
81
+ self._observations: list[float] = []
82
+
83
+ # ------------------------------------------------------------------
84
+ # Observation ingestion
85
+ # ------------------------------------------------------------------
86
+
87
+ def record_post_promotion(
88
+ self, *, query_id: str, ndcg_at_10: float,
89
+ ) -> None:
90
+ """Record one post-promotion NDCG@10 sample (at most WATCH_WINDOW)."""
91
+ if len(self._observations) >= self.WATCH_WINDOW:
92
+ return
93
+ self._observations.append(float(ndcg_at_10))
94
+
95
+ def should_rollback(self) -> bool:
96
+ """Return True iff ≥ WATCH_WINDOW samples and regression detected.
97
+
98
+ Regression is detected by whichever of these is true:
99
+ * **Ratio path** (baseline ≥ ``_BASELINE_RATIO_FLOOR``, i.e. 0.05):
100
+ ``(baseline - current) / baseline ≥ REGRESSION_THRESHOLD``.
101
+ Existing v3.4.21 pre-fix semantics preserved at normal
102
+ baselines.
103
+ * **Absolute path** (baseline below the ratio floor — includes
104
+ zero and negative baselines which can happen on sparse data):
105
+ ``(baseline - current) ≥ REGRESSION_THRESHOLD`` in absolute
106
+ units. Stage 8 F4.B H-04 fix — a zero baseline is a valid
107
+ observation, not "no baseline".
108
+
109
+ Invariants:
110
+ * Watch window minimum is always enforced — we will NOT fire
111
+ before ``WATCH_WINDOW`` samples land.
112
+ * ``REGRESSION_THRESHOLD`` is the same 0.02 for both paths, so
113
+ the fix is not stricter for typical baselines.
114
+ """
115
+ if len(self._observations) < self.WATCH_WINDOW:
116
+ return False
117
+ current = sum(self._observations) / len(self._observations)
118
+ drop_abs = self._baseline - current
119
+ # S9-SKEP-10: smooth the ratio/absolute boundary.
120
+ #
121
+ # Before this fix there was a step discontinuity at
122
+ # ``baseline == _BASELINE_RATIO_FLOOR`` (0.05):
123
+ # * baseline=0.049 → abs path fires at drop≥0.02
124
+ # * baseline=0.050 → ratio path fires at drop≥0.001 (0.02*0.05)
125
+ # So a 0.001 baseline drift produced a 20× sensitivity change,
126
+ # and new-user profiles near 0.05 oscillated between "never
127
+ # rollback" and "rollback on any noise". We now linearly blend
128
+ # the two thresholds across a ±`_BLEND_BAND` neighbourhood
129
+ # around the floor, so the derivative of the threshold with
130
+ # respect to baseline stays bounded.
131
+ #
132
+ # Asymptotic equivalence:
133
+ # baseline ≥ floor + band → pure ratio (prior behaviour).
134
+ # baseline ≤ floor - band → pure absolute (prior behaviour).
135
+ _BLEND_BAND = 0.02
136
+ hi = _BASELINE_RATIO_FLOOR + _BLEND_BAND
137
+ lo = max(0.0, _BASELINE_RATIO_FLOOR - _BLEND_BAND)
138
+ # The "drop needed to fire" in each regime.
139
+ # ratio regime: drop ≥ baseline * REGRESSION_THRESHOLD
140
+ # abs regime: drop ≥ REGRESSION_THRESHOLD
141
+ ratio_drop = self._baseline * self.REGRESSION_THRESHOLD
142
+ abs_drop = self.REGRESSION_THRESHOLD
143
+ if self._baseline >= hi:
144
+ needed = ratio_drop
145
+ elif self._baseline <= lo:
146
+ needed = abs_drop
147
+ else:
148
+ # Linear blend — at baseline=lo weight=0, at baseline=hi weight=1.
149
+ weight = (self._baseline - lo) / (hi - lo)
150
+ needed = weight * ratio_drop + (1.0 - weight) * abs_drop
151
+ return drop_abs >= needed
152
+
153
+ # ------------------------------------------------------------------
154
+ # Lineage flip
155
+ # ------------------------------------------------------------------
156
+
157
+ def execute_rollback(self, reason: str) -> bool:
158
+ """Flip lineage atomically — return True on success.
159
+
160
+ Transaction shape (all under one BEGIN IMMEDIATE):
161
+ 1. Confirm an ``is_previous=1`` row exists for profile.
162
+ If missing → abort, set ``safe_mode`` on active, return False.
163
+ 2. Current active row: set ``is_active=0, is_rollback=1,
164
+ rollback_reason=?``.
165
+ 3. Previous row: set ``is_active=1, is_previous=0``.
166
+ 4. Patch ``metadata_json`` on the new active with
167
+ ``retrain_disabled_until`` (+24h) and
168
+ ``last_rollback_at``, counter reset.
169
+ """
170
+ with sqlite3.connect(self._db, timeout=10) as conn:
171
+ conn.row_factory = sqlite3.Row
172
+ try:
173
+ conn.execute("BEGIN IMMEDIATE")
174
+ prev = conn.execute(
175
+ "SELECT id FROM learning_model_state "
176
+ "WHERE profile_id = ? AND is_previous = 1 LIMIT 1",
177
+ (self._profile_id,),
178
+ ).fetchone()
179
+ if prev is None:
180
+ conn.rollback()
181
+ self._set_safe_mode(conn)
182
+ logger.error(
183
+ "rollback: missing is_previous row for profile=%s "
184
+ "reason=%s — entering safe_mode",
185
+ self._profile_id, reason,
186
+ )
187
+ return False
188
+
189
+ # Step 2 — demote current active (unset is_active FIRST so
190
+ # the partial unique index on is_active=1 never has two rows).
191
+ conn.execute(
192
+ "UPDATE learning_model_state "
193
+ "SET is_active = 0, is_rollback = 1, "
194
+ " rollback_reason = ? "
195
+ "WHERE profile_id = ? AND is_active = 1",
196
+ (reason, self._profile_id),
197
+ )
198
+
199
+ # Step 3 — promote previous to active.
200
+ conn.execute(
201
+ "UPDATE learning_model_state "
202
+ "SET is_active = 1, is_previous = 0 "
203
+ "WHERE id = ?",
204
+ (prev["id"],),
205
+ )
206
+
207
+ # Step 4 — patch metadata on the new active.
208
+ new_meta_row = conn.execute(
209
+ "SELECT metadata_json FROM learning_model_state "
210
+ "WHERE id = ?",
211
+ (prev["id"],),
212
+ ).fetchone()
213
+ try:
214
+ meta = json.loads(new_meta_row["metadata_json"] or "{}")
215
+ except (TypeError, ValueError):
216
+ meta = {}
217
+ meta.update({
218
+ "retrain_disabled_until": _iso_in_hours(
219
+ self.RETRAIN_DISABLED_HOURS,
220
+ ),
221
+ "last_rollback_at": _iso_now(),
222
+ "new_outcomes_since_last_retrain": 0,
223
+ })
224
+ conn.execute(
225
+ "UPDATE learning_model_state "
226
+ "SET metadata_json = ? WHERE id = ?",
227
+ (json.dumps(meta), prev["id"]),
228
+ )
229
+ conn.commit()
230
+ except sqlite3.Error as exc:
231
+ conn.rollback()
232
+ logger.error(
233
+ "rollback: sqlite error profile=%s reason=%s: %s",
234
+ self._profile_id, reason, exc,
235
+ )
236
+ return False
237
+
238
+ logger.warning(
239
+ "AUTO-ROLLBACK profile=%s reason=%s observations=%d "
240
+ "baseline_ndcg=%.4f current_ndcg=%.4f",
241
+ self._profile_id, reason, len(self._observations),
242
+ self._baseline,
243
+ (sum(self._observations) / len(self._observations))
244
+ if self._observations else 0.0,
245
+ )
246
+ return True
247
+
248
+ # ------------------------------------------------------------------
249
+ # Safe-mode helper (is_previous missing)
250
+ # ------------------------------------------------------------------
251
+
252
+ def _set_safe_mode(self, conn: sqlite3.Connection) -> None:
253
+ try:
254
+ row = conn.execute(
255
+ "SELECT metadata_json FROM learning_model_state "
256
+ "WHERE profile_id = ? AND is_active = 1",
257
+ (self._profile_id,),
258
+ ).fetchone()
259
+ meta: dict
260
+ if row is None:
261
+ meta = {"safe_mode": 1}
262
+ else:
263
+ try:
264
+ meta = json.loads(row["metadata_json"] or "{}")
265
+ except (TypeError, ValueError):
266
+ meta = {}
267
+ meta["safe_mode"] = 1
268
+ conn.execute(
269
+ "UPDATE learning_model_state SET metadata_json = ? "
270
+ "WHERE profile_id = ? AND is_active = 1",
271
+ (json.dumps(meta), self._profile_id),
272
+ )
273
+ conn.commit()
274
+ except sqlite3.Error as exc: # pragma: no cover — defensive
275
+ logger.debug("safe_mode set failed: %s", exc)
276
+
277
+
278
+ __all__ = ("ModelRollback",)