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
|
@@ -37,6 +37,8 @@ __all__ = [
|
|
|
37
37
|
"EMBEDDING_BYTES",
|
|
38
38
|
"encode_embedding",
|
|
39
39
|
"decode_embedding",
|
|
40
|
+
"encode_float_vector",
|
|
41
|
+
"decode_float_vector",
|
|
40
42
|
]
|
|
41
43
|
|
|
42
44
|
EMBEDDING_DIM: int = 768
|
|
@@ -127,3 +129,72 @@ def decode_embedding(
|
|
|
127
129
|
f"Unexpected embedding type {type(raw).__name__!r} for fact {fact_id!r}; "
|
|
128
130
|
f"expected bytes or str"
|
|
129
131
|
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# The same treatment for the other float vectors stored on a fact
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
#
|
|
138
|
+
# A fact carries two more 768-wide vectors besides its embedding: the diagonal
|
|
139
|
+
# Fisher mean and variance that the memory dynamics read. They were written as
|
|
140
|
+
# JSON text, which costs about 17 KB each against 3 KB for the same numbers as
|
|
141
|
+
# float32. Measured on a real 447 MB store: 116.5 MB of Fisher text describing
|
|
142
|
+
# 3.6 MB of memories — thirty-two times the size of the content itself, and
|
|
143
|
+
# more than a quarter of the whole file.
|
|
144
|
+
#
|
|
145
|
+
# The read path accepts both forms for the same reason the embedding one does:
|
|
146
|
+
# a store converts when a migration reaches it, and everything has to keep
|
|
147
|
+
# working in the meantime.
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def encode_float_vector(vec: list[float] | None) -> bytes | None:
|
|
151
|
+
"""Serialise any float vector to a little-endian float32 BLOB."""
|
|
152
|
+
if vec is None:
|
|
153
|
+
return None
|
|
154
|
+
return np.asarray(vec, dtype=np.float32).tobytes()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def decode_float_vector(
|
|
158
|
+
raw: bytes | str | None,
|
|
159
|
+
*,
|
|
160
|
+
field: str = "vector",
|
|
161
|
+
fact_id: str = "<unknown>",
|
|
162
|
+
) -> list[float] | None:
|
|
163
|
+
"""Read a float vector written as either JSON text or a float32 BLOB.
|
|
164
|
+
|
|
165
|
+
Raises rather than returning ``None`` for a malformed value: a caller
|
|
166
|
+
cannot tell a legitimately absent vector from a lost one, and these feed
|
|
167
|
+
the decay dynamics, where a silently empty vector reads as "no evidence"
|
|
168
|
+
instead of "evidence missing".
|
|
169
|
+
"""
|
|
170
|
+
if raw is None or raw == "":
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
174
|
+
if len(raw) == 0 or len(raw) % 4 != 0:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"Corrupt {field} buffer for fact {fact_id!r}: {len(raw)} bytes "
|
|
177
|
+
f"is not a multiple of 4 (float32)"
|
|
178
|
+
)
|
|
179
|
+
return np.frombuffer(raw, dtype=np.float32).tolist()
|
|
180
|
+
|
|
181
|
+
if isinstance(raw, str):
|
|
182
|
+
try:
|
|
183
|
+
value = json.loads(raw)
|
|
184
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"Corrupt JSON {field} for fact {fact_id!r}: {exc}"
|
|
187
|
+
) from exc
|
|
188
|
+
if value is None:
|
|
189
|
+
return None
|
|
190
|
+
if not isinstance(value, list):
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"{field} for fact {fact_id!r} decoded to "
|
|
193
|
+
f"{type(value).__name__}, expected a list"
|
|
194
|
+
)
|
|
195
|
+
return [float(v) for v in value]
|
|
196
|
+
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"Unexpected {field} type {type(raw).__name__!r} for fact {fact_id!r}; "
|
|
199
|
+
f"expected bytes or str"
|
|
200
|
+
)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Forget the provenance of things that no longer exist.
|
|
6
|
+
|
|
7
|
+
`derivation_lineage` records where each derived object came from — which source
|
|
8
|
+
span produced this fact, this graph edge, this scene. Every ingestion operation
|
|
9
|
+
re-captures lineage for the objects present at that moment, so the table grows
|
|
10
|
+
with use, and nothing has ever deleted from it.
|
|
11
|
+
|
|
12
|
+
Measured on a real 447 MB store:
|
|
13
|
+
|
|
14
|
+
derivation_lineage 256,885 rows, 64 MB, plus 65 MB of indexes
|
|
15
|
+
... describing a graph edge
|
|
16
|
+
that no longer exists 100,581 rows — 39.2% of the table
|
|
17
|
+
growth ~9,000 rows/day, sustained
|
|
18
|
+
|
|
19
|
+
The graph is pruned. Its lineage was not, so the record of how a deleted edge
|
|
20
|
+
came to exist outlives the edge forever. Those rows answer no question: the
|
|
21
|
+
evidence bundle computes lineage coverage over the objects it exports, and an
|
|
22
|
+
object that is gone is not exported.
|
|
23
|
+
|
|
24
|
+
WHAT IS AND IS NOT DELETED
|
|
25
|
+
|
|
26
|
+
Only a row whose object is provably absent — the type is one this module knows
|
|
27
|
+
how to resolve, the table exists, and no row with that id is in it. An
|
|
28
|
+
unrecognised object type is left alone, because "I do not know what this
|
|
29
|
+
describes" is not evidence that it describes nothing.
|
|
30
|
+
|
|
31
|
+
There is deliberately no age rule. Lineage is what an audit reads to answer
|
|
32
|
+
"where did this come from", and a fact can be years old and still current.
|
|
33
|
+
Deleting provenance for something that still exists would be destroying the
|
|
34
|
+
answer while keeping the question.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
import sqlite3
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
__all__ = ["OBJECT_SOURCES", "LineagePruneReport", "count_orphan_lineage",
|
|
46
|
+
"prune_orphan_lineage"]
|
|
47
|
+
|
|
48
|
+
#: object_type -> (table holding it, column carrying its id).
|
|
49
|
+
#: Mirrors what ``core/derivation_lineage.capture_operation_lineage`` writes.
|
|
50
|
+
#: A type absent from this map is never deleted.
|
|
51
|
+
OBJECT_SOURCES: dict[str, tuple[str, str]] = {
|
|
52
|
+
"fact": ("atomic_facts", "fact_id"),
|
|
53
|
+
"graph_edge": ("graph_edges", "edge_id"),
|
|
54
|
+
"memory_scene": ("memory_scenes", "scene_id"),
|
|
55
|
+
"entity_summary": ("entity_profiles", "profile_entry_id"),
|
|
56
|
+
"index_bm25": ("bm25_tokens", "fact_id"),
|
|
57
|
+
"profile": ("profiles", "profile_id"),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#: Rows per transaction. Big enough that commit overhead vanishes, small enough
|
|
61
|
+
#: that an interrupted run has done most of its work and holds no long lock.
|
|
62
|
+
_BATCH = 2_000
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _Rows:
|
|
66
|
+
"""Read and write through either a raw connection or the DatabaseManager.
|
|
67
|
+
|
|
68
|
+
The maintenance cycle holds a ``DatabaseManager``, whose lock serialises
|
|
69
|
+
every write; a migration or a test holds a plain ``sqlite3.Connection``.
|
|
70
|
+
Both are legitimate callers, and the difference is two method names.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, db: object) -> None:
|
|
74
|
+
self._db = db
|
|
75
|
+
self._managed = hasattr(db, "transaction") and not isinstance(
|
|
76
|
+
db, sqlite3.Connection
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def query(self, sql: str, params: tuple = ()) -> list:
|
|
80
|
+
rows = self._db.execute(sql, tuple(params))
|
|
81
|
+
return list(rows) if self._managed else rows.fetchall()
|
|
82
|
+
|
|
83
|
+
def write(self, sql: str, params: tuple = ()) -> None:
|
|
84
|
+
if self._managed:
|
|
85
|
+
with self._db.transaction():
|
|
86
|
+
self._db.execute(sql, tuple(params))
|
|
87
|
+
return
|
|
88
|
+
self._db.execute("BEGIN IMMEDIATE")
|
|
89
|
+
try:
|
|
90
|
+
self._db.execute(sql, tuple(params))
|
|
91
|
+
self._db.commit()
|
|
92
|
+
except Exception:
|
|
93
|
+
self._db.rollback()
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class LineagePruneReport:
|
|
99
|
+
"""What was removed, by object type, and what was left alone."""
|
|
100
|
+
|
|
101
|
+
deleted: dict[str, int] = field(default_factory=dict)
|
|
102
|
+
skipped_types: tuple[str, ...] = ()
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def total(self) -> int:
|
|
106
|
+
return sum(self.deleted.values())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _table_exists(rows: _Rows, table: str) -> bool:
|
|
110
|
+
return bool(rows.query(
|
|
111
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
|
112
|
+
))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolvable(rows: _Rows) -> tuple[dict[str, tuple[str, str]], tuple[str, ...]]:
|
|
116
|
+
"""Split the types present in the table into resolvable and not."""
|
|
117
|
+
present = {
|
|
118
|
+
str(r[0]) for r in rows.query(
|
|
119
|
+
"SELECT DISTINCT object_type FROM derivation_lineage"
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
resolvable: dict[str, tuple[str, str]] = {}
|
|
123
|
+
skipped: list[str] = []
|
|
124
|
+
for object_type in sorted(present):
|
|
125
|
+
source = OBJECT_SOURCES.get(object_type)
|
|
126
|
+
if source is None or not _table_exists(rows, source[0]):
|
|
127
|
+
skipped.append(object_type)
|
|
128
|
+
continue
|
|
129
|
+
resolvable[object_type] = source
|
|
130
|
+
return resolvable, tuple(skipped)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def count_orphan_lineage(
|
|
134
|
+
db: object, *, profile_id: str | None = None,
|
|
135
|
+
) -> LineagePruneReport:
|
|
136
|
+
"""How many rows describe something absent, without deleting anything."""
|
|
137
|
+
rows = _Rows(db)
|
|
138
|
+
if not _table_exists(rows, "derivation_lineage"):
|
|
139
|
+
return LineagePruneReport()
|
|
140
|
+
|
|
141
|
+
resolvable, skipped = _resolvable(rows)
|
|
142
|
+
counts: dict[str, int] = {}
|
|
143
|
+
for object_type, (table, column) in resolvable.items():
|
|
144
|
+
sql = (
|
|
145
|
+
"SELECT COUNT(*) FROM derivation_lineage d WHERE d.object_type = ? "
|
|
146
|
+
f"AND NOT EXISTS (SELECT 1 FROM {table} t WHERE t.{column} = d.object_id)"
|
|
147
|
+
)
|
|
148
|
+
params: list[object] = [object_type]
|
|
149
|
+
if profile_id is not None:
|
|
150
|
+
sql += " AND d.profile_id = ?"
|
|
151
|
+
params.append(profile_id)
|
|
152
|
+
count = int(rows.query(sql, tuple(params))[0][0])
|
|
153
|
+
if count:
|
|
154
|
+
counts[object_type] = count
|
|
155
|
+
return LineagePruneReport(counts, skipped)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def prune_orphan_lineage(
|
|
159
|
+
db: object,
|
|
160
|
+
*,
|
|
161
|
+
profile_id: str | None = None,
|
|
162
|
+
dry_run: bool = False,
|
|
163
|
+
) -> LineagePruneReport:
|
|
164
|
+
"""Delete lineage rows whose object is provably gone.
|
|
165
|
+
|
|
166
|
+
Batched and committed as it goes, so an interrupted run keeps the work it
|
|
167
|
+
already did and the next one continues. Nothing here reads a clock: what is
|
|
168
|
+
deleted depends only on what exists.
|
|
169
|
+
"""
|
|
170
|
+
rows = _Rows(db)
|
|
171
|
+
if not _table_exists(rows, "derivation_lineage"):
|
|
172
|
+
return LineagePruneReport()
|
|
173
|
+
|
|
174
|
+
report = count_orphan_lineage(db, profile_id=profile_id)
|
|
175
|
+
if dry_run or not report.total:
|
|
176
|
+
return report
|
|
177
|
+
|
|
178
|
+
resolvable, skipped = _resolvable(rows)
|
|
179
|
+
deleted: dict[str, int] = {}
|
|
180
|
+
|
|
181
|
+
for object_type, (table, column) in resolvable.items():
|
|
182
|
+
if object_type not in report.deleted:
|
|
183
|
+
continue
|
|
184
|
+
removed = 0
|
|
185
|
+
while True:
|
|
186
|
+
sql = (
|
|
187
|
+
"SELECT lineage_id FROM derivation_lineage d "
|
|
188
|
+
"WHERE d.object_type = ? "
|
|
189
|
+
f"AND NOT EXISTS (SELECT 1 FROM {table} t WHERE t.{column} = d.object_id)"
|
|
190
|
+
)
|
|
191
|
+
params: list[object] = [object_type]
|
|
192
|
+
if profile_id is not None:
|
|
193
|
+
sql += " AND d.profile_id = ?"
|
|
194
|
+
params.append(profile_id)
|
|
195
|
+
sql += f" LIMIT {_BATCH}"
|
|
196
|
+
|
|
197
|
+
ids = [r[0] for r in rows.query(sql, tuple(params))]
|
|
198
|
+
if not ids:
|
|
199
|
+
break
|
|
200
|
+
placeholders = ",".join("?" * len(ids))
|
|
201
|
+
# Re-check absence in the DELETE itself. Selecting orphans and then
|
|
202
|
+
# deleting them by id is a check-then-act: another process can
|
|
203
|
+
# recreate the object between the two statements, and the row would
|
|
204
|
+
# be deleted anyway. Repeating the predicate makes the decision and
|
|
205
|
+
# the deletion one statement.
|
|
206
|
+
delete_sql = (
|
|
207
|
+
f"DELETE FROM derivation_lineage WHERE lineage_id IN ({placeholders}) "
|
|
208
|
+
f"AND NOT EXISTS (SELECT 1 FROM {table} t "
|
|
209
|
+
f"WHERE t.{column} = derivation_lineage.object_id)"
|
|
210
|
+
)
|
|
211
|
+
rows.write(delete_sql, tuple(ids))
|
|
212
|
+
# Count what survived the re-check rather than what was offered.
|
|
213
|
+
still = rows.query(
|
|
214
|
+
f"SELECT COUNT(*) FROM derivation_lineage "
|
|
215
|
+
f"WHERE lineage_id IN ({placeholders})",
|
|
216
|
+
tuple(ids),
|
|
217
|
+
)
|
|
218
|
+
remaining_here = int(still[0][0]) if still else 0
|
|
219
|
+
removed += len(ids) - remaining_here
|
|
220
|
+
if remaining_here == len(ids):
|
|
221
|
+
# Every row in this batch was rescued by a concurrent writer.
|
|
222
|
+
# Another pass would select the same rows forever.
|
|
223
|
+
break
|
|
224
|
+
if removed:
|
|
225
|
+
deleted[object_type] = removed
|
|
226
|
+
logger.info(
|
|
227
|
+
"lineage retention: removed %d row(s) describing a %s that no "
|
|
228
|
+
"longer exists", removed, object_type,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if skipped:
|
|
232
|
+
logger.info(
|
|
233
|
+
"lineage retention: left %s alone — no table is known to hold them",
|
|
234
|
+
", ".join(skipped),
|
|
235
|
+
)
|
|
236
|
+
return LineagePruneReport(deleted, skipped)
|
|
@@ -20,17 +20,58 @@ _LOGICAL_EDGE_SELECT = """
|
|
|
20
20
|
profile_id
|
|
21
21
|
FROM graph_edges
|
|
22
22
|
WHERE profile_id = ?
|
|
23
|
+
AND NOT EXISTS (
|
|
24
|
+
SELECT 1 FROM atomic_facts f
|
|
25
|
+
WHERE f.fact_id IN (graph_edges.source_id, graph_edges.target_id)
|
|
26
|
+
AND NOT ({visible})
|
|
27
|
+
)
|
|
23
28
|
GROUP BY profile_id, source_id, target_id, COALESCE(edge_type, 'related')
|
|
24
29
|
"""
|
|
25
30
|
|
|
26
31
|
|
|
32
|
+
def _edge_select(conn: sqlite3.Connection) -> str:
|
|
33
|
+
"""The logical-edge query, with the withheld-endpoint exclusion resolved.
|
|
34
|
+
|
|
35
|
+
WHY AN EDGE WITH A WITHHELD ENDPOINT IS NOT A LOGICAL EDGE
|
|
36
|
+
----------------------------------------------------------
|
|
37
|
+
The retrieval channel this projection stands in for does not traverse them.
|
|
38
|
+
It loads ``graph_edges`` by scope and then prunes the result: "Edge scope
|
|
39
|
+
alone cannot authorize an endpoint. Prune both endpoints against the visible
|
|
40
|
+
fact corpus so denied facts cannot influence an allowed candidate indirectly
|
|
41
|
+
through propagation." Its entity map is filtered the same way, for a reason
|
|
42
|
+
it spells out — a withheld row carries its whole cluster's pooled entity
|
|
43
|
+
list, so it out-ranks real memories and then gets discarded at hydration,
|
|
44
|
+
spending the channel's budget on nothing.
|
|
45
|
+
|
|
46
|
+
The export predated that fix and kept the withheld endpoints. Measured on a
|
|
47
|
+
copy of the author's store: Cozo's bridge held 1,257 facts the store may not
|
|
48
|
+
return and its edges touched 805, and the graph search diverged from SQLite
|
|
49
|
+
on **every** query — three shadow checks, three mismatches. One query
|
|
50
|
+
returned 9 results against SQLite's 20, because withheld facts had taken the
|
|
51
|
+
top-k budget. The projection failed closed every time, so recall was correct
|
|
52
|
+
and the projection was dead weight.
|
|
53
|
+
|
|
54
|
+
The predicate is resolved against the passed connection because
|
|
55
|
+
``archive_status`` and ``quarantined`` each arrive with a migration and may
|
|
56
|
+
be absent on a store the engine has not opened.
|
|
57
|
+
"""
|
|
58
|
+
from superlocalmemory.storage.database import visible_fact_clause_for_connection
|
|
59
|
+
|
|
60
|
+
# The helper returns a leading-AND clause for appending; here it is needed as
|
|
61
|
+
# a standalone predicate, so strip the connective and default to "always
|
|
62
|
+
# visible" on a store that has neither column yet.
|
|
63
|
+
clause = visible_fact_clause_for_connection(conn, prefix="f").strip()
|
|
64
|
+
predicate = clause[4:].strip() if clause.upper().startswith("AND ") else clause
|
|
65
|
+
return _LOGICAL_EDGE_SELECT.format(visible=predicate or "1=1")
|
|
66
|
+
|
|
67
|
+
|
|
27
68
|
def iter_logical_edges(
|
|
28
69
|
conn: sqlite3.Connection, profile_id: str
|
|
29
70
|
) -> Iterator[tuple[Any, ...]]:
|
|
30
71
|
"""Yield normalized graph edges in deterministic fingerprint order."""
|
|
31
72
|
return iter(
|
|
32
73
|
conn.execute(
|
|
33
|
-
|
|
74
|
+
_edge_select(conn) + " ORDER BY source_id, target_id, edge_type",
|
|
34
75
|
(profile_id,),
|
|
35
76
|
)
|
|
36
77
|
)
|
|
@@ -39,7 +80,7 @@ def iter_logical_edges(
|
|
|
39
80
|
def count_logical_edges(conn: sqlite3.Connection, profile_id: str) -> int:
|
|
40
81
|
"""Count relationships using the canonical logical identity."""
|
|
41
82
|
row = conn.execute(
|
|
42
|
-
"SELECT COUNT(*) FROM (" +
|
|
83
|
+
"SELECT COUNT(*) FROM (" + _edge_select(conn) + ")",
|
|
43
84
|
(profile_id,),
|
|
44
85
|
).fetchone()
|
|
45
86
|
return int(row[0] if row else 0)
|
|
@@ -159,11 +159,25 @@ from superlocalmemory.storage.migrations import (
|
|
|
159
159
|
from superlocalmemory.storage.migrations import (
|
|
160
160
|
M042_correction_case_ledger as _M042,
|
|
161
161
|
)
|
|
162
|
+
from superlocalmemory.storage.migrations import (
|
|
163
|
+
M044_play_carries_its_own_evidence as _M044,
|
|
164
|
+
)
|
|
165
|
+
from superlocalmemory.storage.migrations import (
|
|
166
|
+
M045_fact_outcome_score as _M045,
|
|
167
|
+
M046_prospective_memory_has_its_own_name as _M046,
|
|
168
|
+
M047_fisher_vectors_are_stored_like_every_other_vector as _M047,
|
|
169
|
+
M048_upcoming_holds_only_what_is_upcoming as _M048,
|
|
170
|
+
M049_a_schema_version_marker_is_one_row as _M049,
|
|
171
|
+
)
|
|
172
|
+
from superlocalmemory.storage.migrations import (
|
|
173
|
+
M043_quarantine_display_summaries as _M043,
|
|
174
|
+
)
|
|
162
175
|
from superlocalmemory.storage._schema_version import (
|
|
163
176
|
SUPPORTED_SCHEMA_VERSION,
|
|
164
177
|
SchemaVersionError,
|
|
165
178
|
check_version_or_raise as _check_version_or_raise,
|
|
166
179
|
ensure_schema_version_table as _ensure_schema_version_table,
|
|
180
|
+
read_schema_version as _read_schema_version,
|
|
167
181
|
write_schema_version as _write_schema_version,
|
|
168
182
|
)
|
|
169
183
|
from superlocalmemory.storage._migration_internals import (
|
|
@@ -249,6 +263,13 @@ MIGRATIONS: list[Migration] = [
|
|
|
249
263
|
# contains identifiers only and does not alter temporal fact state.
|
|
250
264
|
Migration(name=_M042.NAME, db_target="memory", ddl=_M042.DDL,
|
|
251
265
|
dependencies=(_M032.NAME,)),
|
|
266
|
+
# M044 lets a bandit play record which memories it showed, so the reward
|
|
267
|
+
# proxy can settle it from evidence instead of always falling through to
|
|
268
|
+
# the 120-second neutral default. Additive column on M005's bandit_plays,
|
|
269
|
+
# and eager on purpose: nothing bootstraps that table at engine init, so
|
|
270
|
+
# there is no reason to defer it.
|
|
271
|
+
Migration(name=_M044.NAME, db_target="learning", ddl=_M044.DDL,
|
|
272
|
+
dependencies=(_M005.NAME,)),
|
|
252
273
|
# M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
|
|
253
274
|
]
|
|
254
275
|
|
|
@@ -312,6 +333,47 @@ DEFERRED_MIGRATIONS: list[Migration] = [
|
|
|
312
333
|
# Main-line M034 is renumbered in V4. It must remain deferred because its
|
|
313
334
|
# backfill joins engine-bootstrapped memory_scenes and atomic_facts.
|
|
314
335
|
Migration(name=_M039.NAME, db_target="memory", ddl=_M039.DDL),
|
|
336
|
+
# M043 withholds model-written summaries from the retrieval corpus and
|
|
337
|
+
# un-hides the memories they displaced. Deferred because it reads and
|
|
338
|
+
# writes atomic_facts + fact_retention, both bootstrapped at engine init —
|
|
339
|
+
# the same reason M011/M013/M015/M016 are deferred. apply_deferred takes a
|
|
340
|
+
# verified snapshot before the first migration it actually applies, so the
|
|
341
|
+
# store is recoverable.
|
|
342
|
+
Migration(name=_M043.NAME, db_target="memory", ddl=_M043.DDL,
|
|
343
|
+
dependencies=(_M011.NAME,)),
|
|
344
|
+
# M045 holds the per-fact outcome score. Deferred because its backfill
|
|
345
|
+
# reads action_outcomes, which engine init bootstraps — the same reason
|
|
346
|
+
# M006 and M011 are deferred. Depends on M006 for the reward column it
|
|
347
|
+
# averages.
|
|
348
|
+
Migration(name=_M045.NAME, db_target="memory", ddl=_M045.DDL,
|
|
349
|
+
dependencies=(_M006.NAME,)),
|
|
350
|
+
# M046 renames the fact type used for planned future events, which means
|
|
351
|
+
# rebuilding atomic_facts to widen a CHECK constraint SQLite cannot alter.
|
|
352
|
+
# Deferred for the same reason as M043: atomic_facts is bootstrapped at
|
|
353
|
+
# engine init, and apply_deferred takes a verified snapshot before the first
|
|
354
|
+
# migration it applies, so a table rebuild has something to fall back to.
|
|
355
|
+
# Depends on M043 so the two never contend for the same table in one pass.
|
|
356
|
+
Migration(name=_M046.NAME, db_target="memory", ddl=_M046.DDL,
|
|
357
|
+
dependencies=(_M043.NAME,)),
|
|
358
|
+
# M047 rewrites the two Fisher vectors on each fact as float32 rather than
|
|
359
|
+
# as decimal text. Deferred because it walks every fact in atomic_facts,
|
|
360
|
+
# which engine init bootstraps. It changes no schema and both forms stay
|
|
361
|
+
# readable, so it is resumable and an interrupted store still works.
|
|
362
|
+
# Depends on M046 so a table rebuild and a full-table update never run in
|
|
363
|
+
# the same pass over the same table.
|
|
364
|
+
Migration(name=_M047.NAME, db_target="memory", ddl=_M047.DDL,
|
|
365
|
+
dependencies=(_M046.NAME,)),
|
|
366
|
+
# M048 finishes what M046 started: M046 renamed the type used for planned
|
|
367
|
+
# events without re-reading a single one of them, so the same wrongly-filed
|
|
368
|
+
# rows now carry a more confident name. Depends on M046 for the rename.
|
|
369
|
+
Migration(name=_M048.NAME, db_target="memory", ddl=_M048.DDL,
|
|
370
|
+
dependencies=(_M046.NAME,)),
|
|
371
|
+
# M049 gives schema_version the unique constraint its six writers all
|
|
372
|
+
# assumed it had. Every one uses INSERT OR IGNORE, which ignores nothing
|
|
373
|
+
# without a constraint, so each appended a duplicate per run: seven distinct
|
|
374
|
+
# versions held as 3,496 rows on one store and 234,348 on another. No
|
|
375
|
+
# dependency -- it touches a bookkeeping table no other migration reads.
|
|
376
|
+
Migration(name=_M049.NAME, db_target="memory", ddl=_M049.DDL),
|
|
315
377
|
]
|
|
316
378
|
|
|
317
379
|
|
|
@@ -579,6 +641,60 @@ def _deferred_already_applied(conn: sqlite3.Connection, name: str) -> bool:
|
|
|
579
641
|
return False
|
|
580
642
|
|
|
581
643
|
|
|
644
|
+
def _breaking_floor(learning_db: Path, memory_db: Path) -> int:
|
|
645
|
+
"""Highest floor declared by a migration that is recorded complete.
|
|
646
|
+
|
|
647
|
+
A migration declares ``BREAKING_VERSION`` when a store it has touched must
|
|
648
|
+
not be opened by an older build. Only completed ones count: a migration that
|
|
649
|
+
failed has not changed anything an older build would trip over.
|
|
650
|
+
"""
|
|
651
|
+
from superlocalmemory.storage._migration_internals import _MODULES
|
|
652
|
+
|
|
653
|
+
logs = {"learning": _read_log(learning_db), "memory": _read_log(memory_db)}
|
|
654
|
+
floor = 0
|
|
655
|
+
for migration in (*MIGRATIONS, *DEFERRED_MIGRATIONS):
|
|
656
|
+
module = _MODULES.get(migration.name)
|
|
657
|
+
declared = getattr(module, "BREAKING_VERSION", 0) if module else 0
|
|
658
|
+
if not declared:
|
|
659
|
+
continue
|
|
660
|
+
if logs.get(migration.db_target, {}).get(migration.name) == "complete":
|
|
661
|
+
floor = max(floor, int(declared))
|
|
662
|
+
return floor
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _stamp_breaking_floor(
|
|
666
|
+
learning_db: Path, memory_db: Path, details: dict[str, str],
|
|
667
|
+
) -> None:
|
|
668
|
+
"""Raise the recorded version to the highest completed breaking floor.
|
|
669
|
+
|
|
670
|
+
Monotonic: never lowers a stored version, so it cannot undo the completion
|
|
671
|
+
certificate on an already-current store. Never fatal — a store that cannot
|
|
672
|
+
be stamped is reported, because failing the whole run here would block an
|
|
673
|
+
upgrade over a guard that only matters to older builds.
|
|
674
|
+
"""
|
|
675
|
+
floor = _breaking_floor(learning_db, memory_db)
|
|
676
|
+
if floor <= 0:
|
|
677
|
+
return
|
|
678
|
+
for db_path in (learning_db, memory_db):
|
|
679
|
+
try:
|
|
680
|
+
current = _read_schema_version(db_path)
|
|
681
|
+
if current >= floor:
|
|
682
|
+
continue
|
|
683
|
+
conn = _connect(db_path)
|
|
684
|
+
try:
|
|
685
|
+
_ensure_schema_version_table(conn)
|
|
686
|
+
_write_schema_version(conn, floor)
|
|
687
|
+
finally:
|
|
688
|
+
try:
|
|
689
|
+
conn.close()
|
|
690
|
+
except sqlite3.Error: # pragma: no cover
|
|
691
|
+
pass
|
|
692
|
+
except sqlite3.Error as exc: # pragma: no cover — reported, not fatal
|
|
693
|
+
details["schema_version_floor"] = (
|
|
694
|
+
f"cannot raise the floor on {db_path}: {exc}"
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
582
698
|
def apply_deferred(
|
|
583
699
|
learning_db: Path,
|
|
584
700
|
memory_db: Path,
|
|
@@ -683,6 +799,20 @@ def apply_deferred(
|
|
|
683
799
|
except sqlite3.Error: # pragma: no cover
|
|
684
800
|
pass
|
|
685
801
|
|
|
802
|
+
# A migration that makes the store unusable by an older build declares a
|
|
803
|
+
# floor, and that floor is written as soon as the migration is recorded
|
|
804
|
+
# complete — BEFORE and independent of the completion certificate below.
|
|
805
|
+
#
|
|
806
|
+
# The certificate is all-or-nothing across both databases by design. That is
|
|
807
|
+
# right for "is this store fully migrated" and wrong for "may an older build
|
|
808
|
+
# write to it": an unrelated failure on the other database would otherwise
|
|
809
|
+
# leave a rebuilt table guarded by the old ceiling, and the first planned
|
|
810
|
+
# event an older build stored would be rejected by the new constraint and
|
|
811
|
+
# lost. Raising the floor turns that into a refusal to start, which is what
|
|
812
|
+
# the ceiling is for.
|
|
813
|
+
if not dry_run:
|
|
814
|
+
_stamp_breaking_floor(learning_db, memory_db, details)
|
|
815
|
+
|
|
686
816
|
# The version ceiling is a completion certificate, not an intent marker.
|
|
687
817
|
# M039 is deferred until engine-owned tables exist, so apply_all must not
|
|
688
818
|
# stamp version 39. Stamp both stores only after every eager and deferred
|