superlocalmemory 3.8.2 → 3.8.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +139 -91
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +283 -39
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. package/src/superlocalmemory/storage/write_lock.py +88 -0
  94. package/src/superlocalmemory/ui/js/core.js +6 -1
@@ -24,6 +24,10 @@ import time
24
24
  import uuid
25
25
  from datetime import datetime, UTC
26
26
  from pathlib import Path
27
+ from typing import TYPE_CHECKING, Union
28
+
29
+ if TYPE_CHECKING:
30
+ from superlocalmemory.storage.database import DatabaseManager
27
31
 
28
32
  logger = logging.getLogger("superlocalmemory.graph_pruner")
29
33
 
@@ -34,70 +38,118 @@ _CHAIN_BATCH_LIMIT = 10_000
34
38
  _MAX_DEGREE_PER_NODE: int = 100
35
39
  _HUB_PRUNE_BATCH: int = 500 # delete edges in batches to avoid giant IN clauses
36
40
 
41
+ # Fix A: maximum rows to DELETE per transaction (keeps lock-hold < 10 ms).
42
+ _BATCH_SIZE: int = 1_000
43
+ # Brief sleep between batch-delete transactions so other writers get a turn.
44
+ _BATCH_YIELD_S: float = 0.005
45
+
37
46
 
38
47
  def prune_graph(
39
- db_path: str | Path,
48
+ db_or_path: "Union[DatabaseManager, str, Path]",
40
49
  profile_id: str = "default",
41
50
  dry_run: bool = False,
42
51
  cap_degree: bool = True,
52
+ max_degree: int = _MAX_DEGREE_PER_NODE,
53
+ min_edge_weight: float = 0.0,
43
54
  ) -> dict:
44
55
  """Run all graph pruning strategies for a specific profile.
45
56
 
57
+ Fix A: accepts a DatabaseManager as the first argument (preferred) to
58
+ route all writes through the process-level serialisation lock, eliminating
59
+ the lock-storm contribution from the legacy open-own-connection pattern.
60
+
61
+ For backward compatibility a ``str | Path`` is also accepted: a temporary
62
+ DatabaseManager is constructed from it with a deprecation warning.
63
+
64
+ Each DELETE strategy now runs in its own short transaction (≤ _BATCH_SIZE
65
+ rows) with a brief yield between batches. This caps the WAL write-lock
66
+ hold to < 10 ms per batch instead of potentially 30+ seconds for the
67
+ full prune.
68
+
69
+ v3.8.4-G (#84): two new parameters for config-driven thinning:
70
+ - ``max_degree``: per-node in/out degree cap (default = _MAX_DEGREE_PER_NODE=100).
71
+ - ``min_edge_weight``: edges with weight strictly below this floor are
72
+ discarded before any other step. Default 0.0 = no floor (current behaviour).
73
+
74
+ NOTE (Flaw 2 from CRIT): orphan IDs are read once upfront, then deleted
75
+ in batches. New orphan edges created by the materialiser DURING a prune
76
+ run are NOT in the initial ID list and will be pruned on the NEXT cycle.
77
+ The ``total_after`` stat therefore reflects reality after the last batch
78
+ completes, which may be slightly different from total_before - removed.
79
+ This is acceptable — no data loss, just a stat nuance.
80
+
46
81
  Returns stats dict with counts for each strategy.
47
82
  """
48
- conn = sqlite3.connect(str(db_path))
49
- conn.execute("PRAGMA journal_mode=WAL")
50
- conn.execute("PRAGMA busy_timeout=30000")
51
- conn.row_factory = sqlite3.Row
83
+ from superlocalmemory.storage.database import DatabaseManager
52
84
 
53
- stats = {
85
+ if isinstance(db_or_path, DatabaseManager):
86
+ db = db_or_path
87
+ else:
88
+ logger.warning(
89
+ "prune_graph: passing a db_path is deprecated — pass a "
90
+ "DatabaseManager instead (Fix A backward-compat shim active)"
91
+ )
92
+ db = DatabaseManager(db_or_path)
93
+
94
+ stats: dict = {
54
95
  "orphans_removed": 0,
55
96
  "supersedes_collapsed": 0,
56
97
  "self_loops_removed": 0,
57
98
  "duplicates_removed": 0,
58
99
  "hub_edges_removed": 0,
59
- "association_orphans_removed": 0, # gi-04
100
+ "association_orphans_removed": 0,
101
+ "low_weight_removed": 0,
60
102
  "total_before": 0,
61
103
  "total_after": 0,
62
104
  }
63
105
 
64
106
  try:
65
- c = conn.cursor()
66
- c.execute(
67
- "SELECT COUNT(*) as cnt FROM graph_edges WHERE profile_id = ?",
107
+ before = db.execute(
108
+ "SELECT COUNT(*) AS cnt FROM graph_edges WHERE profile_id = ?",
68
109
  (profile_id,),
69
110
  )
70
- stats["total_before"] = c.fetchone()["cnt"]
111
+ stats["total_before"] = int(before[0]["cnt"]) if before else 0
71
112
 
72
113
  start = time.time()
73
114
 
74
- # Explicit transaction for atomicity
75
- c.execute("BEGIN")
76
-
77
- stats["orphans_removed"] = _remove_orphan_edges(c, profile_id, dry_run)
78
- stats["self_loops_removed"] = _remove_self_loops(c, profile_id, dry_run)
79
- stats["duplicates_removed"] = _remove_duplicate_edges(c, profile_id, dry_run)
80
- stats["supersedes_collapsed"] = _collapse_supersedes_chains(
81
- c, profile_id, dry_run,
82
- )
83
- if cap_degree:
84
- stats["hub_edges_removed"] = _cap_node_degree(
85
- c, profile_id, _MAX_DEGREE_PER_NODE, dry_run,
115
+ if not dry_run:
116
+ # v3.8.4-G: min_edge_weight floor — run FIRST so subsequent steps
117
+ # operate on the already-thinned edge set.
118
+ if min_edge_weight > 0.0:
119
+ stats["low_weight_removed"] = _remove_low_weight_edges_batched(
120
+ db, profile_id, min_edge_weight,
121
+ )
122
+ stats["orphans_removed"] = _remove_orphan_edges_batched(
123
+ db, profile_id,
124
+ )
125
+ stats["self_loops_removed"] = _remove_self_loops_batched(
126
+ db, profile_id,
127
+ )
128
+ stats["duplicates_removed"] = _remove_duplicate_edges_batched(
129
+ db, profile_id,
130
+ )
131
+ stats["supersedes_collapsed"] = _collapse_supersedes_chains_batched(
132
+ db, profile_id,
133
+ )
134
+ if cap_degree:
135
+ stats["hub_edges_removed"] = _cap_node_degree_batched(
136
+ db, profile_id, max_degree,
137
+ )
138
+ stats["association_orphans_removed"] = (
139
+ _remove_orphan_association_edges_batched(db, profile_id)
86
140
  )
87
- stats["association_orphans_removed"] = _remove_orphan_association_edges(
88
- c, profile_id, dry_run,
89
- )
90
-
91
- if dry_run:
92
- c.execute("ROLLBACK")
93
141
  else:
94
- c.execute("COMMIT")
142
+ # Dry-run: read-only estimates via a temporary connection
143
+ # (DatabaseManager is not used here — no writes)
144
+ stats.update(
145
+ _dry_run_counts(db.db_path, profile_id, cap_degree)
146
+ )
95
147
 
96
- c.execute(
97
- "SELECT COUNT(*) as cnt FROM graph_edges WHERE profile_id = ?",
148
+ after = db.execute(
149
+ "SELECT COUNT(*) AS cnt FROM graph_edges WHERE profile_id = ?",
98
150
  (profile_id,),
99
151
  )
100
- stats["total_after"] = c.fetchone()["cnt"]
152
+ stats["total_after"] = int(after[0]["cnt"]) if after else 0
101
153
 
102
154
  elapsed = time.time() - start
103
155
  total_removed = stats["total_before"] - stats["total_after"]
@@ -116,16 +168,361 @@ def prune_graph(
116
168
  except Exception as exc:
117
169
  logger.error("Graph pruning failed: %s", exc, exc_info=True)
118
170
  stats["error"] = str(exc)
119
- try:
120
- c.execute("ROLLBACK")
121
- except Exception:
122
- pass
123
- finally:
124
- conn.close()
125
171
 
126
172
  return stats
127
173
 
128
174
 
175
+ # ---------------------------------------------------------------------------
176
+ # Batched delete helpers (Fix A — short transactions, bounded lock hold)
177
+ # ---------------------------------------------------------------------------
178
+
179
+ def _batch_delete_by_ids(
180
+ db: "DatabaseManager",
181
+ table: str,
182
+ ids: list,
183
+ id_col: str = "edge_id",
184
+ ) -> int:
185
+ """Delete rows in ``table`` where ``id_col`` IN ``ids``, _BATCH_SIZE at a time.
186
+
187
+ Each batch runs in its own transaction so the write lock is released
188
+ between batches. Returns total rows deleted.
189
+ """
190
+ removed = 0
191
+ for start in range(0, len(ids), _BATCH_SIZE):
192
+ batch = ids[start:start + _BATCH_SIZE]
193
+ ph = ",".join("?" * len(batch))
194
+ with db.transaction():
195
+ db.execute(
196
+ f"DELETE FROM {table} WHERE {id_col} IN ({ph})",
197
+ tuple(batch),
198
+ )
199
+ removed += len(batch)
200
+ if start + _BATCH_SIZE < len(ids):
201
+ time.sleep(_BATCH_YIELD_S)
202
+ return removed
203
+
204
+
205
+ def _remove_orphan_edges_batched(
206
+ db: "DatabaseManager",
207
+ profile_id: str,
208
+ ) -> int:
209
+ """Remove graph_edges whose source OR target no longer exists in facts/entities.
210
+
211
+ Read all orphan IDs upfront, then batch-delete. New orphans created
212
+ during the run will be handled in the next maintenance cycle.
213
+ """
214
+ rows = db.execute(
215
+ """
216
+ SELECT edge_id FROM graph_edges
217
+ WHERE profile_id = ?
218
+ AND (
219
+ (source_id NOT IN (SELECT fact_id FROM atomic_facts)
220
+ AND source_id NOT IN (SELECT entity_id FROM canonical_entities))
221
+ OR
222
+ (target_id NOT IN (SELECT fact_id FROM atomic_facts)
223
+ AND target_id NOT IN (SELECT entity_id FROM canonical_entities))
224
+ )
225
+ """,
226
+ (profile_id,),
227
+ )
228
+ ids = [dict(r)["edge_id"] for r in rows]
229
+ if not ids:
230
+ return 0
231
+ return _batch_delete_by_ids(db, "graph_edges", ids)
232
+
233
+
234
+ def _remove_self_loops_batched(
235
+ db: "DatabaseManager",
236
+ profile_id: str,
237
+ ) -> int:
238
+ """Remove self-loop edges (source_id == target_id)."""
239
+ rows = db.execute(
240
+ "SELECT edge_id FROM graph_edges "
241
+ "WHERE source_id = target_id AND profile_id = ?",
242
+ (profile_id,),
243
+ )
244
+ ids = [dict(r)["edge_id"] for r in rows]
245
+ if not ids:
246
+ return 0
247
+ return _batch_delete_by_ids(db, "graph_edges", ids)
248
+
249
+
250
+ def _remove_duplicate_edges_batched(
251
+ db: "DatabaseManager",
252
+ profile_id: str,
253
+ ) -> int:
254
+ """Remove duplicate edges (same source+target+type), keeping highest weight."""
255
+ rows = db.execute(
256
+ """
257
+ SELECT edge_id FROM graph_edges ge_outer
258
+ WHERE profile_id = ?
259
+ AND edge_id NOT IN (
260
+ SELECT edge_id FROM graph_edges ge1
261
+ WHERE profile_id = ?
262
+ AND weight = (
263
+ SELECT MAX(weight) FROM graph_edges ge2
264
+ WHERE ge2.source_id = ge1.source_id
265
+ AND ge2.target_id = ge1.target_id
266
+ AND ge2.edge_type = ge1.edge_type
267
+ AND ge2.profile_id = ge1.profile_id
268
+ )
269
+ GROUP BY source_id, target_id, edge_type
270
+ )
271
+ """,
272
+ (profile_id, profile_id),
273
+ )
274
+ ids = [dict(r)["edge_id"] for r in rows]
275
+ if not ids:
276
+ return 0
277
+ return _batch_delete_by_ids(db, "graph_edges", ids)
278
+
279
+
280
+ def _collapse_supersedes_chains_batched(
281
+ db: "DatabaseManager",
282
+ profile_id: str,
283
+ ) -> int:
284
+ """Collapse supersedes chains: A→B, B→C becomes A→C (B→C removed)."""
285
+ rows = db.execute(
286
+ """
287
+ SELECT e1.edge_id AS e1_id, e1.source_id AS a, e1.target_id AS b,
288
+ e1.weight AS e1_weight,
289
+ e2.edge_id AS e2_id, e2.target_id AS c
290
+ FROM graph_edges e1
291
+ JOIN graph_edges e2 ON e1.target_id = e2.source_id
292
+ WHERE e1.edge_type = 'supersedes'
293
+ AND e2.edge_type = 'supersedes'
294
+ AND e1.profile_id = ?
295
+ AND e2.profile_id = ?
296
+ LIMIT ?
297
+ """,
298
+ (profile_id, profile_id, _CHAIN_BATCH_LIMIT),
299
+ )
300
+ chains = [dict(r) for r in rows]
301
+ if not chains:
302
+ return 0
303
+
304
+ if len(chains) >= _CHAIN_BATCH_LIMIT:
305
+ logger.warning(
306
+ "Supersedes chain collapse hit limit (%d). "
307
+ "More chains may exist — will process in next cycle.",
308
+ _CHAIN_BATCH_LIMIT,
309
+ )
310
+
311
+ now = datetime.now(UTC).isoformat()
312
+ delete_ids: list[str] = []
313
+ insert_rows: list[tuple] = []
314
+
315
+ for chain in chains:
316
+ delete_ids.append(chain["e2_id"])
317
+ new_edge_id = uuid.uuid4().hex[:16]
318
+ insert_rows.append((
319
+ new_edge_id, profile_id,
320
+ chain["a"], chain["c"],
321
+ "supersedes", chain["e1_weight"] or 1.0, now,
322
+ ))
323
+
324
+ # Delete B→C edges in batches, then insert A→C shortcuts
325
+ removed = _batch_delete_by_ids(db, "graph_edges", delete_ids)
326
+
327
+ for start in range(0, len(insert_rows), _BATCH_SIZE):
328
+ batch = insert_rows[start:start + _BATCH_SIZE]
329
+ with db.transaction():
330
+ for row in batch:
331
+ db.execute(
332
+ "INSERT OR IGNORE INTO graph_edges "
333
+ "(edge_id, profile_id, source_id, target_id, "
334
+ " edge_type, weight, created_at) "
335
+ "VALUES (?,?,?,?,?,?,?)",
336
+ row,
337
+ )
338
+ if start + _BATCH_SIZE < len(insert_rows):
339
+ time.sleep(_BATCH_YIELD_S)
340
+
341
+ return removed
342
+
343
+
344
+ def _cap_node_degree_batched(
345
+ db: "DatabaseManager",
346
+ profile_id: str,
347
+ max_degree: int,
348
+ ) -> int:
349
+ """Remove low-weight edges from hub nodes (degree > max_degree).
350
+
351
+ Uses the same window-function approach as the legacy cursor version but
352
+ selects IDs into Python then batch-deletes through DatabaseManager.
353
+ """
354
+ rows = db.execute(
355
+ """
356
+ SELECT edge_id FROM (
357
+ SELECT edge_id,
358
+ ROW_NUMBER() OVER (
359
+ PARTITION BY source_id ORDER BY weight DESC
360
+ ) AS out_rn,
361
+ ROW_NUMBER() OVER (
362
+ PARTITION BY target_id ORDER BY weight DESC
363
+ ) AS in_rn
364
+ FROM graph_edges
365
+ WHERE profile_id = ?
366
+ ) WHERE out_rn > ? OR in_rn > ?
367
+ """,
368
+ (profile_id, max_degree, max_degree),
369
+ )
370
+ ids = [dict(r)["edge_id"] for r in rows]
371
+ if not ids:
372
+ return 0
373
+ removed = _batch_delete_by_ids(db, "graph_edges", ids)
374
+ logger.info(
375
+ "_cap_node_degree_batched: removed %d low-weight edges (max_degree=%d)",
376
+ removed, max_degree,
377
+ )
378
+ return removed
379
+
380
+
381
+ def _remove_low_weight_edges_batched(
382
+ db: "DatabaseManager",
383
+ profile_id: str,
384
+ min_weight: float,
385
+ ) -> int:
386
+ """Remove graph_edges with weight strictly below ``min_weight``.
387
+
388
+ v3.8.4-G (#84): implements the ``min_edge_weight`` floor from
389
+ ``GraphPruningConfig``. Runs before orphan/duplicate/degree pruning so
390
+ subsequent steps operate on the already-thinned set.
391
+
392
+ A ``min_weight`` of 0.0 means no floor — no edges are touched. The
393
+ caller in ``prune_graph()`` guards the call with ``if min_edge_weight > 0.0``
394
+ so this function is only invoked when there is an actual floor to enforce.
395
+ """
396
+ rows = db.execute(
397
+ "SELECT edge_id FROM graph_edges WHERE profile_id = ? AND weight < ?",
398
+ (profile_id, min_weight),
399
+ )
400
+ ids = [dict(r)["edge_id"] for r in rows]
401
+ if not ids:
402
+ return 0
403
+ removed = _batch_delete_by_ids(db, "graph_edges", ids)
404
+ logger.info(
405
+ "_remove_low_weight_edges_batched: removed %d edges (min_weight=%.4f)",
406
+ removed, min_weight,
407
+ )
408
+ return removed
409
+
410
+
411
+ def _remove_orphan_association_edges_batched(
412
+ db: "DatabaseManager",
413
+ profile_id: str,
414
+ ) -> int:
415
+ """Remove association_edges whose source/target fact no longer exists.
416
+
417
+ Fix: PK column is ``edge_id``, not ``id`` (schema_v32.py line 177).
418
+ """
419
+ rows = db.execute(
420
+ """
421
+ SELECT edge_id FROM association_edges
422
+ WHERE profile_id = ?
423
+ AND (
424
+ source_fact_id NOT IN (
425
+ SELECT fact_id FROM atomic_facts WHERE profile_id = ?
426
+ )
427
+ OR target_fact_id NOT IN (
428
+ SELECT fact_id FROM atomic_facts WHERE profile_id = ?
429
+ )
430
+ )
431
+ """,
432
+ (profile_id, profile_id, profile_id),
433
+ )
434
+ ids = [dict(r)["edge_id"] for r in rows]
435
+ if not ids:
436
+ return 0
437
+ return _batch_delete_by_ids(db, "association_edges", ids, id_col="edge_id")
438
+
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # Dry-run estimates (read-only; uses a direct connection to avoid side effects)
442
+ # ---------------------------------------------------------------------------
443
+
444
+ def _dry_run_counts(
445
+ db_path: Path,
446
+ profile_id: str,
447
+ cap_degree: bool,
448
+ ) -> dict:
449
+ """Return estimated removal counts without modifying anything."""
450
+ counts: dict = {
451
+ "orphans_removed": 0,
452
+ "supersedes_collapsed": 0,
453
+ "self_loops_removed": 0,
454
+ "duplicates_removed": 0,
455
+ "hub_edges_removed": 0,
456
+ "association_orphans_removed": 0,
457
+ }
458
+ try:
459
+ conn = sqlite3.connect(str(db_path))
460
+ conn.execute("PRAGMA busy_timeout=10000")
461
+ conn.row_factory = sqlite3.Row
462
+ c = conn.cursor()
463
+
464
+ c.execute(
465
+ """
466
+ SELECT COUNT(*) AS cnt FROM graph_edges
467
+ WHERE profile_id = ?
468
+ AND (
469
+ (source_id NOT IN (SELECT fact_id FROM atomic_facts)
470
+ AND source_id NOT IN (SELECT entity_id FROM canonical_entities))
471
+ OR
472
+ (target_id NOT IN (SELECT fact_id FROM atomic_facts)
473
+ AND target_id NOT IN (SELECT entity_id FROM canonical_entities))
474
+ )
475
+ """,
476
+ (profile_id,),
477
+ )
478
+ counts["orphans_removed"] = c.fetchone()["cnt"]
479
+
480
+ c.execute(
481
+ "SELECT COUNT(*) AS cnt FROM graph_edges "
482
+ "WHERE source_id = target_id AND profile_id = ?",
483
+ (profile_id,),
484
+ )
485
+ counts["self_loops_removed"] = c.fetchone()["cnt"]
486
+
487
+ c.execute(
488
+ """
489
+ SELECT
490
+ (SELECT COUNT(*) FROM graph_edges WHERE profile_id = ?) -
491
+ (SELECT COUNT(*) FROM (
492
+ SELECT source_id, target_id, edge_type
493
+ FROM graph_edges WHERE profile_id = ?
494
+ GROUP BY source_id, target_id, edge_type
495
+ )) AS cnt
496
+ """,
497
+ (profile_id, profile_id),
498
+ )
499
+ counts["duplicates_removed"] = max(c.fetchone()["cnt"] or 0, 0)
500
+
501
+ if cap_degree:
502
+ c.execute(
503
+ """
504
+ SELECT COUNT(*) AS cnt FROM (
505
+ SELECT edge_id,
506
+ ROW_NUMBER() OVER (
507
+ PARTITION BY source_id ORDER BY weight DESC
508
+ ) AS out_rn,
509
+ ROW_NUMBER() OVER (
510
+ PARTITION BY target_id ORDER BY weight DESC
511
+ ) AS in_rn
512
+ FROM graph_edges
513
+ WHERE profile_id = ?
514
+ ) WHERE out_rn > ? OR in_rn > ?
515
+ """,
516
+ (profile_id, _MAX_DEGREE_PER_NODE, _MAX_DEGREE_PER_NODE),
517
+ )
518
+ counts["hub_edges_removed"] = c.fetchone()["cnt"]
519
+
520
+ conn.close()
521
+ except Exception as exc:
522
+ logger.warning("dry-run counts failed: %s", exc)
523
+ return counts
524
+
525
+
129
526
  def _remove_orphan_association_edges(
130
527
  c: sqlite3.Cursor,
131
528
  profile_id: str,