superlocalmemory 4.0.9 → 4.1.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +245 -0
- package/README.md +7 -7
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +308 -20
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +26 -4
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance.py +44 -6
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/store_pipeline.py +78 -3
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/server.py +1 -1
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +71 -42
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
- package/src/superlocalmemory/retrieval/vector_store.py +63 -0
- package/src/superlocalmemory/server/api.py +26 -2
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +193 -17
- package/src/superlocalmemory/server/ui.py +24 -1
- package/src/superlocalmemory/server/unified_daemon.py +292 -9
- package/src/superlocalmemory/storage/_migration_internals.py +35 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +598 -82
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +130 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +110 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +10 -19
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +209 -1
- package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -191,17 +191,138 @@ def _batch_delete_by_ids(
|
|
|
191
191
|
for start in range(0, len(ids), _BATCH_SIZE):
|
|
192
192
|
batch = ids[start:start + _BATCH_SIZE]
|
|
193
193
|
ph = ",".join("?" * len(batch))
|
|
194
|
+
endpoints = _endpoints_of(db, table, id_col, batch)
|
|
194
195
|
with db.transaction():
|
|
195
196
|
db.execute(
|
|
196
197
|
f"DELETE FROM {table} WHERE {id_col} IN ({ph})",
|
|
197
198
|
tuple(batch),
|
|
198
199
|
)
|
|
200
|
+
# Inside the same transaction as the delete. The graph also lives in
|
|
201
|
+
# a second store, which no SQLite transaction can reach, so the
|
|
202
|
+
# durable record of "these facts need re-projecting" has to be as
|
|
203
|
+
# durable as the delete itself. Without it the second store keeps
|
|
204
|
+
# serving edges this function just removed, and a search walks a hop
|
|
205
|
+
# that no longer exists.
|
|
206
|
+
_enqueue_reprojection(db, endpoints)
|
|
199
207
|
removed += len(batch)
|
|
200
208
|
if start + _BATCH_SIZE < len(ids):
|
|
201
209
|
time.sleep(_BATCH_YIELD_S)
|
|
202
210
|
return removed
|
|
203
211
|
|
|
204
212
|
|
|
213
|
+
#: The two columns naming an edge's ends, per table. ``association_edges``
|
|
214
|
+
#: calls them ``source_fact_id``/``target_fact_id``, so a helper that assumed
|
|
215
|
+
#: ``source_id``/``target_id`` raised there, returned nothing, and every
|
|
216
|
+
#: association-edge deletion went unannounced to the graph store -- silently,
|
|
217
|
+
#: because the failure looked exactly like "this table has no endpoints".
|
|
218
|
+
_ENDPOINT_COLUMNS: dict[str, tuple[str, str]] = {
|
|
219
|
+
"graph_edges": ("source_id", "target_id"),
|
|
220
|
+
"association_edges": ("source_fact_id", "target_fact_id"),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _endpoints_of(
|
|
225
|
+
db: "DatabaseManager", table: str, id_col: str, ids: list,
|
|
226
|
+
) -> list[tuple[str, str]]:
|
|
227
|
+
"""``(fact_id, profile_id)`` for both ends of the rows about to be deleted.
|
|
228
|
+
|
|
229
|
+
Read before the delete, because afterwards there is nothing to read. Only
|
|
230
|
+
the ends that are facts matter: an entity id here is projected as part of
|
|
231
|
+
whichever facts reference it, and those facts are re-derived anyway.
|
|
232
|
+
"""
|
|
233
|
+
if not ids:
|
|
234
|
+
return []
|
|
235
|
+
columns = _ENDPOINT_COLUMNS.get(table)
|
|
236
|
+
if columns is None:
|
|
237
|
+
# A table nobody has named the endpoints of is one this pass must not
|
|
238
|
+
# guess at. Loudly, because guessing wrong is what produced the silent
|
|
239
|
+
# hole above.
|
|
240
|
+
logger.warning(
|
|
241
|
+
"prune: %s has no declared endpoint columns, so its deletions "
|
|
242
|
+
"cannot be announced to the graph projection", table,
|
|
243
|
+
)
|
|
244
|
+
return []
|
|
245
|
+
ph = ",".join("?" * len(ids))
|
|
246
|
+
try:
|
|
247
|
+
rows = db.execute(
|
|
248
|
+
f"SELECT {columns[0]}, {columns[1]}, profile_id FROM {table} "
|
|
249
|
+
f"WHERE {id_col} IN ({ph})",
|
|
250
|
+
tuple(ids),
|
|
251
|
+
)
|
|
252
|
+
except Exception as exc: # noqa: BLE001
|
|
253
|
+
logger.warning("prune: cannot read endpoints from %s: %s", table, exc)
|
|
254
|
+
return []
|
|
255
|
+
seen: dict[tuple[str, str], None] = {}
|
|
256
|
+
for row in rows:
|
|
257
|
+
record = dict(row)
|
|
258
|
+
profile_id = str(record.get("profile_id") or "default")
|
|
259
|
+
for column in columns:
|
|
260
|
+
value = record.get(column)
|
|
261
|
+
if value:
|
|
262
|
+
seen[(str(value), profile_id)] = None
|
|
263
|
+
return list(seen)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _enqueue_reprojection(
|
|
267
|
+
db: "DatabaseManager", endpoints: list[tuple[str, str]],
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Queue an upsert for each fact whose edges just changed.
|
|
270
|
+
|
|
271
|
+
The queue coalesces on fact id and the worker re-reads the fact's current
|
|
272
|
+
edges from SQLite, so queueing an endpoint twice, or queueing one whose
|
|
273
|
+
edges were already correct, costs one row and converges on the same answer.
|
|
274
|
+
An id that is an entity rather than a fact is filtered out here rather than
|
|
275
|
+
left for the worker, which would otherwise spend a lookup discovering the
|
|
276
|
+
same thing on every cycle.
|
|
277
|
+
"""
|
|
278
|
+
if not endpoints:
|
|
279
|
+
return
|
|
280
|
+
try:
|
|
281
|
+
from superlocalmemory.storage import projection_outbox
|
|
282
|
+
except Exception as exc: # noqa: BLE001
|
|
283
|
+
logger.debug("prune: no projection queue module: %s", exc)
|
|
284
|
+
return
|
|
285
|
+
try:
|
|
286
|
+
if not projection_outbox.is_available(db):
|
|
287
|
+
return
|
|
288
|
+
except Exception as exc: # noqa: BLE001
|
|
289
|
+
logger.debug("prune: projection queue unavailable: %s", exc)
|
|
290
|
+
return
|
|
291
|
+
by_profile: dict[str, list[str]] = {}
|
|
292
|
+
for fact_id, profile_id in endpoints:
|
|
293
|
+
by_profile.setdefault(profile_id, []).append(fact_id)
|
|
294
|
+
for profile_id, fact_ids in by_profile.items():
|
|
295
|
+
ph = ",".join("?" * len(fact_ids))
|
|
296
|
+
try:
|
|
297
|
+
rows = db.execute(
|
|
298
|
+
f"SELECT fact_id FROM atomic_facts WHERE profile_id = ? "
|
|
299
|
+
f"AND fact_id IN ({ph})",
|
|
300
|
+
(profile_id, *fact_ids),
|
|
301
|
+
)
|
|
302
|
+
real = [dict(row)["fact_id"] for row in rows]
|
|
303
|
+
except Exception as exc: # noqa: BLE001
|
|
304
|
+
logger.debug("prune: cannot confirm endpoints are facts: %s", exc)
|
|
305
|
+
continue
|
|
306
|
+
if not real:
|
|
307
|
+
continue
|
|
308
|
+
# Deliberately allowed to raise. This runs inside the same transaction
|
|
309
|
+
# as the delete, so a failure here rolls the delete back and the batch
|
|
310
|
+
# is simply retried on the next pass -- nothing is lost and nothing
|
|
311
|
+
# diverges.
|
|
312
|
+
#
|
|
313
|
+
# Swallowing it, as this once did, committed the delete with no record
|
|
314
|
+
# that the graph needed telling. The queue would then be empty, which
|
|
315
|
+
# is exactly what "the graph is up to date" looks like, and the graph
|
|
316
|
+
# would serve a link the store had removed with nothing anywhere
|
|
317
|
+
# recording that it happened. That is the failure this queue exists to
|
|
318
|
+
# make impossible, and it is the module's own stated policy: "a
|
|
319
|
+
# durability mechanism that silently degrades to best-effort is the
|
|
320
|
+
# defect it exists to remove."
|
|
321
|
+
projection_outbox.enqueue_many(
|
|
322
|
+
db, real, profile_id, op=projection_outbox.OP_UPSERT,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
205
326
|
def _remove_orphan_edges_batched(
|
|
206
327
|
db: "DatabaseManager",
|
|
207
328
|
profile_id: str,
|
|
@@ -31,6 +31,16 @@ if TYPE_CHECKING:
|
|
|
31
31
|
|
|
32
32
|
logger = logging.getLogger(__name__)
|
|
33
33
|
|
|
34
|
+
|
|
35
|
+
class _ConsolidationDisabled(Exception):
|
|
36
|
+
"""Internal signal: consolidation is switched off, so skip its block.
|
|
37
|
+
|
|
38
|
+
A private exception rather than restructuring the surrounding try/except:
|
|
39
|
+
the block's job is to keep one optional maintenance step from taking the
|
|
40
|
+
whole pass down with it, and that guarantee should not be weakened to
|
|
41
|
+
express "deliberately skipped". Caught immediately below, never propagated.
|
|
42
|
+
"""
|
|
43
|
+
|
|
34
44
|
# Backfill constants
|
|
35
45
|
_BACKFILL_BURN_IN_STEPS = 50
|
|
36
46
|
_LANGEVIN_DIM = 8
|
|
@@ -638,9 +648,12 @@ def run_maintenance(
|
|
|
638
648
|
logger.warning("Entity summary consolidation failed: %s", exc)
|
|
639
649
|
|
|
640
650
|
# 4. Fact consolidation (v3.8.4 concurrency-safe path via DatabaseManager).
|
|
641
|
-
#
|
|
642
|
-
#
|
|
643
|
-
#
|
|
651
|
+
# Groups warm/cold atomic facts that share an entity and writes ONE
|
|
652
|
+
# DISPLAY summary per cluster into consolidated_summaries, with provenance
|
|
653
|
+
# in fact_consolidations. It does not write to atomic_facts and does not
|
|
654
|
+
# archive the source facts — until 4.0.10 it did both, which put 1,195
|
|
655
|
+
# model-written rows into the retrieval corpus and left 528 genuine
|
|
656
|
+
# memories archived out of normal recall.
|
|
644
657
|
#
|
|
645
658
|
# Uses the DatabaseManager path so LLM calls happen OUTSIDE the write lock:
|
|
646
659
|
# - Discover clusters in a short memory_read() (no write lock held).
|
|
@@ -652,20 +665,45 @@ def run_maintenance(
|
|
|
652
665
|
try:
|
|
653
666
|
from superlocalmemory.core.fact_consolidator import consolidate_facts
|
|
654
667
|
|
|
668
|
+
# The documented off-switch has to actually switch something off.
|
|
669
|
+
# ConsolidationConfig.enabled has existed since Phase 5 and this call
|
|
670
|
+
# site never read it, so a user who ran `slm config` to turn
|
|
671
|
+
# consolidation off got consolidation anyway — for four months, on
|
|
672
|
+
# every maintenance pass. -2 is a third distinguishable value, kept
|
|
673
|
+
# apart from 0 (nothing to merge) and -1 (the step failed), so a
|
|
674
|
+
# deliberately disabled step is never mistaken for either.
|
|
675
|
+
_consolidation = getattr(config, "consolidation", None)
|
|
676
|
+
if _consolidation is not None and not getattr(_consolidation, "enabled", True):
|
|
677
|
+
counts["facts_consolidated"] = -2
|
|
678
|
+
logger.debug("Fact consolidation disabled by configuration")
|
|
679
|
+
raise _ConsolidationDisabled
|
|
680
|
+
|
|
655
681
|
fc_stats = consolidate_facts(
|
|
656
682
|
db,
|
|
657
683
|
profile_id=profile_id,
|
|
658
|
-
|
|
684
|
+
# Read from ConsolidationConfig, with the old SLMConfig-level name
|
|
685
|
+
# as the fallback. `getattr(config, "max_consolidation_clusters")`
|
|
686
|
+
# alone never resolved — SLMConfig has no such attribute — so the
|
|
687
|
+
# default was the only value this had ever used.
|
|
688
|
+
max_clusters=int(
|
|
689
|
+
getattr(_consolidation, "max_consolidation_clusters", None)
|
|
690
|
+
or getattr(config, "max_consolidation_clusters", None)
|
|
691
|
+
or 20
|
|
692
|
+
),
|
|
659
693
|
dry_run=False,
|
|
660
694
|
config=config,
|
|
661
695
|
)
|
|
662
696
|
counts["facts_consolidated"] = fc_stats.get("consolidated", 0)
|
|
663
697
|
if fc_stats.get("consolidated", 0) > 0:
|
|
664
698
|
logger.info(
|
|
665
|
-
"Fact consolidation: %d
|
|
699
|
+
"Fact consolidation: %d display summaries over %d facts "
|
|
700
|
+
"(%d clusters refused)",
|
|
666
701
|
fc_stats.get("consolidated", 0),
|
|
667
|
-
fc_stats.get("
|
|
702
|
+
fc_stats.get("facts_summarized", 0),
|
|
703
|
+
fc_stats.get("rejected", 0),
|
|
668
704
|
)
|
|
705
|
+
except _ConsolidationDisabled:
|
|
706
|
+
pass
|
|
669
707
|
except Exception as exc:
|
|
670
708
|
# WARNING, not debug, and a distinguishable count. Leaving this at debug
|
|
671
709
|
# with facts_consolidated=0 made a failing consolidation report exactly
|
|
@@ -67,6 +67,16 @@ class MaintenanceScheduler:
|
|
|
67
67
|
self._initial_gc_timer = threading.Timer(90.0, self._initial_cache_gc)
|
|
68
68
|
self._initial_gc_timer.daemon = True
|
|
69
69
|
self._initial_gc_timer.start()
|
|
70
|
+
# An upgrade arrives with whatever backlog the previous version left.
|
|
71
|
+
# Waiting a full interval for the first graph-metrics pass would mean
|
|
72
|
+
# half an hour of ranking memories as though they had no position in the
|
|
73
|
+
# graph, on exactly the store that just gained the fix. Staggered behind
|
|
74
|
+
# the cache GC so the two never contend for the write lock.
|
|
75
|
+
self._initial_metrics_timer = threading.Timer(
|
|
76
|
+
150.0, self._initial_graph_metrics,
|
|
77
|
+
)
|
|
78
|
+
self._initial_metrics_timer.daemon = True
|
|
79
|
+
self._initial_metrics_timer.start()
|
|
70
80
|
logger.info(
|
|
71
81
|
"Maintenance scheduler started (interval=%dm)",
|
|
72
82
|
self._config.forgetting.scheduler_interval_minutes,
|
|
@@ -86,6 +96,29 @@ class MaintenanceScheduler:
|
|
|
86
96
|
except Exception as exc:
|
|
87
97
|
logger.debug("Startup activation-cache GC skipped: %s", exc)
|
|
88
98
|
|
|
99
|
+
def _initial_graph_metrics(self) -> None:
|
|
100
|
+
"""One-shot catch-up so an upgrade does not rank on stale metrics."""
|
|
101
|
+
if not self._running:
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
from superlocalmemory.core.graph_metrics import (
|
|
105
|
+
compute_graph_metrics,
|
|
106
|
+
metrics_are_stale,
|
|
107
|
+
)
|
|
108
|
+
for profile_id in self._profile_ids():
|
|
109
|
+
stale, why = metrics_are_stale(self._db, profile_id)
|
|
110
|
+
if not stale:
|
|
111
|
+
continue
|
|
112
|
+
report = compute_graph_metrics(self._db, profile_id)
|
|
113
|
+
if report.ok:
|
|
114
|
+
logger.info(
|
|
115
|
+
"Graph metrics at startup (%s): %s", why, report.summary(),
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
logger.warning("Graph metrics at startup: %s", report.summary())
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
logger.debug("Startup graph metrics skipped: %s", exc)
|
|
121
|
+
|
|
89
122
|
def stop(self) -> None:
|
|
90
123
|
"""Stop the scheduler. Idempotent."""
|
|
91
124
|
self._running = False
|
|
@@ -96,6 +129,10 @@ class MaintenanceScheduler:
|
|
|
96
129
|
if _gc_timer is not None:
|
|
97
130
|
_gc_timer.cancel()
|
|
98
131
|
self._initial_gc_timer = None
|
|
132
|
+
_metrics_timer = getattr(self, "_initial_metrics_timer", None)
|
|
133
|
+
if _metrics_timer is not None:
|
|
134
|
+
_metrics_timer.cancel()
|
|
135
|
+
self._initial_metrics_timer = None
|
|
99
136
|
logger.info("Maintenance scheduler stopped")
|
|
100
137
|
|
|
101
138
|
def _schedule_next(self) -> None:
|
|
@@ -106,6 +143,47 @@ class MaintenanceScheduler:
|
|
|
106
143
|
self._timer.daemon = True
|
|
107
144
|
self._timer.start()
|
|
108
145
|
|
|
146
|
+
#: Consecutive failures of one step before it stops being a hiccup.
|
|
147
|
+
_ESCALATE_AFTER = 3
|
|
148
|
+
|
|
149
|
+
def _record_step(self, step: str, ok: bool, detail: str = "") -> None:
|
|
150
|
+
"""Remember whether a maintenance step worked, and say so when it has
|
|
151
|
+
stopped working."""
|
|
152
|
+
counts = getattr(self, "_step_failures", None)
|
|
153
|
+
if counts is None:
|
|
154
|
+
counts = self._step_failures = {}
|
|
155
|
+
if ok:
|
|
156
|
+
if counts.pop(step, 0):
|
|
157
|
+
logger.info("maintenance: %s is working again", step)
|
|
158
|
+
return
|
|
159
|
+
counts[step] = counts.get(step, 0) + 1
|
|
160
|
+
if counts[step] >= self._ESCALATE_AFTER:
|
|
161
|
+
logger.error(
|
|
162
|
+
"maintenance: %s has failed %d cycles in a row (%s). This is "
|
|
163
|
+
"not a transient failure; the work it does is not being done.",
|
|
164
|
+
step, counts[step], detail or "no detail",
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
logger.warning("maintenance: %s failed (%s)", step, detail or "")
|
|
168
|
+
|
|
169
|
+
def failing_steps(self) -> dict[str, int]:
|
|
170
|
+
"""Steps that have failed on consecutive cycles, and how many.
|
|
171
|
+
|
|
172
|
+
Read by the status surfaces, so an operator can see a persistently
|
|
173
|
+
broken maintenance step instead of having to find it in the log.
|
|
174
|
+
"""
|
|
175
|
+
return dict(getattr(self, "_step_failures", {}) or {})
|
|
176
|
+
|
|
177
|
+
def _note_step_outcomes(self) -> None:
|
|
178
|
+
"""Escalate anything still failing after this cycle's steps."""
|
|
179
|
+
failing = self.failing_steps()
|
|
180
|
+
if failing:
|
|
181
|
+
logger.warning(
|
|
182
|
+
"maintenance: %d step(s) still failing: %s",
|
|
183
|
+
len(failing),
|
|
184
|
+
", ".join(f"{name} x{count}" for name, count in sorted(failing.items())),
|
|
185
|
+
)
|
|
186
|
+
|
|
109
187
|
def _run(self) -> None:
|
|
110
188
|
"""Execute maintenance + auto-backup check, then schedule next run."""
|
|
111
189
|
if not self._running:
|
|
@@ -183,6 +261,66 @@ class MaintenanceScheduler:
|
|
|
183
261
|
except Exception as exc:
|
|
184
262
|
logger.debug("Graph pruning skipped for %s: %s", profile_id, exc)
|
|
185
263
|
|
|
264
|
+
# Pruning the graph orphans the lineage of every edge it removed,
|
|
265
|
+
# and nothing had ever deleted from that table — on a real store it
|
|
266
|
+
# had grown to 39% rows describing edges that no longer existed.
|
|
267
|
+
# This runs immediately after so the rows the pass just orphaned are
|
|
268
|
+
# collected in the same pass.
|
|
269
|
+
try:
|
|
270
|
+
from superlocalmemory.storage.lineage_retention import (
|
|
271
|
+
prune_orphan_lineage,
|
|
272
|
+
)
|
|
273
|
+
report = prune_orphan_lineage(self._db, profile_id=profile_id)
|
|
274
|
+
if report.total:
|
|
275
|
+
logger.info(
|
|
276
|
+
"Lineage retention for %s: %d row(s) removed (%s)",
|
|
277
|
+
profile_id, report.total, report.deleted,
|
|
278
|
+
)
|
|
279
|
+
except Exception as exc:
|
|
280
|
+
logger.debug("Lineage retention skipped for %s: %s", profile_id, exc)
|
|
281
|
+
|
|
282
|
+
# Structural metrics. Recall multiplies a candidate's activation by
|
|
283
|
+
# its PageRank at every hop and biases it toward its query seeds'
|
|
284
|
+
# communities, and both numbers live in fact_importance -- so a
|
|
285
|
+
# memory missing from that table is found by the walk and then
|
|
286
|
+
# ranked as though it had no position in the graph.
|
|
287
|
+
#
|
|
288
|
+
# Nothing scheduled this. It ran only when a consolidation happened
|
|
289
|
+
# to fire or someone called the HTTP endpoint by hand, and on the
|
|
290
|
+
# author's store that meant one run in nine days: 1,036 of 4,034
|
|
291
|
+
# visible memories had no score and no community, and the newest
|
|
292
|
+
# four days of memories had none at all. This runs after pruning so
|
|
293
|
+
# it describes the graph that pruning left behind.
|
|
294
|
+
try:
|
|
295
|
+
from superlocalmemory.core.graph_metrics import (
|
|
296
|
+
compute_graph_metrics,
|
|
297
|
+
metrics_are_stale,
|
|
298
|
+
)
|
|
299
|
+
stale, why = metrics_are_stale(self._db, profile_id)
|
|
300
|
+
if stale:
|
|
301
|
+
backend = None
|
|
302
|
+
try:
|
|
303
|
+
from superlocalmemory.core.backend_orchestrator import (
|
|
304
|
+
get_orchestrator,
|
|
305
|
+
)
|
|
306
|
+
orchestrator = get_orchestrator()
|
|
307
|
+
if orchestrator is not None:
|
|
308
|
+
backend = orchestrator.get_graph_backend()
|
|
309
|
+
except Exception: # noqa: BLE001 -- in-process is the default anyway
|
|
310
|
+
backend = None
|
|
311
|
+
report = compute_graph_metrics(
|
|
312
|
+
self._db, profile_id, backend=backend,
|
|
313
|
+
)
|
|
314
|
+
if report.ok:
|
|
315
|
+
logger.info("Graph metrics (%s): %s", why, report.summary())
|
|
316
|
+
else:
|
|
317
|
+
logger.warning("Graph metrics: %s", report.summary())
|
|
318
|
+
else:
|
|
319
|
+
logger.debug("Graph metrics up to date for %s", profile_id)
|
|
320
|
+
self._record_step("graph metrics", True)
|
|
321
|
+
except Exception as exc: # noqa: BLE001
|
|
322
|
+
self._record_step("graph metrics", False, str(exc))
|
|
323
|
+
|
|
186
324
|
# Lifecycle evaluation must cover every stored profile, not only
|
|
187
325
|
# whichever profile was active when the engine started.
|
|
188
326
|
try:
|
|
@@ -201,6 +339,73 @@ class MaintenanceScheduler:
|
|
|
201
339
|
except Exception as exc:
|
|
202
340
|
logger.debug("Core-block recompile skipped for %s: %s", profile_id, exc)
|
|
203
341
|
|
|
342
|
+
# Re-read what is filed as a plan. The one-time pass runs as a
|
|
343
|
+
# migration; the rule it uses keeps getting sharper, and a completed
|
|
344
|
+
# migration is never replayed — so without this the store drifts
|
|
345
|
+
# further from the rule with every release and nothing repairs it. The
|
|
346
|
+
# pass is a pure function of the text and idempotent, so this is a no-op
|
|
347
|
+
# once the store has converged.
|
|
348
|
+
#
|
|
349
|
+
# Once per cycle, not once per profile: it reads the whole table in one
|
|
350
|
+
# sweep with no profile predicate, so running it per profile did the
|
|
351
|
+
# identical global work N times over and took the write lock N times to
|
|
352
|
+
# do it. It also takes and releases that lock per batch, so a memory
|
|
353
|
+
# being saved waits for one batch rather than the whole sweep.
|
|
354
|
+
#
|
|
355
|
+
# An earlier version reached for `_conn` and then `.connection`, and the
|
|
356
|
+
# database manager has neither, so the guard was always False and this
|
|
357
|
+
# had never run once -- the store drifted further from the rule with
|
|
358
|
+
# every release while a block that looks like it repairs that sat here
|
|
359
|
+
# doing nothing.
|
|
360
|
+
try:
|
|
361
|
+
from superlocalmemory.storage.migrations import (
|
|
362
|
+
M048_upcoming_holds_only_what_is_upcoming as _reclassify,
|
|
363
|
+
)
|
|
364
|
+
_reclassify.apply(open_connection=self._db.raw_connection)
|
|
365
|
+
self._record_step("re-reading plans", True)
|
|
366
|
+
except Exception as exc: # noqa: BLE001
|
|
367
|
+
self._record_step("re-reading plans", False, str(exc))
|
|
368
|
+
|
|
369
|
+
# Anything that has failed on several cycles running is not a blip.
|
|
370
|
+
# Every step here logs and continues, which is right -- one broken step
|
|
371
|
+
# must not stop the rest -- but it meant a step that had been failing
|
|
372
|
+
# for a week looked exactly like one that had just hiccupped, and the
|
|
373
|
+
# daemon still reported itself healthy throughout.
|
|
374
|
+
self._note_step_outcomes()
|
|
375
|
+
|
|
376
|
+
# Retention. Three tables had a pruner each, written and wired
|
|
377
|
+
# separately; the fourth unbounded table was found by reading a
|
|
378
|
+
# disk-usage report and the fifth by reading the fourth. The policy for
|
|
379
|
+
# every append-shaped table now lives in one registry and this enforces
|
|
380
|
+
# all of them, so a table added without a policy is something the test
|
|
381
|
+
# suite can see rather than something a person has to remember.
|
|
382
|
+
#
|
|
383
|
+
# Once per cycle, not per profile: every rule is keyed either on a row's
|
|
384
|
+
# own age or on whether its referent still exists, and neither is
|
|
385
|
+
# profile-scoped. Placed after the per-profile work so it sweeps rows
|
|
386
|
+
# that pass orphaned -- pruning the graph and demoting tiers is what
|
|
387
|
+
# leaves a lineage or temporal row without a referent.
|
|
388
|
+
# In pieces, each taking and releasing the write lock. Entering
|
|
389
|
+
# ``raw_connection`` is what takes that lock, so entering it once for
|
|
390
|
+
# the whole sweep held it for the whole sweep: measured at 1,480 ms and
|
|
391
|
+
# 123,888 rows on a 1 GB store, which is most of the budget a save is
|
|
392
|
+
# allowed, spent waiting.
|
|
393
|
+
try:
|
|
394
|
+
from superlocalmemory.storage.retention_policy import (
|
|
395
|
+
run_retention_bounded,
|
|
396
|
+
)
|
|
397
|
+
removed = run_retention_bounded(self._db.raw_connection)
|
|
398
|
+
if removed:
|
|
399
|
+
logger.info(
|
|
400
|
+
"Retention: %s",
|
|
401
|
+
", ".join(
|
|
402
|
+
f"{table} -{count}" for table, count in sorted(removed.items())
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
self._record_step("retention", True)
|
|
406
|
+
except Exception as exc: # noqa: BLE001
|
|
407
|
+
self._record_step("retention", False, str(exc))
|
|
408
|
+
|
|
204
409
|
# V3.4.10: Check if auto-backup is due
|
|
205
410
|
try:
|
|
206
411
|
from superlocalmemory.infra.backup import BackupManager
|