superlocalmemory 3.4.19 → 3.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +3 -2
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +219 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/engine.py +38 -2
  31. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  32. package/src/superlocalmemory/core/ram_lock.py +111 -0
  33. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  34. package/src/superlocalmemory/core/recall_worker.py +8 -3
  35. package/src/superlocalmemory/core/security_primitives.py +635 -0
  36. package/src/superlocalmemory/core/shadow_router.py +319 -0
  37. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  38. package/src/superlocalmemory/core/slmignore.py +125 -0
  39. package/src/superlocalmemory/core/topic_signature.py +143 -0
  40. package/src/superlocalmemory/core/worker_pool.py +14 -3
  41. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  42. package/src/superlocalmemory/evolution/budget.py +321 -0
  43. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  44. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  45. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  46. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  47. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  48. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  49. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  50. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  51. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  52. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  53. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  54. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  55. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  56. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  57. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  58. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  59. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  60. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  61. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  62. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  63. package/src/superlocalmemory/infra/backup.py +3 -3
  64. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  65. package/src/superlocalmemory/infra/event_bus.py +2 -2
  66. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  67. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  68. package/src/superlocalmemory/learning/bandit.py +526 -0
  69. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  70. package/src/superlocalmemory/learning/behavioral.py +53 -1
  71. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  72. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  73. package/src/superlocalmemory/learning/database.py +256 -0
  74. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  75. package/src/superlocalmemory/learning/ensemble.py +300 -0
  76. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  77. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  78. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  79. package/src/superlocalmemory/learning/labeler.py +87 -0
  80. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  81. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  82. package/src/superlocalmemory/learning/model_cache.py +269 -0
  83. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  84. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  85. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  86. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  87. package/src/superlocalmemory/learning/ranker.py +225 -81
  88. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  89. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  91. package/src/superlocalmemory/learning/reward.py +777 -0
  92. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  93. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  94. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  95. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  96. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  97. package/src/superlocalmemory/learning/signals.py +314 -0
  98. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  99. package/src/superlocalmemory/mcp/server.py +5 -5
  100. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  101. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  102. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  103. package/src/superlocalmemory/retrieval/engine.py +52 -0
  104. package/src/superlocalmemory/server/api.py +2 -2
  105. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  106. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  107. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  108. package/src/superlocalmemory/server/routes/backup.py +36 -13
  109. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  110. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  111. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  112. package/src/superlocalmemory/server/routes/events.py +2 -2
  113. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  114. package/src/superlocalmemory/server/routes/learning.py +192 -7
  115. package/src/superlocalmemory/server/routes/memories.py +189 -1
  116. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  117. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  118. package/src/superlocalmemory/server/routes/token.py +88 -0
  119. package/src/superlocalmemory/server/routes/ws.py +5 -5
  120. package/src/superlocalmemory/server/security_middleware.py +13 -7
  121. package/src/superlocalmemory/server/ui.py +2 -2
  122. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  123. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  124. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  125. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  126. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  127. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  128. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  129. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  130. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  131. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  132. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  133. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  134. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  135. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  136. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  137. package/src/superlocalmemory/storage/models.py +4 -0
  138. package/src/superlocalmemory/ui/css/brain.css +409 -0
  139. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  140. package/src/superlocalmemory/ui/index.html +459 -1345
  141. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  142. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  143. package/src/superlocalmemory/ui/js/init.js +48 -39
  144. package/src/superlocalmemory/ui/js/memories.js +88 -2
  145. package/src/superlocalmemory/ui/js/modal.js +71 -1
  146. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  147. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  148. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  149. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  153. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  154. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  155. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  157. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  159. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  160. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  161. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  162. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  163. package/src/superlocalmemory/ui/js/learning.js +0 -435
  164. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  165. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  166. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  167. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  168. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  169. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  170. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -87,6 +87,32 @@ class LearningDatabase:
87
87
  self._lock = threading.Lock()
88
88
  self._init_schema()
89
89
 
90
+ @property
91
+ def path(self) -> str:
92
+ """Read-only path to the learning SQLite database.
93
+
94
+ S8-ARC-02 (v3.4.21): public alternative to the underscore-private
95
+ ``_db_path``. Callers that need a raw connection for specialised
96
+ read patterns should prefer :meth:`ro_connection` over building
97
+ one themselves so WAL + busy_timeout pragmas are consistent.
98
+ """
99
+ return self._db_path
100
+
101
+ def ro_connection(self, *, timeout: float = 5.0) -> sqlite3.Connection:
102
+ """Return a read-only-shaped connection with WAL/timeout pragmas set.
103
+
104
+ Callers outside this class previously opened raw
105
+ ``sqlite3.connect(lrn_db._db_path, ...)`` connections without the
106
+ WAL/busy_timeout pragmas, making them vulnerable to ``database is
107
+ locked`` errors under concurrent writer activity. This helper
108
+ produces a configured connection they can use instead.
109
+ """
110
+ conn = sqlite3.connect(self._db_path, timeout=timeout)
111
+ conn.execute("PRAGMA journal_mode=WAL")
112
+ conn.execute("PRAGMA busy_timeout=5000")
113
+ conn.row_factory = sqlite3.Row
114
+ return conn
115
+
90
116
  def _connect(self) -> sqlite3.Connection:
91
117
  """Create a configured connection to the learning database."""
92
118
  conn = sqlite3.connect(self._db_path, timeout=10)
@@ -339,6 +365,236 @@ class LearningDatabase:
339
365
  finally:
340
366
  conn.close()
341
367
 
368
+ # ------------------------------------------------------------------
369
+ # LLD-02 §4.8 — v3.4.21 writer surface
370
+ # ------------------------------------------------------------------
371
+
372
+ def count_signals(self, profile_id: str) -> int:
373
+ """Count ``learning_signals`` rows for ``profile_id``.
374
+
375
+ Used by ``_compute_ranker_phase`` + consolidation_worker training
376
+ gate. Pure SELECT — thread-safe without lock.
377
+ """
378
+ conn = self._connect()
379
+ try:
380
+ row = conn.execute(
381
+ "SELECT COUNT(*) AS cnt FROM learning_signals "
382
+ "WHERE profile_id = ?",
383
+ (profile_id,),
384
+ ).fetchone()
385
+ return int(row["cnt"]) if row else 0
386
+ finally:
387
+ conn.close()
388
+
389
+ def persist_model(
390
+ self,
391
+ *,
392
+ profile_id: str,
393
+ state_bytes: bytes,
394
+ bytes_sha256: str,
395
+ feature_names: list[str],
396
+ trained_on_count: int,
397
+ metrics: dict,
398
+ model_version: str = "3.4.21",
399
+ ) -> int:
400
+ """Persist a newly trained model and flip the active flag.
401
+
402
+ LLD-02 §4.8 — single TX:
403
+ 1. UPDATE existing active row → is_active = 0.
404
+ 2. INSERT new row with is_active = 1.
405
+
406
+ Requires M002 (columns ``bytes_sha256``, ``feature_names``,
407
+ ``metrics_json``, ``trained_on_count``, ``is_active``). Raises if
408
+ M002 hasn't been applied.
409
+
410
+ Returns the new row id.
411
+ """
412
+ if not isinstance(state_bytes, (bytes, bytearray)):
413
+ raise TypeError("state_bytes must be bytes")
414
+ if not bytes_sha256 or len(bytes_sha256) != 64:
415
+ raise ValueError("bytes_sha256 must be 64 hex chars")
416
+ names_json = json.dumps(list(feature_names), separators=(",", ":"))
417
+ metrics_json = json.dumps(dict(metrics), separators=(",", ":"))
418
+ now = self._now()
419
+
420
+ with self._lock:
421
+ conn = self._connect()
422
+ try:
423
+ conn.execute("BEGIN IMMEDIATE")
424
+ conn.execute(
425
+ "UPDATE learning_model_state "
426
+ "SET is_active = 0 "
427
+ "WHERE profile_id = ? AND is_active = 1",
428
+ (profile_id,),
429
+ )
430
+ cur = conn.execute(
431
+ "INSERT INTO learning_model_state "
432
+ "(profile_id, model_version, state_bytes, bytes_sha256, "
433
+ " trained_on_count, feature_names, metrics_json, "
434
+ " is_active, trained_at, updated_at) "
435
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
436
+ (
437
+ profile_id,
438
+ model_version,
439
+ bytes(state_bytes),
440
+ bytes_sha256.lower(),
441
+ int(trained_on_count),
442
+ names_json,
443
+ metrics_json,
444
+ now,
445
+ now,
446
+ ),
447
+ )
448
+ conn.commit()
449
+ return int(cur.lastrowid or 0)
450
+ except sqlite3.Error as exc:
451
+ conn.rollback()
452
+ logger.error("persist_model failed: %s", exc)
453
+ raise
454
+ finally:
455
+ conn.close()
456
+
457
+ def load_active_model(self, profile_id: str) -> Optional[dict]:
458
+ """Return the active model row as a dict, or ``None`` if none.
459
+
460
+ Post-M002 schema. Keys: ``state_bytes``, ``bytes_sha256``,
461
+ ``feature_names`` (JSON str), ``trained_at``, ``model_version``.
462
+ """
463
+ conn = self._connect()
464
+ try:
465
+ row = conn.execute(
466
+ "SELECT state_bytes, bytes_sha256, feature_names, trained_at, "
467
+ " model_version "
468
+ "FROM learning_model_state "
469
+ "WHERE profile_id = ? AND is_active = 1 "
470
+ "LIMIT 1",
471
+ (profile_id,),
472
+ ).fetchone()
473
+ if row is None:
474
+ return None
475
+ return {
476
+ "state_bytes": bytes(row["state_bytes"]),
477
+ "bytes_sha256": row["bytes_sha256"],
478
+ "feature_names": row["feature_names"],
479
+ "trained_at": row["trained_at"],
480
+ "model_version": row["model_version"],
481
+ }
482
+ except sqlite3.Error as exc:
483
+ logger.error("load_active_model failed: %s", exc)
484
+ return None
485
+ finally:
486
+ conn.close()
487
+
488
+ # --- training-row fetch (version-gated on M006) --------------------
489
+
490
+ _SQL_POSITION_ONLY = (
491
+ "SELECT s.id AS signal_id, s.query_id, s.fact_id, s.position, "
492
+ " s.created_at, f.features_json, NULL AS outcome_reward "
493
+ "FROM learning_signals s "
494
+ "JOIN learning_features f "
495
+ " ON f.signal_id = s.id AND f.profile_id = s.profile_id "
496
+ "WHERE s.profile_id = ? "
497
+ " AND s.signal_type IN ('candidate', 'shown', 'legacy_feedback') "
498
+ " AND f.is_synthetic = 0 "
499
+ "ORDER BY s.created_at DESC "
500
+ "LIMIT ?"
501
+ )
502
+
503
+ _SQL_WITH_OUTCOMES = (
504
+ "SELECT s.id AS signal_id, s.query_id, s.fact_id, s.position, "
505
+ " s.created_at, f.features_json, o.reward AS outcome_reward "
506
+ "FROM learning_signals s "
507
+ "JOIN learning_features f "
508
+ " ON f.signal_id = s.id AND f.profile_id = s.profile_id "
509
+ "LEFT JOIN action_outcomes o "
510
+ " ON o.recall_query_id = s.query_id AND o.settled = 1 "
511
+ "WHERE s.profile_id = ? "
512
+ " AND s.signal_type IN ('candidate', 'shown', 'legacy_feedback') "
513
+ " AND f.is_synthetic = 0 "
514
+ " AND (o.settled IS NULL OR "
515
+ " (julianday('now') - julianday(o.settled_at)) * 86400.0 >= ?) "
516
+ "ORDER BY s.created_at DESC "
517
+ "LIMIT ?"
518
+ )
519
+
520
+ def _migration_applied(self, name: str) -> bool:
521
+ """Return True if ``name`` is recorded complete in migration_log.
522
+
523
+ M006 (action_outcomes.reward) lands in v3.4.21. When absent, we
524
+ fall back to the position-only training query.
525
+ """
526
+ conn = self._connect()
527
+ try:
528
+ row = conn.execute(
529
+ "SELECT status FROM migration_log WHERE name = ?",
530
+ (name,),
531
+ ).fetchone()
532
+ except sqlite3.Error:
533
+ return False
534
+ finally:
535
+ conn.close()
536
+ if row is None:
537
+ return False
538
+ return row["status"] == "complete"
539
+
540
+ def fetch_training_examples(
541
+ self,
542
+ *,
543
+ profile_id: str,
544
+ limit: int = 2000,
545
+ min_outcome_age_sec: int = 60,
546
+ include_synthetic: bool = False,
547
+ ) -> list[dict]:
548
+ """Fetch training rows for LightGBM lambdarank training.
549
+
550
+ Version-gated on M006: without the ``reward`` column we return rows
551
+ with ``outcome_reward = None`` and the labeler falls through to the
552
+ position proxy (§4.7).
553
+
554
+ When ``include_synthetic`` is True, migrated legacy rows (with
555
+ ``learning_features.is_synthetic=1``) are included. The default
556
+ (False) preserves Stage 8 D9 — synthetic rows excluded unless the
557
+ caller opts in explicitly. The UI exposes this via the
558
+ "Migrate legacy data" flow so users consciously choose to let their
559
+ pre-v3.4.21 feedback bootstrap the model.
560
+
561
+ Returns rows sorted newest-first; the caller is expected to regroup
562
+ by ``query_id`` before training.
563
+ """
564
+ m006_applied = self._migration_applied("M006_action_outcomes_reward")
565
+ sql = self._SQL_WITH_OUTCOMES if m006_applied else self._SQL_POSITION_ONLY
566
+ if include_synthetic:
567
+ # Drop the synthetic-filter clause verbatim. Safe because the
568
+ # surrounding clauses already reference ``f.`` so removing this
569
+ # one keeps the SQL grammatically valid.
570
+ sql = sql.replace(" AND f.is_synthetic = 0 ", " ")
571
+ params: tuple
572
+ if m006_applied:
573
+ params = (profile_id, int(min_outcome_age_sec), int(limit))
574
+ else:
575
+ params = (profile_id, int(limit))
576
+ conn = self._connect()
577
+ try:
578
+ try:
579
+ rows = conn.execute(sql, params).fetchall()
580
+ except sqlite3.Error as exc:
581
+ logger.warning(
582
+ "fetch_training_examples failed (m006=%s): %s",
583
+ m006_applied, exc,
584
+ )
585
+ return []
586
+ out: list[dict] = []
587
+ for row in rows:
588
+ d = dict(row)
589
+ try:
590
+ d["features"] = json.loads(d.pop("features_json") or "{}")
591
+ except (ValueError, TypeError):
592
+ d["features"] = {}
593
+ out.append(d)
594
+ return out
595
+ finally:
596
+ conn.close()
597
+
342
598
  def reset(self, profile_id: Optional[str] = None) -> None:
343
599
  """Delete learning data. GDPR Article 17 handler.
344
600
 
@@ -0,0 +1,413 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — F4.A Stage-8 H-03/H-17/H-18 fix
4
+
5
+ """HNSW-backed near-duplicate detection for atomic_facts.
6
+
7
+ Extracted from ``hnsw_dedup.py`` as part of the F4.A split (Stage 8
8
+ H-03/H-18). Reward-gated archive + strong-memory boost live in
9
+ ``reward_archive.py`` + ``reward_boost.py``; the shim
10
+ ``hnsw_dedup.py`` re-exports every public symbol.
11
+
12
+ Contract refs:
13
+ - LLD-12 §2 — cosine > 0.95 AND entity_jaccard > 0.8 thresholds.
14
+ - LLD-12 §3 — hnswlib RAM budget + prefix-dedup fallback.
15
+ - LLD-00 §7 — ``ram_reservation`` protocol.
16
+ - Stage 8 H-17 — fallback emits logger.warning + counter.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import math
24
+ import sqlite3
25
+ import threading
26
+ import time
27
+ from pathlib import Path
28
+ from typing import Any, Iterable, Sequence
29
+
30
+ from superlocalmemory.core.ram_lock import ram_reservation
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ # Stage 8 H-17 — fallback degradation counter.
36
+ #: Incremented every time the HNSW path degrades to the prefix fallback
37
+ #: for any reason (hnswlib missing, RAM refused, schema missing, fact
38
+ #: count above cap). Observable via dashboards + tests.
39
+ _HNSW_DEGRADED_COUNT = 0
40
+ _HNSW_DEGRADED_LOCK = threading.Lock()
41
+
42
+
43
+ def get_hnsw_degraded_count() -> int:
44
+ """Return the current cumulative fallback count."""
45
+ return _HNSW_DEGRADED_COUNT
46
+
47
+
48
+ def reset_hnsw_degraded_count() -> None:
49
+ """Reset the counter — for tests only."""
50
+ global _HNSW_DEGRADED_COUNT
51
+ with _HNSW_DEGRADED_LOCK:
52
+ _HNSW_DEGRADED_COUNT = 0
53
+
54
+
55
+ def _record_degradation(reason: str) -> None:
56
+ """Increment the degradation counter + emit a logger.warning."""
57
+ global _HNSW_DEGRADED_COUNT
58
+ with _HNSW_DEGRADED_LOCK:
59
+ _HNSW_DEGRADED_COUNT += 1
60
+ logger.warning("hnsw_dedup: degraded to prefix fallback (%s)", reason)
61
+
62
+
63
+ __all__ = (
64
+ "HnswDeduplicator",
65
+ "get_hnsw_degraded_count",
66
+ "reset_hnsw_degraded_count",
67
+ "_parse_embedding",
68
+ "_cosine",
69
+ "_jaccard",
70
+ "_pick_canonical",
71
+ )
72
+
73
+
74
+ def _parse_embedding(raw: str | None) -> list[float] | None:
75
+ if not raw:
76
+ return None
77
+ try:
78
+ vec = json.loads(raw)
79
+ except (TypeError, ValueError):
80
+ return None
81
+ if not isinstance(vec, list) or not vec:
82
+ return None
83
+ try:
84
+ return [float(x) for x in vec]
85
+ except (TypeError, ValueError):
86
+ return None
87
+
88
+
89
+ # L-P-01: vectorise ``_cosine`` via NumPy when available. NumPy cold
90
+ # import is ~30 ms, but hnswlib already forces numpy in; the import is
91
+ # effectively free in the consolidation context where these helpers run.
92
+ # Pure-Python fallback is retained for environments where numpy is
93
+ # missing (contract: this module MUST NOT hard-depend on numpy).
94
+ try: # pragma: no cover — environment-dependent
95
+ import numpy as _np # type: ignore
96
+ except Exception: # pragma: no cover — numpy always present in our deps
97
+ _np = None
98
+
99
+
100
+ def _cosine(u: Sequence[float], v: Sequence[float]) -> float:
101
+ if _np is not None:
102
+ # S9-W3 M-PERF-04: ``_np.asarray`` is a no-op when the input is
103
+ # already an ndarray of the target dtype. When it is a list of
104
+ # Python floats (which is how embeddings arrive from the JSON
105
+ # fetch path) the cast costs 20-40 μs × N·k in dedup. We still
106
+ # accept lists for API compatibility but prefer callers to pass
107
+ # ndarray directly; the fast path kicks in automatically when
108
+ # they do.
109
+ ua = u if isinstance(u, _np.ndarray) else _np.asarray(u, dtype=_np.float32)
110
+ va = v if isinstance(v, _np.ndarray) else _np.asarray(v, dtype=_np.float32)
111
+ nu = float(_np.linalg.norm(ua))
112
+ nv = float(_np.linalg.norm(va))
113
+ if nu == 0.0 or nv == 0.0:
114
+ return 0.0
115
+ return float(_np.dot(ua, va)) / (nu * nv)
116
+ dot = 0.0
117
+ nu = 0.0
118
+ nv = 0.0
119
+ for a, b in zip(u, v):
120
+ dot += a * b
121
+ nu += a * a
122
+ nv += b * b
123
+ if nu == 0.0 or nv == 0.0:
124
+ return 0.0
125
+ return dot / (math.sqrt(nu) * math.sqrt(nv))
126
+
127
+
128
+ def _jaccard(a: Iterable[str], b: Iterable[str]) -> float:
129
+ # L-P-01: _jaccard is already O(|a|+|b|) set ops — numpy adds
130
+ # hashing overhead for short string sets, so we keep the pure-Python
131
+ # path. The change from the audit is the explicit note here; no
132
+ # behaviour delta.
133
+ sa, sb = set(a), set(b)
134
+ if not sa and not sb:
135
+ return 0.0
136
+ union = sa | sb
137
+ if not union:
138
+ return 0.0
139
+ return len(sa & sb) / len(union)
140
+
141
+
142
+ def _pick_canonical(
143
+ a: dict[str, Any], b: dict[str, Any],
144
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
145
+ """Canonical = higher importance, tie-break: higher confidence, older."""
146
+ ai, bi = float(a.get("importance", 0.0)), float(b.get("importance", 0.0))
147
+ if ai != bi:
148
+ return (a, b) if ai > bi else (b, a)
149
+ ac, bc = float(a.get("confidence", 0.0)), float(b.get("confidence", 0.0))
150
+ if ac != bc:
151
+ return (a, b) if ac > bc else (b, a)
152
+ at, bt = a.get("created_at", ""), b.get("created_at", "")
153
+ return (a, b) if at <= bt else (b, a)
154
+
155
+
156
+ class HnswDeduplicator:
157
+ """Find near-duplicate ``atomic_facts`` rows via HNSW ANN + entity overlap.
158
+
159
+ Contract (LLD-12 §2.1):
160
+ - cosine > COSINE_THRESHOLD AND jaccard > ENTITY_JACCARD_THRESHOLD
161
+ - Canonical = higher importance, tie-break older created_at
162
+ - Never delete; merges happen through memory_merge.apply_merges
163
+ """
164
+
165
+ COSINE_THRESHOLD: float = 0.95
166
+ ENTITY_JACCARD_THRESHOLD: float = 0.8
167
+ MAX_FACTS_FOR_HNSW: int = 200_000
168
+
169
+ # S-L01: HNSW init params — stored on the class so ``_estimate_ram_mb``
170
+ # and ``_ann_candidates`` share ONE source of truth. Previously the
171
+ # estimator hardcoded M=16 while the real build also used M=16 / ef=100
172
+ # — the numbers agreed by coincidence, not by construction. If either
173
+ # knob changes, the estimate tracks automatically and the ``ef_construction``
174
+ # build-time buffer is captured in the 1.4× multiplier below.
175
+ HNSW_M: int = 16
176
+ HNSW_EF_CONSTRUCTION: int = 100
177
+ # Build-time overhead multiplier vs steady-state footprint. Empirically
178
+ # hnswlib uses ~1.3× steady RAM during construction due to the
179
+ # ef_construction candidate pool — we round up to 1.4 for safety on
180
+ # tight-RAM (Light) profiles.
181
+ HNSW_BUILD_OVERHEAD: float = 1.4
182
+
183
+ # Per-vector HNSW footprint estimate (LLD-12 §3.1). Kept for
184
+ # back-compat — callers should prefer ``_estimate_ram_mb``.
185
+ _BYTES_PER_VEC_DEFAULT: int = 384 * 4 + 16 * 8 * 2
186
+
187
+ def __init__(self, *, memory_db_path: str | Path) -> None:
188
+ self._db = Path(memory_db_path)
189
+
190
+ # ------------------------------------------------------------------
191
+ # Public API
192
+ # ------------------------------------------------------------------
193
+
194
+ def find_merge_candidates(
195
+ self,
196
+ profile_id: str,
197
+ *,
198
+ wall_seconds: float = 300.0,
199
+ _force_unavailable: bool = False,
200
+ ) -> list[tuple[str, str, float, float]]:
201
+ """Return ``(canonical_id, duplicate_id, cosine, jaccard)`` tuples.
202
+
203
+ Never raises for expected failure modes — falls back to prefix
204
+ dedup instead. ``wall_seconds`` is the soft budget; we stop
205
+ emitting new candidates once exceeded.
206
+ """
207
+ deadline = time.monotonic() + max(0.0, wall_seconds)
208
+
209
+ rows = self._fetch_live_facts(profile_id)
210
+ if len(rows) < 2:
211
+ return []
212
+ if len(rows) > self.MAX_FACTS_FOR_HNSW:
213
+ _record_degradation(
214
+ f"{len(rows)} facts > MAX {self.MAX_FACTS_FOR_HNSW}",
215
+ )
216
+ return self._prefix_fallback(rows, deadline)
217
+
218
+ # Estimate RAM; let the reservation reject if the system is tight.
219
+ est_mb = self._estimate_ram_mb(len(rows), dim=self._detect_dim(rows))
220
+ required_mb = max(16, int(est_mb * 1.2))
221
+
222
+ hnswlib_mod = None
223
+ if not _force_unavailable:
224
+ try:
225
+ import hnswlib as hnswlib_mod # type: ignore # noqa: PLC0415
226
+ except ImportError:
227
+ hnswlib_mod = None
228
+
229
+ if hnswlib_mod is None:
230
+ _record_degradation("hnswlib unavailable")
231
+ return self._prefix_fallback(rows, deadline)
232
+
233
+ try:
234
+ with ram_reservation(
235
+ "hnswlib",
236
+ required_mb=required_mb,
237
+ timeout_s=min(30.0, max(1.0, wall_seconds)),
238
+ ):
239
+ return self._ann_candidates(rows, hnswlib_mod, deadline)
240
+ except RuntimeError as exc:
241
+ _record_degradation(f"ram_reservation refused: {exc}")
242
+ return self._prefix_fallback(rows, deadline)
243
+
244
+ # ------------------------------------------------------------------
245
+ # Internal helpers
246
+ # ------------------------------------------------------------------
247
+
248
+ def _fetch_live_facts(self, profile_id: str) -> list[dict[str, Any]]:
249
+ conn = sqlite3.connect(str(self._db), timeout=10.0)
250
+ conn.row_factory = sqlite3.Row
251
+ try:
252
+ cursor = conn.execute(
253
+ "SELECT fact_id, content, canonical_entities_json, "
254
+ " embedding, importance, confidence, created_at "
255
+ "FROM atomic_facts "
256
+ "WHERE profile_id = ? "
257
+ " AND (archive_status IS NULL OR archive_status = 'live') "
258
+ " AND (importance IS NULL OR importance < 1.0) "
259
+ "ORDER BY created_at ASC",
260
+ (profile_id,),
261
+ )
262
+ rows: list[dict[str, Any]] = []
263
+ for r in cursor.fetchall():
264
+ rows.append({
265
+ "fact_id": r["fact_id"],
266
+ "content": r["content"] or "",
267
+ "entities": json.loads(r["canonical_entities_json"] or "[]"),
268
+ "embedding": _parse_embedding(r["embedding"]),
269
+ "importance": float(r["importance"] or 0.0),
270
+ "confidence": float(r["confidence"] or 0.0),
271
+ "created_at": r["created_at"] or "",
272
+ })
273
+ return rows
274
+ finally:
275
+ conn.close()
276
+
277
+ @staticmethod
278
+ def _detect_dim(rows: list[dict[str, Any]]) -> int:
279
+ for r in rows:
280
+ emb = r.get("embedding")
281
+ if emb:
282
+ return len(emb)
283
+ return 384
284
+
285
+ def _estimate_ram_mb(self, n: int, *, dim: int) -> float:
286
+ # S-L01: derive per-vector size from the actual HNSW_M knob so
287
+ # a future tuning of M updates the estimate automatically. The
288
+ # 1.4× multiplier folds in the ef_construction build-time
289
+ # candidate pool that the old 1.10× factor under-counted.
290
+ bytes_per_vec = dim * 4 + self.HNSW_M * 8 * 2
291
+ return (n * bytes_per_vec * self.HNSW_BUILD_OVERHEAD) / (1024 * 1024)
292
+
293
+ def _ann_candidates(
294
+ self,
295
+ rows: list[dict[str, Any]],
296
+ hnswlib_mod,
297
+ deadline: float,
298
+ ) -> list[tuple[str, str, float, float]]:
299
+ embedded = [r for r in rows if r["embedding"] is not None]
300
+ if len(embedded) < 2:
301
+ return self._prefix_fallback(rows, deadline)
302
+
303
+ dim = len(embedded[0]["embedding"])
304
+ # Align: drop rows with mismatched dim.
305
+ embedded = [r for r in embedded if len(r["embedding"]) == dim]
306
+ if len(embedded) < 2:
307
+ return self._prefix_fallback(rows, deadline)
308
+
309
+ index = hnswlib_mod.Index(space="cosine", dim=dim)
310
+ # S-L01: share knobs with ``_estimate_ram_mb`` so RAM reservation
311
+ # never under-counts.
312
+ index.init_index(
313
+ max_elements=len(embedded),
314
+ ef_construction=self.HNSW_EF_CONSTRUCTION,
315
+ M=self.HNSW_M,
316
+ )
317
+ index.set_ef(min(50, len(embedded)))
318
+
319
+ try:
320
+ # H-12/C-P-04 + H-12/L-P-05: batch add_items + knn_query instead
321
+ # of one-at-a-time Python→C round-trips. hnswlib releases the GIL
322
+ # during the batch call and processes rows in the same order, so
323
+ # neighbour-label output is unchanged — behavioural equivalence
324
+ # holds. The subsequent candidate-selection loop below still
325
+ # drives `seen_losers` inline, so its decisions are identical.
326
+ #
327
+ # S9-W3 H-PERF-02: stream ``embedded`` straight into the
328
+ # index without materialising ``all_embeddings`` as a
329
+ # second Python list. At N=100k × 384-dim × 4 B this saves
330
+ # ~150 MB of transient RAM (the previous comprehension
331
+ # doubled the embedding footprint). hnswlib's add_items
332
+ # accepts any sized iterable and a parallel list of labels.
333
+ #
334
+ # S9-W3 H-SKEP-02: pin ``set_ef(max(50, k*3))`` before the
335
+ # batched knn so approximate-search quality matches the
336
+ # pre-refactor per-item default. Stage 9 Skeptic flagged
337
+ # that batched knn can miss near-duplicates at scale when
338
+ # ef is not explicitly set.
339
+ k = min(6, len(embedded))
340
+ index.set_ef(max(50, k * 3))
341
+ labels = list(range(len(embedded)))
342
+ # Pass the DB rows' embedding lists directly — hnswlib
343
+ # converts to ndarray inside and copies into its own
344
+ # contiguous buffer, so we never need a second Python list.
345
+ index.add_items([r["embedding"] for r in embedded], labels)
346
+
347
+ candidates: list[tuple[str, str, float, float]] = []
348
+ seen_losers: set[str] = set()
349
+
350
+ # H-12/C-P-04: one batched knn_query for all rows. The
351
+ # same embedding list is consumed once; hnswlib frees its
352
+ # internal ndarray before this block returns via ``del index``
353
+ # in the finally.
354
+ all_labels, all_distances = index.knn_query(
355
+ [r["embedding"] for r in embedded], k=k,
356
+ )
357
+
358
+ for i, r in enumerate(embedded):
359
+ if time.monotonic() > deadline:
360
+ break
361
+ lbls = all_labels[i]
362
+ dsts = all_distances[i]
363
+ for nb_idx, dist in zip(lbls, dsts):
364
+ if int(nb_idx) == i:
365
+ continue
366
+ neighbour = embedded[int(nb_idx)]
367
+ if neighbour["fact_id"] in seen_losers:
368
+ continue
369
+ if r["fact_id"] in seen_losers:
370
+ break
371
+ # hnswlib cosine distance is (1 - cos).
372
+ cos = max(0.0, min(1.0, 1.0 - float(dist)))
373
+ if cos <= self.COSINE_THRESHOLD:
374
+ continue
375
+ jac = _jaccard(r["entities"], neighbour["entities"])
376
+ if jac <= self.ENTITY_JACCARD_THRESHOLD:
377
+ continue
378
+ canonical, loser = _pick_canonical(r, neighbour)
379
+ if loser["fact_id"] in seen_losers:
380
+ continue
381
+ candidates.append(
382
+ (canonical["fact_id"], loser["fact_id"], cos, jac),
383
+ )
384
+ seen_losers.add(loser["fact_id"])
385
+ return candidates
386
+ finally:
387
+ # Free ANN RAM immediately (LLD-12 §3.3).
388
+ del index
389
+
390
+ def _prefix_fallback(
391
+ self,
392
+ rows: list[dict[str, Any]],
393
+ deadline: float,
394
+ ) -> list[tuple[str, str, float, float]]:
395
+ """Content-prefix dedup — retained behaviour when hnswlib cannot run."""
396
+ seen_prefix: dict[str, dict[str, Any]] = {}
397
+ candidates: list[tuple[str, str, float, float]] = []
398
+ for r in rows:
399
+ if time.monotonic() > deadline:
400
+ break
401
+ prefix = (r["content"] or "")[:100].strip().lower()
402
+ if not prefix:
403
+ continue
404
+ prior = seen_prefix.get(prefix)
405
+ if prior is None:
406
+ seen_prefix[prefix] = r
407
+ continue
408
+ canonical, loser = _pick_canonical(prior, r)
409
+ jac = _jaccard(prior["entities"], r["entities"])
410
+ candidates.append(
411
+ (canonical["fact_id"], loser["fact_id"], 1.0, jac),
412
+ )
413
+ return candidates