superlocalmemory 3.4.18 → 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.
- package/CHANGELOG.md +35 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +219 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/embeddings.py +8 -2
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/retrieval/reranker.py +4 -2
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- 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
|