superlocalmemory 3.2.1 → 3.2.3

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 (30) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +61 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +26 -1
  5. package/src/superlocalmemory/attribution/signer.py +6 -1
  6. package/src/superlocalmemory/core/config.py +113 -1
  7. package/src/superlocalmemory/core/consolidation_engine.py +595 -0
  8. package/src/superlocalmemory/core/embeddings.py +0 -1
  9. package/src/superlocalmemory/core/engine.py +164 -674
  10. package/src/superlocalmemory/core/engine_wiring.py +474 -0
  11. package/src/superlocalmemory/core/graph_analyzer.py +199 -0
  12. package/src/superlocalmemory/core/recall_pipeline.py +247 -0
  13. package/src/superlocalmemory/core/store_pipeline.py +483 -0
  14. package/src/superlocalmemory/core/worker_pool.py +35 -12
  15. package/src/superlocalmemory/encoding/auto_linker.py +308 -0
  16. package/src/superlocalmemory/encoding/context_generator.py +175 -0
  17. package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
  18. package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
  19. package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
  20. package/src/superlocalmemory/retrieval/engine.py +12 -0
  21. package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
  22. package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
  23. package/src/superlocalmemory/retrieval/strategy.py +6 -6
  24. package/src/superlocalmemory/retrieval/vector_store.py +386 -0
  25. package/src/superlocalmemory/server/routes/v3_api.py +576 -0
  26. package/src/superlocalmemory/storage/access_log.py +169 -0
  27. package/src/superlocalmemory/storage/database.py +288 -0
  28. package/src/superlocalmemory/storage/schema.py +10 -0
  29. package/src/superlocalmemory/storage/schema_v32.py +252 -0
  30. package/src/superlocalmemory/storage/v2_migrator.py +24 -2
@@ -0,0 +1,474 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Engine wiring — extracted free functions for MemoryEngine initialization.
6
+
7
+ Direction: engine.py imports this module. This module NEVER imports engine.py.
8
+
9
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from superlocalmemory.core.config import SLMConfig
19
+ from superlocalmemory.core.hooks import HookRegistry
20
+ from superlocalmemory.storage.database import DatabaseManager
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # init_embedder (was MemoryEngine._init_embedder + helpers)
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def _try_ollama_embedder(emb_cfg: Any) -> Any | None:
30
+ """Try to create an OllamaEmbedder. Returns it or None."""
31
+ try:
32
+ from superlocalmemory.core.ollama_embedder import OllamaEmbedder
33
+ emb = OllamaEmbedder(
34
+ model=emb_cfg.ollama_model,
35
+ base_url=emb_cfg.ollama_base_url,
36
+ dimension=emb_cfg.dimension,
37
+ )
38
+ if emb.is_available:
39
+ logger.info("Using Ollama embeddings (%s)", emb_cfg.ollama_model)
40
+ return emb
41
+ logger.warning(
42
+ "Ollama embedder not available (model=%s). Falling back.",
43
+ emb_cfg.ollama_model,
44
+ )
45
+ except Exception as exc:
46
+ logger.warning("OllamaEmbedder init failed: %s", exc)
47
+ return None
48
+
49
+
50
+ def _try_service_embedder(cls: type, emb_cfg: Any) -> Any | None:
51
+ """Try to create an EmbeddingService. Returns it or None."""
52
+ try:
53
+ emb = cls(emb_cfg)
54
+ if emb.is_available:
55
+ return emb
56
+ logger.warning("EmbeddingService not available. BM25-only mode. Run 'slm doctor' to diagnose.")
57
+ except Exception as exc:
58
+ logger.warning("Embeddings unavailable (%s). BM25-only mode. Run 'slm doctor' to diagnose.", exc)
59
+ return None
60
+
61
+
62
+ def init_embedder(config: SLMConfig) -> Any | None:
63
+ """Initialize the best available embedding provider.
64
+
65
+ Priority order:
66
+ 1. Explicit provider in config (ollama / cloud / sentence-transformers)
67
+ 2. Auto-detect: if Ollama has embedding model -> use it
68
+ 3. Fallback to sentence-transformers subprocess
69
+ 4. If nothing works -> None (BM25-only mode)
70
+ """
71
+ from superlocalmemory.core.embeddings import EmbeddingService
72
+
73
+ emb_cfg = config.embedding
74
+ provider = emb_cfg.provider
75
+
76
+ # --- Explicit ollama provider ---
77
+ if provider == "ollama":
78
+ return _try_ollama_embedder(emb_cfg)
79
+
80
+ # --- Explicit cloud provider ---
81
+ if provider == "cloud" or emb_cfg.is_cloud:
82
+ return _try_service_embedder(EmbeddingService, emb_cfg)
83
+
84
+ # --- Explicit sentence-transformers ---
85
+ if provider == "sentence-transformers":
86
+ return _try_service_embedder(EmbeddingService, emb_cfg)
87
+
88
+ # --- Auto-detect: try Ollama first (fast path, <1s) ---
89
+ ollama_emb = _try_ollama_embedder(emb_cfg)
90
+ if ollama_emb is not None:
91
+ logger.info("Auto-detected Ollama embeddings (fast path)")
92
+ return ollama_emb
93
+
94
+ # --- Fallback: sentence-transformers subprocess ---
95
+ return _try_service_embedder(EmbeddingService, emb_cfg)
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # init_encoding (was MemoryEngine._init_encoding)
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def init_encoding(
103
+ config: SLMConfig,
104
+ db: DatabaseManager,
105
+ embedder: Any,
106
+ llm: Any,
107
+ ) -> dict[str, Any]:
108
+ """Create all encoding components. Returns dict of components."""
109
+ from superlocalmemory.encoding.fact_extractor import FactExtractor
110
+ from superlocalmemory.encoding.entity_resolver import EntityResolver
111
+ from superlocalmemory.encoding.temporal_parser import TemporalParser
112
+ from superlocalmemory.encoding.type_router import TypeRouter
113
+ from superlocalmemory.encoding.graph_builder import GraphBuilder
114
+ from superlocalmemory.encoding.consolidator import MemoryConsolidator
115
+ from superlocalmemory.encoding.observation_builder import ObservationBuilder
116
+ from superlocalmemory.encoding.scene_builder import SceneBuilder
117
+ from superlocalmemory.encoding.entropy_gate import EntropyGate
118
+ from superlocalmemory.retrieval.ann_index import ANNIndex
119
+
120
+ ann_index = ANNIndex(dimension=config.embedding.dimension)
121
+ fact_extractor = FactExtractor(
122
+ config=config.encoding, llm=llm,
123
+ embedder=embedder, mode=config.mode,
124
+ )
125
+ entity_resolver = EntityResolver(db, llm)
126
+ temporal_parser = TemporalParser()
127
+ type_router = TypeRouter(
128
+ mode=config.mode, embedder=embedder, llm=llm,
129
+ )
130
+ graph_builder = GraphBuilder(db, ann_index)
131
+ consolidator = MemoryConsolidator(
132
+ db, embedder, llm, config.encoding,
133
+ )
134
+ observation_builder = ObservationBuilder(db)
135
+ scene_builder = SceneBuilder(db, embedder)
136
+ entropy_gate = EntropyGate(
137
+ embedder, config.encoding.entropy_threshold,
138
+ )
139
+
140
+ sheaf_checker = None
141
+ if config.math.sheaf_at_encoding:
142
+ from superlocalmemory.math.sheaf import SheafConsistencyChecker
143
+ sheaf_checker = SheafConsistencyChecker(
144
+ db, config.math.sheaf_contradiction_threshold,
145
+ )
146
+
147
+ # V3.2: VectorStore (Phase 1) -- sqlite-vec KNN
148
+ vector_store = _init_vector_store(config)
149
+
150
+ # V3.2: AccessLog (Phase 1) -- fact access tracking
151
+ access_log = _init_access_log(db)
152
+
153
+ # V3.2: ContextGenerator (Phase 2) -- contextual descriptions for facts
154
+ context_generator = _init_context_generator(config, llm)
155
+
156
+ # V3.2: TemporalValidator (Phase 4) -- bi-temporal fact invalidation
157
+ temporal_validator = _init_temporal(config, db, sheaf_checker, llm)
158
+
159
+ # V3.2: Phase 3 -- Association Graph components
160
+ auto_linker = _init_auto_linker(db, vector_store, context_generator, config)
161
+ graph_analyzer = _init_graph_analyzer(db)
162
+
163
+ return {
164
+ "ann_index": ann_index,
165
+ "fact_extractor": fact_extractor,
166
+ "entity_resolver": entity_resolver,
167
+ "temporal_parser": temporal_parser,
168
+ "type_router": type_router,
169
+ "graph_builder": graph_builder,
170
+ "consolidator": consolidator,
171
+ "observation_builder": observation_builder,
172
+ "scene_builder": scene_builder,
173
+ "entropy_gate": entropy_gate,
174
+ "sheaf_checker": sheaf_checker,
175
+ "vector_store": vector_store,
176
+ "access_log": access_log,
177
+ "context_generator": context_generator,
178
+ "temporal_validator": temporal_validator,
179
+ "auto_linker": auto_linker,
180
+ "graph_analyzer": graph_analyzer,
181
+ }
182
+
183
+
184
+ def _init_vector_store(config: SLMConfig) -> Any | None:
185
+ """Create VectorStore if sqlite-vec is available. Returns None on failure."""
186
+ try:
187
+ from superlocalmemory.retrieval.vector_store import (
188
+ VectorStore, VectorStoreConfig,
189
+ )
190
+ vec_config = VectorStoreConfig(
191
+ dimension=config.embedding.dimension,
192
+ enabled=True,
193
+ )
194
+ vs = VectorStore(config.db_path, vec_config)
195
+ if vs.available:
196
+ logger.info("VectorStore initialized (sqlite-vec KNN enabled)")
197
+ return vs
198
+ logger.debug("VectorStore unavailable; using ANNIndex fallback")
199
+ except Exception as exc:
200
+ logger.debug("VectorStore init failed: %s", exc)
201
+ return None
202
+
203
+
204
+ def _init_access_log(db: DatabaseManager) -> Any | None:
205
+ """Create AccessLog for fact access tracking."""
206
+ try:
207
+ from superlocalmemory.storage.access_log import AccessLog
208
+ return AccessLog(db)
209
+ except Exception as exc:
210
+ logger.debug("AccessLog init failed: %s", exc)
211
+ return None
212
+
213
+
214
+ def _init_context_generator(config: SLMConfig, llm: Any) -> Any | None:
215
+ """Create ContextGenerator for Phase 2 contextual descriptions."""
216
+ try:
217
+ from superlocalmemory.encoding.context_generator import ContextGenerator
218
+ if hasattr(config, "auto_invoke") and config.auto_invoke.enabled:
219
+ return ContextGenerator(llm=llm)
220
+ # Still create the generator (rules-only) even when disabled,
221
+ # so store pipeline can generate context when called directly.
222
+ return ContextGenerator(llm=llm)
223
+ except Exception as exc:
224
+ logger.debug("ContextGenerator init failed: %s", exc)
225
+ return None
226
+
227
+
228
+ def _init_temporal(
229
+ config: SLMConfig,
230
+ db: DatabaseManager,
231
+ sheaf_checker: Any,
232
+ llm: Any,
233
+ ) -> Any | None:
234
+ """Create TemporalValidator for Phase 4 temporal intelligence."""
235
+ if not config.temporal_validator.enabled:
236
+ return None
237
+
238
+ try:
239
+ from superlocalmemory.encoding.temporal_validator import TemporalValidator
240
+ from superlocalmemory.trust.scorer import TrustScorer
241
+
242
+ trust_scorer = TrustScorer(db)
243
+ tv = TemporalValidator(
244
+ db=db,
245
+ sheaf_checker=sheaf_checker,
246
+ trust_scorer=trust_scorer,
247
+ llm=llm,
248
+ config=config.temporal_validator,
249
+ )
250
+ logger.info("TemporalValidator initialized (mode=%s)", config.temporal_validator.mode)
251
+ return tv
252
+ except Exception as exc:
253
+ logger.debug("TemporalValidator init failed: %s", exc)
254
+ return None
255
+
256
+
257
+ def _init_auto_linker(
258
+ db: DatabaseManager,
259
+ vector_store: Any,
260
+ context_generator: Any,
261
+ config: SLMConfig,
262
+ ) -> Any | None:
263
+ """Create AutoLinker for Phase 3 association graph."""
264
+ try:
265
+ from superlocalmemory.encoding.auto_linker import AutoLinker
266
+ return AutoLinker(
267
+ db=db,
268
+ vector_store=vector_store,
269
+ context_generator=context_generator,
270
+ config=config,
271
+ )
272
+ except Exception as exc:
273
+ logger.debug("AutoLinker init failed: %s", exc)
274
+ return None
275
+
276
+
277
+ def _init_graph_analyzer(db: DatabaseManager) -> Any | None:
278
+ """Create GraphAnalyzer for Phase 3 structural importance."""
279
+ try:
280
+ from superlocalmemory.core.graph_analyzer import GraphAnalyzer
281
+ return GraphAnalyzer(db=db)
282
+ except Exception as exc:
283
+ logger.debug("GraphAnalyzer init failed: %s", exc)
284
+ return None
285
+
286
+
287
+ def _init_consolidation(
288
+ config: SLMConfig,
289
+ db: DatabaseManager,
290
+ auto_linker: Any,
291
+ graph_analyzer: Any,
292
+ temporal_validator: Any,
293
+ summarizer: Any,
294
+ behavioral_store: Any,
295
+ ) -> Any | None:
296
+ """Create ConsolidationEngine for Phase 5 sleep-time consolidation."""
297
+ try:
298
+ from superlocalmemory.core.consolidation_engine import ConsolidationEngine
299
+ return ConsolidationEngine(
300
+ db=db,
301
+ config=config.consolidation,
302
+ summarizer=summarizer,
303
+ behavioral_store=behavioral_store,
304
+ auto_linker=auto_linker,
305
+ graph_analyzer=graph_analyzer,
306
+ temporal_validator=temporal_validator,
307
+ slm_config=config,
308
+ )
309
+ except Exception as exc:
310
+ logger.debug("ConsolidationEngine init failed: %s", exc)
311
+ return None
312
+
313
+
314
+ def _init_spreading_activation(
315
+ db: DatabaseManager,
316
+ vector_store: Any,
317
+ ) -> Any | None:
318
+ """Create SpreadingActivation for Phase 3 5th retrieval channel."""
319
+ try:
320
+ from superlocalmemory.retrieval.spreading_activation import (
321
+ SpreadingActivation,
322
+ SpreadingActivationConfig,
323
+ )
324
+ sa_config = SpreadingActivationConfig(enabled=False)
325
+ return SpreadingActivation(
326
+ db=db, vector_store=vector_store, config=sa_config,
327
+ )
328
+ except Exception as exc:
329
+ logger.debug("SpreadingActivation init failed: %s", exc)
330
+ return None
331
+
332
+
333
+ def _init_auto_invoker(
334
+ config: SLMConfig,
335
+ db: DatabaseManager,
336
+ vector_store: Any,
337
+ trust_scorer: Any,
338
+ embedder: Any,
339
+ ) -> Any | None:
340
+ """Create AutoInvoker for Phase 2 multi-signal retrieval."""
341
+ if not hasattr(config, "auto_invoke") or not config.auto_invoke.enabled:
342
+ return None
343
+ try:
344
+ from superlocalmemory.hooks.auto_invoker import AutoInvoker
345
+ return AutoInvoker(
346
+ db=db,
347
+ vector_store=vector_store,
348
+ trust_scorer=trust_scorer,
349
+ embedder=embedder,
350
+ config=config.auto_invoke,
351
+ )
352
+ except Exception as exc:
353
+ logger.debug("AutoInvoker init failed: %s", exc)
354
+ return None
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # init_retrieval (was MemoryEngine._init_retrieval)
359
+ # ---------------------------------------------------------------------------
360
+
361
+ def init_retrieval(
362
+ config: SLMConfig,
363
+ db: DatabaseManager,
364
+ embedder: Any,
365
+ entity_resolver: Any,
366
+ trust_scorer: Any,
367
+ vector_store: Any = None,
368
+ ) -> Any:
369
+ """Create the RetrievalEngine with 5 channels. Returns it."""
370
+ from superlocalmemory.retrieval.engine import RetrievalEngine
371
+ from superlocalmemory.retrieval.semantic_channel import SemanticChannel
372
+ from superlocalmemory.retrieval.bm25_channel import BM25Channel
373
+ from superlocalmemory.retrieval.entity_channel import EntityGraphChannel
374
+ from superlocalmemory.retrieval.temporal_channel import TemporalChannel
375
+ from superlocalmemory.retrieval.reranker import CrossEncoderReranker
376
+ from superlocalmemory.retrieval.profile_channel import ProfileChannel
377
+ from superlocalmemory.retrieval.bridge_discovery import BridgeDiscovery
378
+
379
+ channels: dict = {
380
+ "semantic": SemanticChannel(
381
+ db,
382
+ fisher_temperature=config.math.fisher_temperature,
383
+ embedder=embedder,
384
+ fisher_mode=config.math.fisher_mode,
385
+ vector_store=vector_store,
386
+ ),
387
+ "bm25": BM25Channel(db),
388
+ "entity_graph": EntityGraphChannel(db, entity_resolver),
389
+ "temporal": TemporalChannel(db),
390
+ }
391
+
392
+ # Phase 3: Register SpreadingActivation as 5th channel
393
+ sa_channel = _init_spreading_activation(db, vector_store)
394
+ if sa_channel is not None:
395
+ channels["spreading_activation"] = sa_channel
396
+
397
+ reranker = None
398
+ if config.retrieval.use_cross_encoder:
399
+ reranker = CrossEncoderReranker(config.retrieval.cross_encoder_model)
400
+
401
+ profile_ch = ProfileChannel(db)
402
+ bridge = BridgeDiscovery(db)
403
+
404
+ return RetrievalEngine(
405
+ db=db, config=config.retrieval, channels=channels,
406
+ embedder=embedder, reranker=reranker,
407
+ base_weights=config.channel_weights,
408
+ profile_channel=profile_ch,
409
+ bridge_discovery=bridge,
410
+ trust_scorer=trust_scorer,
411
+ )
412
+
413
+
414
+ # ---------------------------------------------------------------------------
415
+ # wire_hooks (was MemoryEngine._wire_hooks)
416
+ # ---------------------------------------------------------------------------
417
+
418
+ def wire_hooks(
419
+ hooks: HookRegistry,
420
+ config: SLMConfig,
421
+ db: DatabaseManager,
422
+ trust_scorer: Any,
423
+ profile_id: str,
424
+ ) -> dict[str, Any]:
425
+ """Wire trust, compliance, and event bus hooks into engine lifecycle.
426
+
427
+ Returns dict of created components (signal_recorder, audit_chain)
428
+ so engine can hold references.
429
+ """
430
+ result: dict[str, Any] = {"signal_recorder": None, "audit_chain": None}
431
+
432
+ # -- Pre-store hooks (synchronous, can reject) --
433
+ if trust_scorer:
434
+ from superlocalmemory.trust.gate import TrustGate
435
+ gate = TrustGate(trust_scorer)
436
+ hooks.register_pre("store", lambda ctx: gate.check_write(
437
+ ctx.get("agent_id", "unknown"), ctx.get("profile_id", profile_id)))
438
+ hooks.register_pre("delete", lambda ctx: gate.check_delete(
439
+ ctx.get("agent_id", "unknown"), ctx.get("profile_id", profile_id)))
440
+
441
+ # -- Post-store hooks (async, never block) --
442
+ if trust_scorer:
443
+ hooks.register_post("store", lambda ctx: trust_scorer.record_signal(
444
+ ctx.get("agent_id", "unknown"), ctx.get("profile_id", profile_id), "store_success"))
445
+ hooks.register_post("recall", lambda ctx: trust_scorer.record_signal(
446
+ ctx.get("agent_id", "unknown"), ctx.get("profile_id", profile_id), "recall_hit"))
447
+
448
+ # -- Burst detection via SignalRecorder --
449
+ try:
450
+ from superlocalmemory.trust.signals import SignalRecorder
451
+ signal_recorder = SignalRecorder(db)
452
+ hooks.register_post("store", lambda ctx: signal_recorder.record(
453
+ ctx.get("agent_id", "unknown"), ctx.get("profile_id", profile_id), "store_success"))
454
+ result["signal_recorder"] = signal_recorder
455
+ except Exception:
456
+ pass
457
+
458
+ # -- Tamper-proof audit chain (all operations logged with hash chain) --
459
+ try:
460
+ from superlocalmemory.compliance.audit import AuditChain
461
+ audit_path = config.db_path.parent / "audit_chain.db"
462
+ audit_chain = AuditChain(audit_path)
463
+ for op in ("store", "recall", "delete"):
464
+ hooks.register_post(op, lambda ctx, _op=op: audit_chain.log(
465
+ operation=_op,
466
+ agent_id=ctx.get("agent_id", "unknown"),
467
+ profile_id=ctx.get("profile_id", profile_id),
468
+ content_hash=ctx.get("content_hash", ""),
469
+ ))
470
+ result["audit_chain"] = audit_chain
471
+ except Exception:
472
+ pass
473
+
474
+ return result
@@ -0,0 +1,199 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory V3
4
+
5
+ """Graph structural analysis -- PageRank, community detection, centrality.
6
+
7
+ Reads BOTH graph_edges and association_edges for the full graph picture.
8
+ Stores results in fact_importance table.
9
+ Called during consolidation (Phase 5), not at query time.
10
+
11
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
12
+ License: MIT
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class GraphAnalyzer:
24
+ """Compute structural importance metrics for the memory graph.
25
+
26
+ - PageRank: global structural importance via networkx
27
+ - Community detection: Label Propagation via networkx
28
+ - Degree centrality: connection count normalization
29
+
30
+ Reads BOTH graph_edges and association_edges (Rule 13).
31
+ Stores results in fact_importance table.
32
+ """
33
+
34
+ def __init__(self, db: Any) -> None:
35
+ self._db = db
36
+
37
+ def compute_and_store(self, profile_id: str) -> dict[str, Any]:
38
+ """Run all analyses and persist to fact_importance.
39
+
40
+ Returns summary dict with node_count, community_count, top_5_nodes.
41
+ """
42
+ try:
43
+ graph = self._build_networkx_graph(profile_id)
44
+ if graph.number_of_nodes() == 0:
45
+ return {
46
+ "node_count": 0,
47
+ "edge_count": 0,
48
+ "community_count": 0,
49
+ "top_5_nodes": [],
50
+ }
51
+
52
+ pagerank = self.compute_pagerank(graph)
53
+ communities = self.detect_communities(graph)
54
+ centrality = self._compute_degree_centrality(graph)
55
+
56
+ # Persist to fact_importance
57
+ for node_id in graph.nodes():
58
+ pr_score = pagerank.get(node_id, 0.0)
59
+ comm_id = communities.get(node_id)
60
+ deg_cent = centrality.get(node_id, 0.0)
61
+ self._db.execute(
62
+ "INSERT OR REPLACE INTO fact_importance "
63
+ "(fact_id, profile_id, pagerank_score, community_id, "
64
+ " degree_centrality, computed_at) "
65
+ "VALUES (?, ?, ?, ?, ?, datetime('now'))",
66
+ (node_id, profile_id, round(pr_score, 6),
67
+ comm_id, round(deg_cent, 4)),
68
+ )
69
+
70
+ top_5 = sorted(
71
+ pagerank.items(), key=lambda x: x[1], reverse=True,
72
+ )[:5]
73
+ unique_communities = len(
74
+ set(c for c in communities.values() if c is not None),
75
+ )
76
+
77
+ return {
78
+ "node_count": graph.number_of_nodes(),
79
+ "edge_count": graph.number_of_edges(),
80
+ "community_count": unique_communities,
81
+ "top_5_nodes": [
82
+ (nid, round(score, 4)) for nid, score in top_5
83
+ ],
84
+ }
85
+ except Exception as exc:
86
+ logger.debug("GraphAnalyzer.compute_and_store failed: %s", exc)
87
+ return {
88
+ "node_count": 0,
89
+ "edge_count": 0,
90
+ "community_count": 0,
91
+ "top_5_nodes": [],
92
+ }
93
+
94
+ def compute_pagerank(
95
+ self,
96
+ graph: Any = None,
97
+ profile_id: str = "",
98
+ alpha: float = 0.85,
99
+ ) -> dict[str, float]:
100
+ """Compute PageRank using networkx.
101
+
102
+ alpha = damping factor (0.85 is standard).
103
+ """
104
+ import networkx as nx
105
+
106
+ if graph is None:
107
+ graph = self._build_networkx_graph(profile_id)
108
+ if graph.number_of_nodes() == 0:
109
+ return {}
110
+ try:
111
+ return nx.pagerank(graph, alpha=alpha, weight="weight")
112
+ except nx.PowerIterationFailedConvergence:
113
+ return nx.pagerank(graph, alpha=alpha, weight=None)
114
+
115
+ def detect_communities(
116
+ self,
117
+ graph: Any = None,
118
+ profile_id: str = "",
119
+ ) -> dict[str, int]:
120
+ """Detect communities via Label Propagation.
121
+
122
+ O(m) where m = edges (fast), no parameter tuning needed.
123
+ """
124
+ import networkx as nx
125
+ from networkx.algorithms.community import (
126
+ label_propagation_communities,
127
+ )
128
+
129
+ if graph is None:
130
+ graph = self._build_networkx_graph(profile_id)
131
+ if graph.number_of_nodes() == 0:
132
+ return {}
133
+
134
+ # Label propagation needs undirected graph
135
+ undirected = graph.to_undirected()
136
+ communities_gen = label_propagation_communities(undirected)
137
+ result: dict[str, int] = {}
138
+ for comm_id, community in enumerate(communities_gen):
139
+ for node in community:
140
+ result[node] = comm_id
141
+ return result
142
+
143
+ def _compute_degree_centrality(
144
+ self, graph: Any,
145
+ ) -> dict[str, float]:
146
+ """Degree centrality: fraction of nodes each node connects to."""
147
+ import networkx as nx
148
+
149
+ if graph.number_of_nodes() <= 1:
150
+ return {n: 0.0 for n in graph.nodes()}
151
+ return nx.degree_centrality(graph)
152
+
153
+ def _build_networkx_graph(self, profile_id: str) -> Any:
154
+ """Build networkx DiGraph from BOTH graph_edges + association_edges."""
155
+ import networkx as nx
156
+
157
+ g = nx.DiGraph()
158
+
159
+ # graph_edges
160
+ try:
161
+ rows = self._db.execute(
162
+ "SELECT source_id, target_id, weight, edge_type "
163
+ "FROM graph_edges WHERE profile_id = ?",
164
+ (profile_id,),
165
+ )
166
+ for row in rows:
167
+ d = dict(row)
168
+ g.add_edge(
169
+ d["source_id"], d["target_id"],
170
+ weight=d["weight"], edge_type=d["edge_type"],
171
+ )
172
+ except Exception as exc:
173
+ logger.debug("graph_edges read failed: %s", exc)
174
+
175
+ # association_edges
176
+ try:
177
+ rows = self._db.execute(
178
+ "SELECT source_fact_id, target_fact_id, weight, "
179
+ " association_type "
180
+ "FROM association_edges WHERE profile_id = ?",
181
+ (profile_id,),
182
+ )
183
+ for row in rows:
184
+ d = dict(row)
185
+ src, tgt = d["source_fact_id"], d["target_fact_id"]
186
+ if g.has_edge(src, tgt):
187
+ existing_w = g[src][tgt].get("weight", 0)
188
+ if d["weight"] > existing_w:
189
+ g[src][tgt]["weight"] = d["weight"]
190
+ else:
191
+ g.add_edge(
192
+ src, tgt,
193
+ weight=d["weight"],
194
+ edge_type=d["association_type"],
195
+ )
196
+ except Exception as exc:
197
+ logger.debug("association_edges read failed: %s", exc)
198
+
199
+ return g