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.
- package/CHANGELOG.md +57 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +19 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/engine.py +92 -11
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +160 -31
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/recall_pipeline.py +3 -0
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -1
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +98 -15
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +91 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +6 -12
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/helpers.py +24 -13
- package/src/superlocalmemory/server/routes/memories.py +139 -91
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +42 -30
- package/src/superlocalmemory/server/routes/v3_api.py +67 -77
- package/src/superlocalmemory/server/unified_daemon.py +283 -39
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/database.py +109 -19
- package/src/superlocalmemory/storage/deferred_writes.py +153 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +119 -0
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
66
|
-
|
|
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"] =
|
|
111
|
+
stats["total_before"] = int(before[0]["cnt"]) if before else 0
|
|
71
112
|
|
|
72
113
|
start = time.time()
|
|
73
114
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
stats["
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
"SELECT COUNT(*)
|
|
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"] =
|
|
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,
|