superlocalmemory 3.7.3 → 3.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.7.5] - 2026-07-18 — Complete Scale Engine projection parity
9
+
10
+ ### Improved
11
+
12
+ - Scale Engine vector preparation now reads the supported sqlite-vec virtual-table contract and joins vectors through canonical embedding metadata.
13
+ - LanceDB projection imports are profile-scoped, lifecycle-aware, and processed in bounded batches to keep migration memory stable.
14
+ - Scale verification and fingerprints now cover the same canonical vector rows that are written to LanceDB, so a partial vector projection cannot pass parity.
15
+
16
+ ## [3.7.4] - 2026-07-18 — Scale Engine projection-parity release
17
+
18
+ ### Fixed
19
+
20
+ - Aligned CozoDB projection parity with SLM's logical graph-edge identity while retaining the strongest relationship weight.
21
+ - Preserved canonical SQLite history when normalizing legacy repeated graph rows into derived scale projections.
22
+ - Kept rejected projection manifests inspectable, retired replaceable stage payloads, and allowed a corrected, explicitly confirmed adoption retry.
23
+ - Removed stale CozoDB graph edges when a fact is deleted and kept shadow-error telemetry side-effect free.
24
+
8
25
  ## [3.7.3] - 2026-07-18 — Scale Engine integrity release
9
26
 
10
27
  ### Fixed
package/README.md CHANGED
@@ -5,10 +5,10 @@
5
5
  </picture>
6
6
  </p>
7
7
 
8
- <h1 align="center">SuperLocalMemory V3.7.3</h1>
8
+ <h1 align="center">SuperLocalMemory V3.7.5</h1>
9
9
  <p align="center"><strong>Cache. Compress. Remember. Three surfaces — proxy, MCP tools, or skill. Every setup covered.</strong><br/>
10
10
  <em>Local-first agent memory with explicit operating modes, auditable retrieval, and optional Optimize tools.</em></p>
11
- <p align="center"><code>v3.7.3</code> — <strong>Scale Engine integrity release: explicit legacy adoption, durable promotion recovery, and honest runtime status.</strong><br/>
11
+ <p align="center"><code>v3.7.5</code> — <strong>Scale Engine graph and vector projection parity, durable promotion recovery, and honest runtime status.</strong><br/>
12
12
  Proxy: <code>slm wrap claude</code> &nbsp;·&nbsp; MCP: add <code>slm_compress</code> to your config &nbsp;·&nbsp; Skill: zero-config</p>
13
13
  <p align="center"><strong>3 public research preprints</strong> (arXiv + Zenodo archives) · <a href="https://arxiv.org/abs/2603.02240">arXiv:2603.02240</a> · <a href="https://arxiv.org/abs/2603.14588">arXiv:2603.14588</a> · <a href="https://arxiv.org/abs/2604.04514">arXiv:2604.04514</a></p>
14
14
 
@@ -111,7 +111,7 @@ health surfaces expose the stages actually completed by the installed runtime.
111
111
  | **Retrieval and recall** | Semantic, lexical, temporal, Hopfield and spreading-activation candidate channels; RRF fusion, optional reranking and graph score enhancement | Healthy channels participate; response provenance states the evidence used. |
112
112
  | **Brain and learning** | Behavioral patterns, feedback/outcome records, rewards, consolidation, LightGBM-related ranking components, soft prompts, and guarded skill-evolution workflows | Learning is evidence-driven; it does not claim autonomous correctness or guaranteed improvement. |
113
113
  | **Knowledge graph and entities** | Canonical entities, aliases, entity profiles, graph edges, scenes, timelines, explorer and graph APIs | Stored/derived graph data is evidence, not an instruction authority. |
114
- | **Scale Engine** | SQLite + sqlite-vec are canonical. CozoDB graph and LanceDB vector projections are managed with prepare → verify → promote → rollback; a structurally detected pre-v3.7 projection can be explicitly adopted. | Promotion is parity-gated and crash-recoverable. Legacy adoption preserves the prior projection as a rollback backup; if proof fails, routing remains on canonical SQLite and status reports repair required. |
114
+ | **Scale Engine** | SQLite + sqlite-vec are canonical. CozoDB graph and LanceDB vector projections are managed with prepare → verify → promote → rollback; a structurally detected pre-v3.7 projection can be explicitly adopted. | Promotion is parity-gated and crash-recoverable. Legacy adoption preserves the prior projection as a rollback backup; repeated physical edge rows normalize to one logical edge with the strongest weight. |
115
115
  | **Optimize** | Exact cache, tagged invalidation, safe compression, opt-in aggressive prose compression, CCR originals, proxy/MCP/skill surfaces | Only proxy intercepts a primary provider turn. MCP/skill cache results explicitly routed through SLM. |
116
116
  | **Mesh** | Authenticated peer messages, inbox/outbox, locks, offline queue, optional discovery and mesh MCP tools | Mesh is coordination, not automatic replicated memory or conflict resolution. |
117
117
  | **Governance and operations** | Provenance, audit/retention/policy surfaces, export/erasure controls, diagnostics, health, backups and daemon lifecycle | These are engineering controls, not a legal certification. |
@@ -238,7 +238,8 @@ SLM then rebuilds from canonical SQLite, verifies it, and promotes it with a
238
238
  durable recovery journal while retaining the prior directories as a rollback
239
239
  backup. `adopt` reports `restart_required: true`; run `slm restart` before
240
240
  checking daemon health. If proof fails, recall remains on SQLite and status
241
- reports repair required.
241
+ retains the rejected manifest for inspection, retires its replaceable derived
242
+ payload, and allows a corrected retry.
242
243
 
243
244
  Canonical ingestion is a durable state machine: `raw → queryable → enriching →
244
245
  complete`, with `failed` retaining raw evidence, error details, attempt count,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.7.3",
3
+ "version": "3.7.5",
4
4
  "description": "Local-first agent memory with MCP and an agent-native CLI. Documented clients include Claude Code, Cursor, and Windsurf.",
5
5
  "keywords": [
6
6
  "ai-memory",
@@ -16,5 +16,5 @@
16
16
  "mcpServers": "./.mcp.json",
17
17
  "name": "superlocalmemory",
18
18
  "repository": "https://github.com/qualixar/superlocalmemory",
19
- "version": "3.7.3"
19
+ "version": "3.7.5"
20
20
  }
@@ -1 +1 @@
1
- superlocalmemory==3.7.3
1
+ superlocalmemory==3.7.5
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.7.3",
2
+ "version": "3.7.5",
3
3
  "pluginName": "superlocalmemory",
4
4
  "displayName": "SuperLocalMemory",
5
5
  "repository": "https://github.com/qualixar/superlocalmemory",
@@ -1 +1 @@
1
- superlocalmemory==3.7.3
1
+ superlocalmemory==3.7.5
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.7.3"
3
+ version = "3.7.5"
4
4
  description = "Local-first agent memory with auditable hybrid retrieval"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-or-later"
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
32
32
  os.environ["OMP_NUM_THREADS"] = "2"
33
33
  # ---------------------------------------------------------------------------
34
34
 
35
- __version__ = "3.7.3"
35
+ __version__ = "3.7.5"
36
36
 
37
37
  _REQUIRED_VERSIONS = {
38
38
  "sentence_transformers": "5.3.0",
@@ -8,6 +8,7 @@ from argparse import Namespace
8
8
  def cmd_db_scale(args: Namespace) -> int:
9
9
  from superlocalmemory.core.config import SLMConfig
10
10
  from superlocalmemory.core.scale_engine import ScaleEngineError, ScaleEngineManager
11
+ from superlocalmemory.storage.sqlite_vectors import CanonicalVectorError
11
12
 
12
13
  action = args.scale_action
13
14
  # Scale Engine projections are currently canonical-default-profile data.
@@ -42,7 +43,7 @@ def cmd_db_scale(args: Namespace) -> int:
42
43
  result = manager.rollback(args.backup_id)
43
44
  else:
44
45
  raise ScaleEngineError(f"unknown Scale Engine action: {action}")
45
- except ScaleEngineError as exc:
46
+ except (ScaleEngineError, CanonicalVectorError) as exc:
46
47
  print(f"Scale Engine: {exc}")
47
48
  return 1
48
49
  print(json.dumps(result, indent=2, sort_keys=True))
@@ -22,6 +22,14 @@ from datetime import UTC, datetime
22
22
  from pathlib import Path
23
23
  from typing import Any, Callable
24
24
 
25
+ from superlocalmemory.storage.logical_edges import count_logical_edges, iter_logical_edges
26
+ from superlocalmemory.storage.sqlite_vectors import (
27
+ CanonicalVectorError,
28
+ count_canonical_vectors,
29
+ iter_canonical_vectors,
30
+ load_sqlite_vec_extension,
31
+ )
32
+
25
33
 
26
34
  class ScaleEngineError(RuntimeError):
27
35
  """A scale projection cannot safely advance to its next lifecycle state."""
@@ -98,8 +106,8 @@ class ScaleEngineManager:
98
106
  legacy_projection_candidate = (
99
107
  state == "local_core"
100
108
  and all(paths_present.values())
101
- and not manifests
102
109
  and not backups
110
+ and not self.promotion_journal_path.exists()
103
111
  and self._has_legacy_projection_layout()
104
112
  )
105
113
  runtime = self._runtime_backend_status()
@@ -117,7 +125,17 @@ class ScaleEngineManager:
117
125
  "legacy_candidate_requires_confirmation": legacy_projection_candidate,
118
126
  "migration_repair_required": (
119
127
  self.promotion_journal_path.exists()
120
- or (state == "local_core" and bool(manifests))
128
+ or any(
129
+ manifest.get("state") == "promoted" and state != "promoted"
130
+ for manifest in manifests
131
+ )
132
+ or (
133
+ state == "promoted"
134
+ and any(
135
+ manifest.get("state") in {"prepared", "verified"}
136
+ for manifest in manifests
137
+ )
138
+ )
121
139
  ),
122
140
  "stages": manifests,
123
141
  "backups": backups,
@@ -135,26 +153,91 @@ class ScaleEngineManager:
135
153
  if not self.status()["legacy_projection_candidate"]:
136
154
  return None
137
155
  lock_path = self._acquire_lifecycle_lock()
156
+ prepared: dict[str, Any] | None = None
138
157
  try:
139
158
  # Re-check inside the lock. A concurrent command may have
140
159
  # completed promotion while this caller waited to acquire it.
141
- if not self.status()["legacy_projection_candidate"]:
160
+ current_status = self.status()
161
+ if not current_status["legacy_projection_candidate"]:
142
162
  return None
163
+ retry_payloads = [
164
+ manifest["stage_id"]
165
+ for manifest in current_status["stages"]
166
+ if manifest.get("state") in {"prepared", "verified"}
167
+ and manifest.get("stage_id")
168
+ ]
143
169
  prepared = self._prepare()
144
170
  self._verify(prepared["stage_id"])
145
- return self._promote(prepared["stage_id"])
146
- except Exception:
171
+ promoted = self._promote(prepared["stage_id"])
172
+ retired: list[str] = []
173
+ retirement_failures: dict[str, str] = {}
174
+ for stage_id in retry_payloads:
175
+ try:
176
+ self._retire_superseded_stage(stage_id)
177
+ retired.append(stage_id)
178
+ except Exception as cleanup_exc:
179
+ retirement_failures[stage_id] = str(cleanup_exc)
180
+ return {
181
+ **promoted,
182
+ "retired_stages": retired,
183
+ "retirement_failures": retirement_failures,
184
+ }
185
+ except Exception as exc:
147
186
  # A failed adoption must leave the canonical path selected. The
148
- # stage is retained for operator inspection instead of deleting
149
- # evidence about why an upgrade could not be completed.
150
- self.config.scale_engine_state = "local_core"
187
+ # manifest is retained for inspection, but its replaceable Cozo
188
+ # and Lance payloads are retired so repeated retries cannot grow
189
+ # the data root without bound.
190
+ retirement_error: Exception | None = None
191
+ unresolved_promotion = self.promotion_journal_path.exists()
192
+ if prepared is not None and not unresolved_promotion:
193
+ try:
194
+ self._retire_rejected_stage(prepared["stage_id"], exc)
195
+ except Exception as cleanup_exc:
196
+ retirement_error = cleanup_exc
197
+ self.config.scale_engine_state = (
198
+ "verified" if unresolved_promotion else "local_core"
199
+ )
151
200
  self.config.graph_backend = "auto"
152
201
  self.config.vector_backend = "auto"
153
202
  self._save_config()
203
+ if retirement_error is not None:
204
+ raise ScaleEngineError(
205
+ f"{exc}; rejected stage retirement failed: {retirement_error}"
206
+ ) from exc
154
207
  raise
155
208
  finally:
156
209
  self._release_lifecycle_lock(lock_path)
157
210
 
211
+ def _retire_rejected_stage(self, stage_id: str, error: Exception) -> None:
212
+ """Keep rejection evidence while removing replaceable projection data."""
213
+ self._retire_stage_payload(
214
+ stage_id,
215
+ {
216
+ "state": "rejected",
217
+ "rejected_at": _utc_now(),
218
+ "failure": f"{type(error).__name__}: {error}",
219
+ },
220
+ )
221
+
222
+ def _retire_superseded_stage(self, stage_id: str) -> None:
223
+ """Retain an old retry manifest after a newer projection is promoted."""
224
+ self._retire_stage_payload(
225
+ stage_id,
226
+ {"state": "superseded", "superseded_at": _utc_now()},
227
+ )
228
+
229
+ def _retire_stage_payload(
230
+ self, stage_id: str, manifest_updates: dict[str, Any]
231
+ ) -> None:
232
+ """Remove derived stage bytes while retaining its durable manifest."""
233
+ stage_dir, manifest = self._load_stage(stage_id)
234
+ for payload in (stage_dir / "cozo", stage_dir / "lance"):
235
+ if payload.exists():
236
+ shutil.rmtree(payload)
237
+ self._fsync_directory(stage_dir)
238
+ manifest.update(manifest_updates)
239
+ self._write_manifest(stage_dir, manifest)
240
+
158
241
  def prepare(self) -> dict[str, Any]:
159
242
  """Build a new projection in a private staging directory."""
160
243
  lock_path = self._acquire_lifecycle_lock()
@@ -195,6 +278,9 @@ class ScaleEngineManager:
195
278
  self.config.scale_engine_state = "prepared"
196
279
  self._save_config()
197
280
  return manifest
281
+ except CanonicalVectorError as exc:
282
+ shutil.rmtree(stage_dir, ignore_errors=True)
283
+ raise ScaleEngineError(f"canonical vector projection failed: {exc}") from exc
198
284
  except Exception:
199
285
  shutil.rmtree(stage_dir, ignore_errors=True)
200
286
  raise
@@ -208,6 +294,8 @@ class ScaleEngineManager:
208
294
  try:
209
295
  self._recover_interrupted_promotion()
210
296
  return self._verify(stage_id)
297
+ except CanonicalVectorError as exc:
298
+ raise ScaleEngineError(f"canonical vector projection failed: {exc}") from exc
211
299
  finally:
212
300
  self._release_lifecycle_lock(lock_path)
213
301
 
@@ -224,7 +312,9 @@ class ScaleEngineManager:
224
312
  source_fingerprint = self._projection_fingerprint(conn, canonical)
225
313
  observed = self._observed_counts(cozo, lance)
226
314
  if manifest["source_fingerprint"] != source_fingerprint:
227
- raise ScaleEngineError("canonical SQLite changed after preparation; prepare a new stage")
315
+ raise ScaleEngineError(
316
+ "canonical SQLite changed after preparation; prepare a new stage"
317
+ )
228
318
  if canonical != manifest["canonical"] or observed != canonical:
229
319
  raise ScaleEngineError(
230
320
  f"projection parity failed: canonical={canonical}, observed={observed}"
@@ -244,6 +334,8 @@ class ScaleEngineManager:
244
334
  try:
245
335
  self._recover_interrupted_promotion()
246
336
  return self._promote(stage_id)
337
+ except CanonicalVectorError as exc:
338
+ raise ScaleEngineError(f"canonical vector projection failed: {exc}") from exc
247
339
  finally:
248
340
  self._release_lifecycle_lock(lock_path)
249
341
 
@@ -264,7 +356,9 @@ class ScaleEngineManager:
264
356
  gate.execute("BEGIN IMMEDIATE")
265
357
  canonical = self._canonical_counts(gate)
266
358
  if manifest["source_fingerprint"] != self._projection_fingerprint(gate, canonical):
267
- raise ScaleEngineError("canonical SQLite changed after verification; prepare a new stage")
359
+ raise ScaleEngineError(
360
+ "canonical SQLite changed after verification; prepare a new stage"
361
+ )
268
362
  self._mkdir_durable(self.backup_root)
269
363
  journal = {
270
364
  "schema_version": self.SCHEMA_VERSION,
@@ -291,7 +385,13 @@ class ScaleEngineManager:
291
385
  self._replace_durable(destination, source)
292
386
  move["state"] = "complete"
293
387
  self._write_promotion_journal(journal)
294
- manifest.update({"state": "promoted", "promoted_at": _utc_now(), "backup_id": backup_dir.name})
388
+ manifest.update(
389
+ {
390
+ "state": "promoted",
391
+ "promoted_at": _utc_now(),
392
+ "backup_id": backup_dir.name,
393
+ }
394
+ )
295
395
  self._write_manifest(stage_dir, manifest)
296
396
  self.config.scale_engine_state = "promoted"
297
397
  self.config.graph_backend = "cozo"
@@ -308,11 +408,14 @@ class ScaleEngineManager:
308
408
  except sqlite3.Error:
309
409
  pass
310
410
  try:
311
- self._recover_interrupted_promotion()
312
- except ScaleEngineError as recovery_error:
411
+ recovery = self._recover_interrupted_promotion()
412
+ except Exception as recovery_error:
313
413
  raise ScaleEngineError(
314
414
  f"promotion interrupted; automatic recovery needs repair: {recovery_error}"
315
415
  ) from exc
416
+ if recovery == "finalized_committed_promotion":
417
+ _, recovered_manifest = self._load_stage(stage_id)
418
+ return recovered_manifest
316
419
  raise ScaleEngineError(f"promotion rolled back: {exc}") from exc
317
420
  finally:
318
421
  gate.close()
@@ -367,7 +470,11 @@ class ScaleEngineManager:
367
470
  journal["state"] = "committed"
368
471
  self._write_promotion_journal(journal)
369
472
  self.promotion_journal_path.unlink(missing_ok=True)
370
- return {"state": "local_core", "restored_backup": backup_id, "displaced": displaced.name}
473
+ return {
474
+ "state": "local_core",
475
+ "restored_backup": backup_id,
476
+ "displaced": displaced.name,
477
+ }
371
478
  except Exception as exc:
372
479
  try:
373
480
  self._recover_interrupted_promotion()
@@ -385,6 +492,7 @@ class ScaleEngineManager:
385
492
  def _readonly_connection(self) -> sqlite3.Connection:
386
493
  conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True)
387
494
  conn.execute("PRAGMA query_only=ON")
495
+ load_sqlite_vec_extension(conn)
388
496
  return conn
389
497
 
390
498
  def _canonical_counts(self, conn: sqlite3.Connection) -> dict[str, int]:
@@ -392,18 +500,8 @@ class ScaleEngineManager:
392
500
  "SELECT COUNT(*) FROM canonical_entities WHERE profile_id=?",
393
501
  (self.profile_id,),
394
502
  ).fetchone()[0]
395
- edges = conn.execute(
396
- "SELECT COUNT(*) FROM graph_edges WHERE profile_id=?", (self.profile_id,)
397
- ).fetchone()[0]
398
- try:
399
- vectors = conn.execute(
400
- "SELECT COUNT(*) FROM fact_embeddings_rowids fer "
401
- "JOIN atomic_facts af ON af.fact_id = fer.fact_id "
402
- "WHERE af.profile_id = ?",
403
- (self.profile_id,),
404
- ).fetchone()[0]
405
- except sqlite3.OperationalError:
406
- vectors = 0
503
+ edges = count_logical_edges(conn, self.profile_id)
504
+ vectors = count_canonical_vectors(conn, self.profile_id)
407
505
  return {"entities": int(nodes), "edges": int(edges), "vectors": int(vectors)}
408
506
 
409
507
  def _observed_counts(self, cozo: Any, lance: Any) -> dict[str, int]:
@@ -411,7 +509,11 @@ class ScaleEngineManager:
411
509
  vector = lance.health_check()
412
510
  if graph.get("status") != "active" or vector.get("status") != "active":
413
511
  raise ScaleEngineError(f"projection health failed: cozo={graph}, lancedb={vector}")
414
- return {"entities": int(graph["entities"]), "edges": int(graph["edges"]), "vectors": int(vector["vectors"])}
512
+ return {
513
+ "entities": int(graph["entities"]),
514
+ "edges": int(graph["edges"]),
515
+ "vectors": int(vector["vectors"]),
516
+ }
415
517
 
416
518
  def _projection_fingerprint(
417
519
  self, conn: sqlite3.Connection, counts: dict[str, int]
@@ -420,9 +522,13 @@ class ScaleEngineManager:
420
522
  digest = hashlib.sha256()
421
523
  digest.update(json.dumps(counts, sort_keys=True).encode())
422
524
  tables = (
423
- ("canonical_entities", "entity_id, canonical_name, entity_type, first_seen, last_seen, fact_count, profile_id", "entity_id"),
525
+ (
526
+ "canonical_entities",
527
+ "entity_id, canonical_name, entity_type, first_seen, last_seen, "
528
+ "fact_count, profile_id",
529
+ "entity_id",
530
+ ),
424
531
  ("atomic_facts", "fact_id, canonical_entities_json, lifecycle, profile_id", "fact_id"),
425
- ("graph_edges", "source_id, target_id, edge_type, weight, profile_id", "source_id, target_id, edge_type"),
426
532
  )
427
533
  for table, columns, ordering in tables:
428
534
  try:
@@ -435,17 +541,12 @@ class ScaleEngineManager:
435
541
  except sqlite3.OperationalError as exc:
436
542
  raise ScaleEngineError(f"canonical SQLite missing required {table} table") from exc
437
543
  try:
438
- rows = conn.execute(
439
- "SELECT fer.rowid, fer.fact_id, vec.vector FROM fact_embeddings_rowids fer "
440
- "JOIN atomic_facts af ON af.fact_id = fer.fact_id "
441
- "LEFT JOIN fact_embeddings_vector_chunks00 vec ON vec.rowid = fer.rowid "
442
- "WHERE af.profile_id=? ORDER BY fer.rowid",
443
- (self.profile_id,),
444
- )
445
- for row in rows:
446
- self._digest_row(digest, "fact_embeddings_rowids", row)
447
- except sqlite3.OperationalError:
448
- pass
544
+ for row in iter_logical_edges(conn, self.profile_id):
545
+ self._digest_row(digest, "graph_edges", row)
546
+ except sqlite3.OperationalError as exc:
547
+ raise ScaleEngineError("canonical SQLite missing required graph_edges table") from exc
548
+ for row in iter_canonical_vectors(conn, self.profile_id):
549
+ self._digest_row(digest, "fact_embeddings", row)
449
550
  return digest.hexdigest()
450
551
 
451
552
  @staticmethod
@@ -494,7 +595,9 @@ class ScaleEngineManager:
494
595
 
495
596
  def _require_default_profile(self) -> None:
496
597
  if self.profile_id != "default":
497
- raise ScaleEngineError("Scale Engine promotion currently supports the default profile only")
598
+ raise ScaleEngineError(
599
+ "Scale Engine promotion currently supports the default profile only"
600
+ )
498
601
 
499
602
  def _require_canonical_db(self) -> None:
500
603
  if not self.db_path.exists():
@@ -24,6 +24,8 @@ from datetime import datetime
24
24
  from pathlib import Path
25
25
  from typing import Any
26
26
 
27
+ from superlocalmemory.storage.logical_edges import iter_logical_edges
28
+
27
29
  logger = logging.getLogger(__name__)
28
30
 
29
31
  # Optional import — CozoDB is an optional dependency
@@ -260,6 +262,12 @@ class CozoDBGraphBackend:
260
262
  *fact_entity{fact_id, entity_id, profile_id}, fact_id = $fact_id
261
263
  :rm fact_entity {fact_id, entity_id => profile_id}
262
264
  """, {"fact_id": fact_id})
265
+ self._db.run("""
266
+ ?[from_id, to_id, edge_type, weight, metadata, profile_id, created_at] :=
267
+ *edge{from_id, to_id, edge_type, weight, metadata, profile_id, created_at},
268
+ (from_id = $fact_id or to_id = $fact_id)
269
+ :rm edge {from_id, to_id, edge_type => weight, metadata, profile_id, created_at}
270
+ """, {"fact_id": fact_id})
263
271
 
264
272
  def record_shadow_comparison(
265
273
  self,
@@ -280,12 +288,6 @@ class CozoDBGraphBackend:
280
288
  def record_shadow_error(self, error: str) -> None:
281
289
  self._shadow_errors += 1
282
290
  logger.warning("Cozo entity recall failed closed to SQLite: %s", error)
283
- self._db.run("""
284
- ?[from_id, to_id, edge_type, weight, metadata, profile_id, created_at] :=
285
- *edge{from_id, to_id, edge_type, weight, metadata, profile_id, created_at},
286
- (from_id = $fact_id or to_id = $fact_id)
287
- :rm edge {from_id, to_id, edge_type => weight, metadata, profile_id, created_at}
288
- """, {"fact_id": fact_id})
289
291
 
290
292
  # ------------------------------------------------------------------
291
293
  # Bulk Import (SQLite → CozoDB)
@@ -358,22 +360,15 @@ class CozoDBGraphBackend:
358
360
 
359
361
  # Step 3: Export fact graph edges directly. Fact graph traversal is
360
362
  # intentionally kept in its native fact-ID namespace.
361
- edges_sql = """
362
- SELECT source_id, target_id, edge_type, weight
363
- FROM graph_edges WHERE profile_id = ?
364
- """
365
- edge_rows = conn.execute(edges_sql, (profile_id,)).fetchall()
366
-
367
363
  edge_dicts = []
368
- for row in edge_rows:
369
- ea, eb, etype, weight = row
364
+ for ea, eb, etype, weight, edge_profile in iter_logical_edges(conn, profile_id):
370
365
  edge_dicts.append({
371
366
  "from_id": ea,
372
367
  "to_id": eb,
373
- "edge_type": etype or "related",
374
- "weight": float(weight or 1.0),
368
+ "edge_type": etype,
369
+ "weight": float(weight),
375
370
  "metadata": "{}",
376
- "profile_id": profile_id,
371
+ "profile_id": edge_profile,
377
372
  "created_at": now,
378
373
  })
379
374
 
@@ -0,0 +1,45 @@
1
+ """Canonical logical-edge projection shared by scale backends.
2
+
3
+ Legacy databases can contain multiple physical ``graph_edges`` rows for one
4
+ logical relationship. Current writes define identity as profile, source,
5
+ target, and edge type, retaining the strongest weight. Derived projections
6
+ must use that same contract without rewriting canonical SQLite history.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ from collections.abc import Iterator
12
+ from typing import Any
13
+
14
+ _LOGICAL_EDGE_SELECT = """
15
+ SELECT
16
+ source_id,
17
+ target_id,
18
+ COALESCE(edge_type, 'related') AS edge_type,
19
+ MAX(COALESCE(weight, 1.0)) AS weight,
20
+ profile_id
21
+ FROM graph_edges
22
+ WHERE profile_id = ?
23
+ GROUP BY profile_id, source_id, target_id, COALESCE(edge_type, 'related')
24
+ """
25
+
26
+
27
+ def iter_logical_edges(
28
+ conn: sqlite3.Connection, profile_id: str
29
+ ) -> Iterator[tuple[Any, ...]]:
30
+ """Yield normalized graph edges in deterministic fingerprint order."""
31
+ return iter(
32
+ conn.execute(
33
+ _LOGICAL_EDGE_SELECT + " ORDER BY source_id, target_id, edge_type",
34
+ (profile_id,),
35
+ )
36
+ )
37
+
38
+
39
+ def count_logical_edges(conn: sqlite3.Connection, profile_id: str) -> int:
40
+ """Count relationships using the canonical logical identity."""
41
+ row = conn.execute(
42
+ "SELECT COUNT(*) FROM (" + _LOGICAL_EDGE_SELECT + ")",
43
+ (profile_id,),
44
+ ).fetchone()
45
+ return int(row[0] if row else 0)
@@ -0,0 +1,169 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Canonical sqlite-vec export contract for derived vector projections."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import sqlite3
9
+ from collections.abc import Iterator
10
+ from typing import TypeAlias
11
+
12
+
13
+ class CanonicalVectorError(RuntimeError):
14
+ """Canonical sqlite-vec data cannot be read without risking data loss."""
15
+
16
+
17
+ CanonicalVector: TypeAlias = tuple[int, str, str, str, bytes]
18
+
19
+
20
+ def load_sqlite_vec_extension(conn: sqlite3.Connection) -> None:
21
+ """Load sqlite-vec for this connection or fail the migration explicitly."""
22
+ try:
23
+ import sqlite_vec
24
+
25
+ conn.enable_load_extension(True)
26
+ sqlite_vec.load(conn)
27
+ except Exception as exc:
28
+ raise CanonicalVectorError(
29
+ "sqlite-vec could not be loaded; refusing to treat canonical vectors as empty"
30
+ ) from exc
31
+ finally:
32
+ try:
33
+ conn.enable_load_extension(False)
34
+ except (AttributeError, sqlite3.Error):
35
+ pass
36
+
37
+
38
+ def count_canonical_vectors(conn: sqlite3.Connection, profile_id: str) -> int:
39
+ """Count profile-scoped vectors that have a canonical fact identity."""
40
+ if not _canonical_vector_table_exists(conn):
41
+ return 0
42
+ load_sqlite_vec_extension(conn)
43
+ return _validate_canonical_vector_contract(conn, profile_id)
44
+
45
+
46
+ def iter_canonical_vectors(
47
+ conn: sqlite3.Connection, profile_id: str
48
+ ) -> Iterator[CanonicalVector]:
49
+ """Yield supported vec0 rows joined to canonical fact identity and lifecycle."""
50
+ if not _canonical_vector_table_exists(conn):
51
+ return
52
+ load_sqlite_vec_extension(conn)
53
+ _validate_canonical_vector_contract(conn, profile_id)
54
+ try:
55
+ rows = conn.execute(
56
+ "SELECT fe.rowid, em.fact_id, COALESCE(af.lifecycle, 'active'), "
57
+ "af.profile_id, fe.embedding "
58
+ "FROM fact_embeddings fe "
59
+ "JOIN embedding_metadata em ON em.vec_rowid = fe.rowid "
60
+ "JOIN atomic_facts af ON af.fact_id = em.fact_id "
61
+ "AND af.profile_id = em.profile_id "
62
+ "WHERE af.profile_id = ? AND fe.profile_id = af.profile_id "
63
+ "ORDER BY fe.rowid",
64
+ (profile_id,),
65
+ )
66
+ for rowid, fact_id, lifecycle, row_profile_id, blob in rows:
67
+ if not isinstance(blob, bytes):
68
+ raise CanonicalVectorError(
69
+ f"canonical vector {rowid} is not a float32 blob"
70
+ )
71
+ yield int(rowid), str(fact_id), str(lifecycle), str(row_profile_id), blob
72
+ except sqlite3.Error as exc:
73
+ raise CanonicalVectorError(
74
+ "canonical sqlite-vec rows are unreadable; refusing a partial projection"
75
+ ) from exc
76
+
77
+
78
+ def _validate_canonical_vector_contract(
79
+ conn: sqlite3.Connection, profile_id: str
80
+ ) -> int:
81
+ """Prove every profile-owned metadata row maps to one vec0 partition row."""
82
+ try:
83
+ misowned = int(
84
+ conn.execute(
85
+ "SELECT COUNT(*) FROM embedding_metadata em "
86
+ "LEFT JOIN atomic_facts af ON af.fact_id = em.fact_id "
87
+ "WHERE (em.profile_id = ? OR af.profile_id = ?) "
88
+ "AND (af.fact_id IS NULL OR af.profile_id <> em.profile_id)",
89
+ (profile_id, profile_id),
90
+ ).fetchone()[0]
91
+ )
92
+ expected = int(
93
+ conn.execute(
94
+ "SELECT COUNT(*) FROM embedding_metadata em "
95
+ "JOIN atomic_facts af ON af.fact_id = em.fact_id "
96
+ "AND af.profile_id = em.profile_id "
97
+ "WHERE af.profile_id = ?",
98
+ (profile_id,),
99
+ ).fetchone()[0]
100
+ )
101
+ mapped = int(
102
+ conn.execute(
103
+ "SELECT COUNT(*) FROM embedding_metadata em "
104
+ "JOIN atomic_facts af ON af.fact_id = em.fact_id "
105
+ "AND af.profile_id = em.profile_id "
106
+ "JOIN fact_embeddings fe ON fe.rowid = em.vec_rowid "
107
+ "AND fe.profile_id = af.profile_id "
108
+ "WHERE af.profile_id = ?",
109
+ (profile_id,),
110
+ ).fetchone()[0]
111
+ )
112
+ except sqlite3.Error as exc:
113
+ raise CanonicalVectorError(
114
+ "canonical vector contract is unreadable; refusing a partial projection"
115
+ ) from exc
116
+ if misowned:
117
+ raise CanonicalVectorError(
118
+ f"canonical vector ownership mismatch for {misowned} metadata row(s)"
119
+ )
120
+ if mapped != expected:
121
+ raise CanonicalVectorError(
122
+ "canonical vector mapping is incomplete: "
123
+ f"metadata={expected}, mapped_vec0={mapped}"
124
+ )
125
+ return expected
126
+
127
+
128
+ def _canonical_vector_table_exists(conn: sqlite3.Connection) -> bool:
129
+ row = conn.execute(
130
+ "SELECT 1 FROM sqlite_master WHERE name='fact_embeddings' "
131
+ "AND type='table'"
132
+ ).fetchone()
133
+ if row is not None:
134
+ return True
135
+ metadata = conn.execute(
136
+ "SELECT 1 FROM sqlite_master WHERE name='embedding_metadata' "
137
+ "AND type='table'"
138
+ ).fetchone()
139
+ if metadata is not None:
140
+ try:
141
+ metadata_count = int(
142
+ conn.execute("SELECT COUNT(*) FROM embedding_metadata").fetchone()[0]
143
+ )
144
+ except sqlite3.Error as exc:
145
+ raise CanonicalVectorError("embedding metadata is unreadable") from exc
146
+ if metadata_count:
147
+ raise CanonicalVectorError(
148
+ "embedding metadata exists but fact_embeddings is missing"
149
+ )
150
+ shadow_payload = 0
151
+ for table in ("fact_embeddings_rowids", "fact_embeddings_vector_chunks00"):
152
+ exists = conn.execute(
153
+ "SELECT 1 FROM sqlite_master WHERE name=? AND type='table'",
154
+ (table,),
155
+ ).fetchone()
156
+ if exists is not None:
157
+ try:
158
+ shadow_payload += int(
159
+ conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
160
+ )
161
+ except sqlite3.Error as exc:
162
+ raise CanonicalVectorError(
163
+ "sqlite-vec shadow payload is unreadable"
164
+ ) from exc
165
+ if shadow_payload:
166
+ raise CanonicalVectorError(
167
+ "sqlite-vec shadow payload exists without the fact_embeddings virtual table"
168
+ )
169
+ return False
@@ -20,6 +20,8 @@ import struct
20
20
  from pathlib import Path
21
21
  from typing import Any
22
22
 
23
+ from superlocalmemory.storage.sqlite_vectors import iter_canonical_vectors
24
+
23
25
  logger = logging.getLogger(__name__)
24
26
 
25
27
  # Optional import
@@ -178,80 +180,49 @@ class LanceDBVectorBackend:
178
180
  ) -> int:
179
181
  """Export embeddings from sqlite-vec → LanceDB.
180
182
 
181
- sqlite-vec stores vectors as raw float32 little-endian blobs
182
- in fact_embeddings_vector_chunks00, with rowid mapping in
183
- fact_embeddings_rowids.
183
+ Reads the supported vec0 virtual table and joins row IDs through
184
+ ``embedding_metadata``. Shadow-table layouts are sqlite-vec internals
185
+ and must not be treated as a stable migration API.
184
186
 
185
187
  Returns number of vectors imported.
186
188
  """
187
- # Get rowid → fact_id mapping
188
- row_map: dict[int, str] = {}
189
- try:
190
- for row in conn.execute("SELECT rowid, fact_id FROM fact_embeddings_rowids"):
191
- row_map[row[0]] = row[1]
192
- except sqlite3.OperationalError:
193
- logger.warning("fact_embeddings_rowids not found — no vectors to import")
194
- return 0
195
-
196
- # Get tiers
197
- tier_map: dict[str, str] = {}
198
- profile_map: dict[str, str] = {}
199
- try:
200
- for row in conn.execute(
201
- "SELECT fact_id, COALESCE(lifecycle, 'active'), profile_id "
202
- "FROM atomic_facts WHERE profile_id = ?",
203
- (profile_id,),
204
- ):
205
- tier_map[row[0]] = row[1]
206
- profile_map[row[0]] = row[2] or "default"
207
- except sqlite3.OperationalError:
208
- pass
209
-
210
- # Read vectors from sqlite-vec
211
- try:
212
- rows = conn.execute(
213
- "SELECT rowid, vector FROM fact_embeddings_vector_chunks00"
214
- ).fetchall()
215
- except sqlite3.OperationalError:
216
- logger.warning("fact_embeddings_vector_chunks00 not found")
217
- return 0
218
-
219
- # Reconstruct and batch import
220
- data = []
221
- for rowid, blob in rows:
222
- fact_id = row_map.get(rowid)
223
- # The rowid mapping is global, but a staged Scale Engine is
224
- # explicitly profile-scoped. Do not import a foreign profile by
225
- # giving it a default tier/profile below.
226
- if fact_id is None or fact_id not in profile_map:
227
- continue
189
+ imported = 0
190
+ batch: list[tuple[str, list[float], str, str]] = []
191
+ for rowid, fact_id, tier, row_profile_id, blob in iter_canonical_vectors(
192
+ conn, profile_id
193
+ ):
228
194
  try:
229
195
  vector = self._decode_vector_blob(blob)
230
196
  except Exception as exc:
231
- logger.warning("Failed to decode vector for rowid %d: %s", rowid, exc)
232
- continue
233
- tier = tier_map.get(fact_id, "active")
234
- data.append({
235
- "fact_id": fact_id,
236
- "vector": vector,
237
- "tier": tier,
238
- "profile_id": profile_map.get(fact_id, profile_id),
239
- })
240
-
241
- if data:
242
- by_profile: dict[str, list[dict[str, Any]]] = {}
243
- for item in data:
244
- by_profile.setdefault(item["profile_id"], []).append(item)
245
- for record_profile_id, records in by_profile.items():
246
- self.add_vectors(
247
- [item["fact_id"] for item in records],
248
- [item["vector"] for item in records],
249
- [item["tier"] for item in records],
250
- record_profile_id,
251
- )
252
-
253
- logger.info("LanceDB: imported %d vectors from sqlite-vec", len(data))
254
- return len(data)
197
+ raise LanceDBError(
198
+ f"Invalid canonical vector for rowid {rowid}: {exc}"
199
+ ) from exc
200
+ batch.append((fact_id, vector, tier, row_profile_id))
201
+ if len(batch) >= 256:
202
+ imported += self._flush_import_batch(batch)
203
+ batch.clear()
204
+ if batch:
205
+ imported += self._flush_import_batch(batch)
206
+
207
+ logger.info("LanceDB: imported %d vectors from sqlite-vec", imported)
208
+ return imported
209
+
210
+ def _flush_import_batch(
211
+ self, batch: list[tuple[str, list[float], str, str]]
212
+ ) -> int:
213
+ """Write one bounded-memory, single-profile canonical vector batch."""
214
+ by_profile: dict[str, list[tuple[str, list[float], str]]] = {}
215
+ for fact_id, vector, tier, profile_id in batch:
216
+ by_profile.setdefault(profile_id, []).append((fact_id, vector, tier))
217
+ imported = 0
218
+ for profile_id, records in by_profile.items():
219
+ imported += self.add_vectors(
220
+ [item[0] for item in records],
221
+ [item[1] for item in records],
222
+ [item[2] for item in records],
223
+ profile_id,
224
+ )
225
+ return imported
255
226
 
256
227
  def _decode_vector_blob(self, blob: bytes) -> list[float]:
257
228
  """Decode sqlite-vec BLOB to list of floats.