superlocalmemory 3.2.3 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +106 -71
  3. package/package.json +1 -2
  4. package/pyproject.toml +16 -1
  5. package/src/superlocalmemory/cli/commands.py +309 -0
  6. package/src/superlocalmemory/cli/main.py +44 -0
  7. package/src/superlocalmemory/core/config.py +276 -4
  8. package/src/superlocalmemory/core/consolidation_engine.py +37 -0
  9. package/src/superlocalmemory/core/engine.py +21 -0
  10. package/src/superlocalmemory/core/engine_wiring.py +58 -8
  11. package/src/superlocalmemory/dynamics/activation_guided_quantization.py +374 -0
  12. package/src/superlocalmemory/dynamics/eap_scheduler.py +276 -0
  13. package/src/superlocalmemory/dynamics/ebbinghaus_langevin_coupling.py +171 -0
  14. package/src/superlocalmemory/encoding/cognitive_consolidator.py +804 -0
  15. package/src/superlocalmemory/hooks/auto_invoker.py +46 -8
  16. package/src/superlocalmemory/hooks/auto_parameterize.py +147 -0
  17. package/src/superlocalmemory/infra/heartbeat_monitor.py +140 -0
  18. package/src/superlocalmemory/infra/pid_manager.py +193 -0
  19. package/src/superlocalmemory/infra/process_reaper.py +572 -0
  20. package/src/superlocalmemory/learning/consolidation_quantization_worker.py +115 -0
  21. package/src/superlocalmemory/learning/forgetting_scheduler.py +263 -0
  22. package/src/superlocalmemory/learning/quantization_scheduler.py +320 -0
  23. package/src/superlocalmemory/math/ebbinghaus.py +309 -0
  24. package/src/superlocalmemory/math/fisher_quantized.py +251 -0
  25. package/src/superlocalmemory/math/hopfield.py +279 -0
  26. package/src/superlocalmemory/math/polar_quant.py +379 -0
  27. package/src/superlocalmemory/math/qjl.py +115 -0
  28. package/src/superlocalmemory/mcp/server.py +2 -0
  29. package/src/superlocalmemory/mcp/tools_v3.py +10 -0
  30. package/src/superlocalmemory/mcp/tools_v33.py +351 -0
  31. package/src/superlocalmemory/parameterization/__init__.py +47 -0
  32. package/src/superlocalmemory/parameterization/pattern_extractor.py +534 -0
  33. package/src/superlocalmemory/parameterization/pii_filter.py +106 -0
  34. package/src/superlocalmemory/parameterization/prompt_injector.py +216 -0
  35. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +275 -0
  36. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +425 -0
  37. package/src/superlocalmemory/retrieval/engine.py +21 -3
  38. package/src/superlocalmemory/retrieval/forgetting_filter.py +145 -0
  39. package/src/superlocalmemory/retrieval/hopfield_channel.py +335 -0
  40. package/src/superlocalmemory/retrieval/quantization_aware_search.py +133 -0
  41. package/src/superlocalmemory/retrieval/strategy.py +16 -6
  42. package/src/superlocalmemory/server/routes/agents.py +68 -8
  43. package/src/superlocalmemory/server/routes/learning.py +18 -1
  44. package/src/superlocalmemory/server/routes/lifecycle.py +36 -17
  45. package/src/superlocalmemory/server/routes/v3_api.py +503 -1
  46. package/src/superlocalmemory/storage/database.py +206 -0
  47. package/src/superlocalmemory/storage/embedding_migrator.py +178 -0
  48. package/src/superlocalmemory/storage/migration_v33.py +140 -0
  49. package/src/superlocalmemory/storage/quantized_store.py +261 -0
  50. package/src/superlocalmemory/storage/schema_v32.py +137 -0
  51. 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