superlocalmemory 3.4.19 → 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.
- package/CHANGELOG.md +24 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +219 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- 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",)
|