superlocalmemory 3.2.2 → 3.3.0
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 +43 -1
- package/README.md +106 -71
- package/package.json +1 -2
- package/pyproject.toml +16 -1
- package/src/superlocalmemory/cli/commands.py +309 -0
- package/src/superlocalmemory/cli/main.py +44 -0
- package/src/superlocalmemory/core/config.py +282 -11
- package/src/superlocalmemory/core/consolidation_engine.py +37 -0
- package/src/superlocalmemory/core/engine.py +21 -0
- package/src/superlocalmemory/core/engine_wiring.py +58 -8
- package/src/superlocalmemory/dynamics/activation_guided_quantization.py +374 -0
- package/src/superlocalmemory/dynamics/eap_scheduler.py +276 -0
- package/src/superlocalmemory/dynamics/ebbinghaus_langevin_coupling.py +171 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +804 -0
- package/src/superlocalmemory/hooks/auto_invoker.py +46 -8
- package/src/superlocalmemory/hooks/auto_parameterize.py +147 -0
- package/src/superlocalmemory/infra/heartbeat_monitor.py +140 -0
- package/src/superlocalmemory/infra/pid_manager.py +193 -0
- package/src/superlocalmemory/infra/process_reaper.py +572 -0
- package/src/superlocalmemory/learning/consolidation_quantization_worker.py +115 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +263 -0
- package/src/superlocalmemory/learning/quantization_scheduler.py +320 -0
- package/src/superlocalmemory/math/ebbinghaus.py +309 -0
- package/src/superlocalmemory/math/fisher_quantized.py +251 -0
- package/src/superlocalmemory/math/hopfield.py +279 -0
- package/src/superlocalmemory/math/polar_quant.py +379 -0
- package/src/superlocalmemory/math/qjl.py +115 -0
- package/src/superlocalmemory/mcp/server.py +2 -0
- package/src/superlocalmemory/mcp/tools_v3.py +10 -0
- package/src/superlocalmemory/mcp/tools_v33.py +351 -0
- package/src/superlocalmemory/parameterization/__init__.py +47 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +534 -0
- package/src/superlocalmemory/parameterization/pii_filter.py +106 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +216 -0
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +275 -0
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +425 -0
- package/src/superlocalmemory/retrieval/engine.py +21 -3
- package/src/superlocalmemory/retrieval/forgetting_filter.py +145 -0
- package/src/superlocalmemory/retrieval/hopfield_channel.py +335 -0
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +133 -0
- package/src/superlocalmemory/retrieval/spreading_activation.py +1 -1
- package/src/superlocalmemory/retrieval/strategy.py +16 -6
- package/src/superlocalmemory/retrieval/vector_store.py +1 -1
- package/src/superlocalmemory/server/routes/agents.py +68 -8
- package/src/superlocalmemory/server/routes/learning.py +18 -1
- package/src/superlocalmemory/server/routes/lifecycle.py +36 -17
- package/src/superlocalmemory/server/routes/v3_api.py +503 -1
- package/src/superlocalmemory/storage/database.py +206 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +178 -0
- package/src/superlocalmemory/storage/migration_v33.py +140 -0
- package/src/superlocalmemory/storage/quantized_store.py +261 -0
- package/src/superlocalmemory/storage/schema_v32.py +137 -0
- package/conftest.py +0 -5
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Forgetting scheduler — periodic retention decay and lifecycle management.
|
|
6
|
+
|
|
7
|
+
Runs the Ebbinghaus decay cycle on all facts for a profile:
|
|
8
|
+
1. Fetches all facts with access counts, importance, and emotion data
|
|
9
|
+
2. Excludes core memory facts (HR-01: Core Memory NEVER forgets)
|
|
10
|
+
3. Computes retention scores via EbbinghausCurve
|
|
11
|
+
4. UPSERTs results into fact_retention table
|
|
12
|
+
5. Soft-deletes facts that fall below forget_threshold
|
|
13
|
+
|
|
14
|
+
Also handles real-time spaced repetition updates on access events.
|
|
15
|
+
|
|
16
|
+
HR-04: Soft-delete ONLY. Never physically deletes.
|
|
17
|
+
HR-08: Runs synchronously in main thread.
|
|
18
|
+
HR-09: Access events are idempotent.
|
|
19
|
+
|
|
20
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
21
|
+
License: MIT
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
from superlocalmemory.core.config import ForgettingConfig
|
|
33
|
+
from superlocalmemory.math.ebbinghaus import EbbinghausCurve
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ForgettingScheduler:
|
|
42
|
+
"""Periodic forgetting scheduler for memory lifecycle management.
|
|
43
|
+
|
|
44
|
+
Computes Ebbinghaus retention scores and manages memory lifecycle
|
|
45
|
+
transitions. Runs synchronously (HR-08).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
__slots__ = ("_db", "_ebbinghaus", "_config", "_last_run_times")
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
db: DatabaseManager,
|
|
53
|
+
ebbinghaus: EbbinghausCurve,
|
|
54
|
+
config: ForgettingConfig,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._db = db
|
|
57
|
+
self._ebbinghaus = ebbinghaus
|
|
58
|
+
self._config = config
|
|
59
|
+
# Track last run time per profile for interval enforcement
|
|
60
|
+
self._last_run_times: dict[str, float] = {}
|
|
61
|
+
|
|
62
|
+
def run_decay_cycle(
|
|
63
|
+
self, profile_id: str, *, force: bool = False,
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""Run a full decay cycle for all facts in a profile.
|
|
66
|
+
|
|
67
|
+
Fetches facts, computes retention, upserts results, and
|
|
68
|
+
soft-deletes forgotten facts.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
profile_id: Profile to process.
|
|
72
|
+
force: If True, bypass interval check.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Stats dict: {total, active, warm, cold, archive, forgotten,
|
|
76
|
+
transitions, skipped}.
|
|
77
|
+
"""
|
|
78
|
+
# Interval check (Test 21)
|
|
79
|
+
now = time.monotonic()
|
|
80
|
+
last_run = self._last_run_times.get(profile_id)
|
|
81
|
+
if not force and last_run is not None:
|
|
82
|
+
elapsed_minutes = (now - last_run) / 60.0
|
|
83
|
+
if elapsed_minutes < self._config.scheduler_interval_minutes:
|
|
84
|
+
return {"skipped": True, "reason": "within_interval"}
|
|
85
|
+
|
|
86
|
+
# Step 1: Fetch all facts with metadata
|
|
87
|
+
facts_data = self._fetch_facts_with_metadata(profile_id)
|
|
88
|
+
|
|
89
|
+
if not facts_data:
|
|
90
|
+
self._last_run_times[profile_id] = now
|
|
91
|
+
return {
|
|
92
|
+
"total": 0, "active": 0, "warm": 0, "cold": 0,
|
|
93
|
+
"archive": 0, "forgotten": 0, "transitions": 0,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Step 2: Get existing retention data for transition tracking
|
|
97
|
+
existing_zones = self._get_existing_zones(profile_id, facts_data)
|
|
98
|
+
|
|
99
|
+
# Step 3: Compute retention scores via EbbinghausCurve
|
|
100
|
+
retention_results = self._ebbinghaus.batch_compute_retention(facts_data)
|
|
101
|
+
|
|
102
|
+
# Step 4: Batch UPSERT into fact_retention
|
|
103
|
+
self._db.batch_upsert_retention(retention_results, profile_id)
|
|
104
|
+
|
|
105
|
+
# Step 5: Count zones and transitions
|
|
106
|
+
zone_counts = {"active": 0, "warm": 0, "cold": 0, "archive": 0, "forgotten": 0}
|
|
107
|
+
transitions = 0
|
|
108
|
+
forgotten_fact_ids: list[str] = []
|
|
109
|
+
|
|
110
|
+
for result in retention_results:
|
|
111
|
+
zone = result["zone"]
|
|
112
|
+
zone_counts[zone] = zone_counts.get(zone, 0) + 1
|
|
113
|
+
|
|
114
|
+
# Track transitions
|
|
115
|
+
old_zone = existing_zones.get(result["fact_id"])
|
|
116
|
+
if old_zone is not None and old_zone != zone:
|
|
117
|
+
transitions += 1
|
|
118
|
+
|
|
119
|
+
# Collect forgotten facts for soft-delete
|
|
120
|
+
if zone == "forgotten":
|
|
121
|
+
forgotten_fact_ids.append(result["fact_id"])
|
|
122
|
+
|
|
123
|
+
# Step 6: Soft-delete forgotten facts (HR-04)
|
|
124
|
+
for fact_id in forgotten_fact_ids:
|
|
125
|
+
self._soft_delete_with_audit(fact_id, profile_id)
|
|
126
|
+
|
|
127
|
+
# Update last run time
|
|
128
|
+
self._last_run_times[profile_id] = now
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"total": len(retention_results),
|
|
132
|
+
"active": zone_counts["active"],
|
|
133
|
+
"warm": zone_counts["warm"],
|
|
134
|
+
"cold": zone_counts["cold"],
|
|
135
|
+
"archive": zone_counts["archive"],
|
|
136
|
+
"forgotten": zone_counts["forgotten"],
|
|
137
|
+
"transitions": transitions,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def on_access_event(self, fact_id: str, profile_id: str) -> None:
|
|
141
|
+
"""Handle real-time access event with spaced repetition update.
|
|
142
|
+
|
|
143
|
+
Called from access_log.store_access() hook.
|
|
144
|
+
HR-09: Idempotent — reads current state, computes from scratch.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
fact_id: Accessed fact ID.
|
|
148
|
+
profile_id: Profile ID.
|
|
149
|
+
"""
|
|
150
|
+
# Fetch current retention data
|
|
151
|
+
current = self._db.get_retention(fact_id, profile_id)
|
|
152
|
+
if current is None:
|
|
153
|
+
# No retention data yet — nothing to update
|
|
154
|
+
logger.debug("on_access_event: no retention for %s, skipping", fact_id)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
current_strength = float(current["memory_strength"])
|
|
158
|
+
last_accessed = current.get("last_accessed_at", "")
|
|
159
|
+
|
|
160
|
+
# Compute hours since last access
|
|
161
|
+
hours_since = 0.0
|
|
162
|
+
if last_accessed:
|
|
163
|
+
try:
|
|
164
|
+
last_dt = datetime.fromisoformat(last_accessed)
|
|
165
|
+
if last_dt.tzinfo is None:
|
|
166
|
+
last_dt = last_dt.replace(tzinfo=UTC)
|
|
167
|
+
now_dt = datetime.now(UTC)
|
|
168
|
+
hours_since = max(0.0, (now_dt - last_dt).total_seconds() / 3600.0)
|
|
169
|
+
except (ValueError, TypeError):
|
|
170
|
+
hours_since = 0.0
|
|
171
|
+
|
|
172
|
+
# Spaced repetition update (HR-07: only increases strength)
|
|
173
|
+
new_strength = self._ebbinghaus.spaced_repetition_update(
|
|
174
|
+
current_strength, hours_since,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Recompute retention with new strength
|
|
178
|
+
new_retention = self._ebbinghaus.retention(0.0, new_strength)
|
|
179
|
+
new_zone = self._ebbinghaus.lifecycle_zone(new_retention)
|
|
180
|
+
|
|
181
|
+
now_iso = datetime.now(UTC).isoformat()
|
|
182
|
+
|
|
183
|
+
# UPSERT updated data
|
|
184
|
+
self._db.upsert_retention(
|
|
185
|
+
fact_id=fact_id,
|
|
186
|
+
profile_id=profile_id,
|
|
187
|
+
retention_score=new_retention,
|
|
188
|
+
memory_strength=new_strength,
|
|
189
|
+
access_count=int(current["access_count"]) + 1,
|
|
190
|
+
last_accessed_at=now_iso,
|
|
191
|
+
lifecycle_zone=new_zone,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
# Private helpers
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def _fetch_facts_with_metadata(self, profile_id: str) -> list[dict]:
|
|
199
|
+
"""Fetch all non-core-memory facts with access, importance, and emotion data.
|
|
200
|
+
|
|
201
|
+
Uses the query from LLD Section 2.4 with A-HIGH-02/03 fixes:
|
|
202
|
+
- confirmation_count mapped from atomic_facts.evidence_count
|
|
203
|
+
- emotional_salience from atomic_facts.emotional_valence
|
|
204
|
+
"""
|
|
205
|
+
rows = self._db.execute(
|
|
206
|
+
"SELECT f.fact_id, "
|
|
207
|
+
" COALESCE(al.access_count, 0) as access_count, "
|
|
208
|
+
" COALESCE(fi.pagerank_score, 0.0) as importance, "
|
|
209
|
+
" COALESCE(f.evidence_count, 0) as confirmation_count, "
|
|
210
|
+
" f.created_at, "
|
|
211
|
+
" COALESCE(r.last_accessed_at, f.created_at) as last_accessed_at, "
|
|
212
|
+
" COALESCE(f.emotional_valence, 0.0) as emotional_salience "
|
|
213
|
+
"FROM atomic_facts f "
|
|
214
|
+
"LEFT JOIN ("
|
|
215
|
+
" SELECT fact_id, COUNT(*) as access_count "
|
|
216
|
+
" FROM fact_access_log WHERE profile_id = ? GROUP BY fact_id"
|
|
217
|
+
") al ON f.fact_id = al.fact_id "
|
|
218
|
+
"LEFT JOIN fact_importance fi "
|
|
219
|
+
" ON f.fact_id = fi.fact_id AND fi.profile_id = ? "
|
|
220
|
+
"LEFT JOIN fact_retention r "
|
|
221
|
+
" ON f.fact_id = r.fact_id AND r.profile_id = ? "
|
|
222
|
+
"WHERE f.profile_id = ? "
|
|
223
|
+
"AND f.fact_id NOT IN ("
|
|
224
|
+
" SELECT json_each.value "
|
|
225
|
+
" FROM core_memory_blocks, json_each(core_memory_blocks.source_fact_ids) "
|
|
226
|
+
" WHERE core_memory_blocks.profile_id = ?"
|
|
227
|
+
")",
|
|
228
|
+
(profile_id, profile_id, profile_id, profile_id, profile_id),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
facts: list[dict] = []
|
|
232
|
+
for row in rows:
|
|
233
|
+
d = dict(row)
|
|
234
|
+
facts.append({
|
|
235
|
+
"fact_id": d["fact_id"],
|
|
236
|
+
"access_count": int(d["access_count"]),
|
|
237
|
+
"importance": float(d["importance"]),
|
|
238
|
+
"confirmation_count": int(d["confirmation_count"]),
|
|
239
|
+
"emotional_salience": float(d["emotional_salience"]),
|
|
240
|
+
"last_accessed_at": str(d["last_accessed_at"]),
|
|
241
|
+
})
|
|
242
|
+
return facts
|
|
243
|
+
|
|
244
|
+
def _get_existing_zones(
|
|
245
|
+
self, profile_id: str, facts_data: list[dict],
|
|
246
|
+
) -> dict[str, str]:
|
|
247
|
+
"""Get existing lifecycle zones for transition tracking."""
|
|
248
|
+
fact_ids = [f["fact_id"] for f in facts_data]
|
|
249
|
+
if not fact_ids:
|
|
250
|
+
return {}
|
|
251
|
+
retention_rows = self._db.batch_get_retention(fact_ids, profile_id)
|
|
252
|
+
return {r["fact_id"]: r["lifecycle_zone"] for r in retention_rows}
|
|
253
|
+
|
|
254
|
+
def _soft_delete_with_audit(self, fact_id: str, profile_id: str) -> None:
|
|
255
|
+
"""Soft-delete a forgotten fact with compliance audit trail.
|
|
256
|
+
|
|
257
|
+
HR-04: Never physically deletes.
|
|
258
|
+
"""
|
|
259
|
+
logger.info(
|
|
260
|
+
"Soft-deleting forgotten fact: fact_id=%s, profile_id=%s",
|
|
261
|
+
fact_id, profile_id,
|
|
262
|
+
)
|
|
263
|
+
self._db.soft_delete_fact(fact_id, profile_id)
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3
|
|
4
|
+
|
|
5
|
+
"""Quantization Scheduler -- combined SAGQ + EAP precision management.
|
|
6
|
+
|
|
7
|
+
Background quantization worker that periodically reviews all memories and
|
|
8
|
+
applies both EAP (forgetting) and SAGQ (network centrality) signals together.
|
|
9
|
+
|
|
10
|
+
Conflict resolution: max(EAP_precision, SAGQ_precision) -- safety first.
|
|
11
|
+
Core Memory blocks are immune to quantization (HR-01).
|
|
12
|
+
Every precision change is logged to fact_access_log for audit trail (HR-09).
|
|
13
|
+
|
|
14
|
+
HR-01: Core Memory immune.
|
|
15
|
+
HR-07: No-op when config.enabled=False.
|
|
16
|
+
HR-08: Synchronous execution (no threading).
|
|
17
|
+
HR-09: Audit trail for every change.
|
|
18
|
+
|
|
19
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
20
|
+
License: MIT
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from superlocalmemory.storage.models import _new_id
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
35
|
+
from superlocalmemory.dynamics.activation_guided_quantization import (
|
|
36
|
+
ActivationGuidedQuantizer,
|
|
37
|
+
SAGQPrecision,
|
|
38
|
+
)
|
|
39
|
+
from superlocalmemory.core.config import SAGQConfig
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Data classes (frozen -- all immutable, Rule 10)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class PrecisionChange:
|
|
51
|
+
"""Record of a single precision change for audit trail."""
|
|
52
|
+
|
|
53
|
+
fact_id: str
|
|
54
|
+
old_bit_width: int
|
|
55
|
+
new_bit_width: int
|
|
56
|
+
action: str # "upgrade" | "downgrade"
|
|
57
|
+
centrality: float
|
|
58
|
+
sagq_signal: int
|
|
59
|
+
eap_signal: int
|
|
60
|
+
timestamp: str # ISO 8601 datetime string
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class SchedulerRunResult:
|
|
65
|
+
"""Result of a single scheduler run."""
|
|
66
|
+
|
|
67
|
+
total_facts: int
|
|
68
|
+
upgrades: int
|
|
69
|
+
downgrades: int
|
|
70
|
+
skipped: int
|
|
71
|
+
errors: int
|
|
72
|
+
changes: tuple[PrecisionChange, ...]
|
|
73
|
+
duration_ms: float
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Bit-width -> quantization level mapping
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
_BW_TO_LEVEL: dict[int, str] = {
|
|
81
|
+
32: "float32",
|
|
82
|
+
8: "int8",
|
|
83
|
+
4: "polar4",
|
|
84
|
+
2: "polar2",
|
|
85
|
+
0: "deleted",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# QuantizationScheduler
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class QuantizationScheduler:
|
|
95
|
+
"""Combined SAGQ + EAP quantization scheduler.
|
|
96
|
+
|
|
97
|
+
Runs synchronously (HR-08). Called by the consolidation engine
|
|
98
|
+
or CLI command on schedule.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
db: Any,
|
|
104
|
+
sagq: Any,
|
|
105
|
+
eap_mapper: Callable[[str], int],
|
|
106
|
+
quantized_store: Any,
|
|
107
|
+
vector_store: Any,
|
|
108
|
+
config: Any,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Initialize scheduler. No side effects."""
|
|
111
|
+
self._db = db
|
|
112
|
+
self._sagq = sagq
|
|
113
|
+
self._eap_mapper = eap_mapper
|
|
114
|
+
self._quantized_store = quantized_store
|
|
115
|
+
self._vector_store = vector_store
|
|
116
|
+
self._config = config
|
|
117
|
+
|
|
118
|
+
def run(self, profile_id: str) -> SchedulerRunResult:
|
|
119
|
+
"""Execute one combined SAGQ + EAP quantization pass.
|
|
120
|
+
|
|
121
|
+
Algorithm:
|
|
122
|
+
1. Compute SAGQ precision recommendations (centrality + EAP + max())
|
|
123
|
+
2. Exclude core memory facts (HR-01)
|
|
124
|
+
3. Execute upgrades/downgrades with error isolation
|
|
125
|
+
4. Log audit trail for each change (HR-09)
|
|
126
|
+
5. Return summary
|
|
127
|
+
|
|
128
|
+
Returns SchedulerRunResult with totals and change records.
|
|
129
|
+
"""
|
|
130
|
+
# HR-07: No-op when disabled
|
|
131
|
+
if not self._config.enabled:
|
|
132
|
+
return SchedulerRunResult(
|
|
133
|
+
total_facts=0, upgrades=0, downgrades=0,
|
|
134
|
+
skipped=0, errors=0, changes=(), duration_ms=0.0,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
start_time = time.monotonic()
|
|
138
|
+
|
|
139
|
+
# Step 2: Get SAGQ precision recommendations
|
|
140
|
+
recommendations = self._sagq.compute_sagq_precision_batch(
|
|
141
|
+
profile_id, self._eap_mapper,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if not recommendations:
|
|
145
|
+
duration_ms = (time.monotonic() - start_time) * 1000
|
|
146
|
+
return SchedulerRunResult(
|
|
147
|
+
total_facts=0, upgrades=0, downgrades=0,
|
|
148
|
+
skipped=0, errors=0, changes=(), duration_ms=duration_ms,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Step 5a: Get core memory fact IDs (HR-01)
|
|
152
|
+
core_fact_ids = self._get_core_fact_ids(profile_id)
|
|
153
|
+
|
|
154
|
+
# Step 6: Process each recommendation
|
|
155
|
+
upgrades = 0
|
|
156
|
+
downgrades = 0
|
|
157
|
+
skipped = 0
|
|
158
|
+
errors = 0
|
|
159
|
+
changes: list[PrecisionChange] = []
|
|
160
|
+
|
|
161
|
+
for prec in recommendations:
|
|
162
|
+
# HR-01: Core Memory immune
|
|
163
|
+
if prec.fact_id in core_fact_ids:
|
|
164
|
+
skipped += 1
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if prec.action == "skip":
|
|
168
|
+
skipped += 1
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
change = self._process_precision_change(prec, profile_id)
|
|
172
|
+
|
|
173
|
+
if change is None:
|
|
174
|
+
# Error or unable to process
|
|
175
|
+
if prec.action in ("downgrade", "upgrade"):
|
|
176
|
+
errors += 1
|
|
177
|
+
else:
|
|
178
|
+
skipped += 1
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
if change.action == "downgrade":
|
|
182
|
+
downgrades += 1
|
|
183
|
+
elif change.action == "upgrade":
|
|
184
|
+
upgrades += 1
|
|
185
|
+
|
|
186
|
+
changes.append(change)
|
|
187
|
+
|
|
188
|
+
duration_ms = (time.monotonic() - start_time) * 1000
|
|
189
|
+
|
|
190
|
+
logger.info(
|
|
191
|
+
"SAGQ scheduler: %d upgrades, %d downgrades, %d skipped, "
|
|
192
|
+
"%d errors in %.1fms",
|
|
193
|
+
upgrades, downgrades, skipped, errors, duration_ms,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return SchedulerRunResult(
|
|
197
|
+
total_facts=len(recommendations),
|
|
198
|
+
upgrades=upgrades,
|
|
199
|
+
downgrades=downgrades,
|
|
200
|
+
skipped=skipped,
|
|
201
|
+
errors=errors,
|
|
202
|
+
changes=tuple(changes),
|
|
203
|
+
duration_ms=duration_ms,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def _process_precision_change(
|
|
207
|
+
self, prec: Any, profile_id: str,
|
|
208
|
+
) -> PrecisionChange | None:
|
|
209
|
+
"""Process a single precision change with error isolation.
|
|
210
|
+
|
|
211
|
+
Each fact is independent -- one failure does not block others.
|
|
212
|
+
Returns PrecisionChange on success, None on failure.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
if prec.action == "downgrade":
|
|
216
|
+
# Fetch float32 embedding
|
|
217
|
+
emb = self._vector_store.get_embedding(prec.fact_id, profile_id)
|
|
218
|
+
if emb is None:
|
|
219
|
+
logger.warning(
|
|
220
|
+
"SAGQ: No float32 for %s, skip downgrade", prec.fact_id,
|
|
221
|
+
)
|
|
222
|
+
return None
|
|
223
|
+
# Compress to target bit-width
|
|
224
|
+
self._quantized_store.compress_fact(
|
|
225
|
+
prec.fact_id, profile_id, emb, prec.final_bit_width,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
elif prec.action == "upgrade":
|
|
229
|
+
# Upgrade = re-compress at higher bit_width from float32 backup
|
|
230
|
+
emb = self._vector_store.get_embedding(prec.fact_id, profile_id)
|
|
231
|
+
if emb is None:
|
|
232
|
+
logger.warning(
|
|
233
|
+
"SAGQ: No float32 for %s, skip upgrade", prec.fact_id,
|
|
234
|
+
)
|
|
235
|
+
return None
|
|
236
|
+
self._quantized_store.compress_fact(
|
|
237
|
+
prec.fact_id, profile_id, emb, prec.final_bit_width,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
else:
|
|
241
|
+
return None # skip
|
|
242
|
+
|
|
243
|
+
# Update embedding_metadata (Q7)
|
|
244
|
+
level = self._bit_width_to_quantization_level(prec.final_bit_width)
|
|
245
|
+
self._db.execute(
|
|
246
|
+
"UPDATE embedding_metadata "
|
|
247
|
+
"SET bit_width = ?, quantization_level = ? "
|
|
248
|
+
"WHERE fact_id = ? AND profile_id = ?",
|
|
249
|
+
(prec.final_bit_width, level, prec.fact_id, profile_id),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Audit trail (Q10 -- HR-09)
|
|
253
|
+
self._db.execute(
|
|
254
|
+
"INSERT INTO fact_access_log "
|
|
255
|
+
"(log_id, fact_id, profile_id, accessed_at, access_type, session_id) "
|
|
256
|
+
"VALUES (?, ?, ?, datetime('now'), 'consolidation', 'sagq_scheduler')",
|
|
257
|
+
(_new_id(), prec.fact_id, profile_id),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
now_iso = datetime.now(UTC).isoformat()
|
|
261
|
+
return PrecisionChange(
|
|
262
|
+
fact_id=prec.fact_id,
|
|
263
|
+
old_bit_width=prec.current_bit_width,
|
|
264
|
+
new_bit_width=prec.final_bit_width,
|
|
265
|
+
action=prec.action,
|
|
266
|
+
centrality=prec.centrality,
|
|
267
|
+
sagq_signal=prec.sagq_bit_width,
|
|
268
|
+
eap_signal=prec.eap_bit_width,
|
|
269
|
+
timestamp=now_iso,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
logger.error(
|
|
274
|
+
"SAGQ: precision change failed for %s: %s", prec.fact_id, exc,
|
|
275
|
+
)
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def _get_core_fact_ids(self, profile_id: str) -> set[str]:
|
|
279
|
+
"""Get fact IDs referenced by core_memory_blocks (immune to quantization).
|
|
280
|
+
|
|
281
|
+
Uses json_each() to extract from source_fact_ids JSON array (Q8).
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
rows = self._db.execute(
|
|
285
|
+
"SELECT json_each.value as fact_id "
|
|
286
|
+
"FROM core_memory_blocks, json_each(core_memory_blocks.source_fact_ids) "
|
|
287
|
+
"WHERE core_memory_blocks.profile_id = ?",
|
|
288
|
+
(profile_id,),
|
|
289
|
+
)
|
|
290
|
+
return {dict(r)["fact_id"] for r in rows}
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
logger.debug("SAGQ: core_memory_blocks query failed: %s", exc)
|
|
293
|
+
return set()
|
|
294
|
+
|
|
295
|
+
def _bit_width_to_quantization_level(self, bit_width: int) -> str:
|
|
296
|
+
"""Map bit-width integer to quantization level string."""
|
|
297
|
+
return _BW_TO_LEVEL.get(bit_width, "float32")
|
|
298
|
+
|
|
299
|
+
def should_run(self, last_run_at: str | None) -> bool:
|
|
300
|
+
"""Check if enough time has passed since the last run.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
last_run_at: ISO 8601 datetime of last run, or None if never run.
|
|
304
|
+
|
|
305
|
+
Returns True if the scheduler should run now.
|
|
306
|
+
"""
|
|
307
|
+
if last_run_at is None:
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
last_run = datetime.fromisoformat(last_run_at)
|
|
312
|
+
now = datetime.now(UTC)
|
|
313
|
+
# Ensure both are timezone-aware for subtraction
|
|
314
|
+
if last_run.tzinfo is None:
|
|
315
|
+
last_run = last_run.replace(tzinfo=UTC)
|
|
316
|
+
hours_since = (now - last_run).total_seconds() / 3600
|
|
317
|
+
return hours_since >= self._config.scheduler_interval_hours
|
|
318
|
+
except (ValueError, TypeError) as exc:
|
|
319
|
+
logger.warning("SAGQ: Could not parse last_run_at '%s': %s", last_run_at, exc)
|
|
320
|
+
return True
|