superlocalmemory 4.0.9 → 4.1.0

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +7 -7
  4. package/package.json +4 -2
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +308 -20
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +26 -4
  44. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  45. package/src/superlocalmemory/code_graph/database.py +44 -0
  46. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  47. package/src/superlocalmemory/core/admission.py +231 -11
  48. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  49. package/src/superlocalmemory/core/config.py +90 -11
  50. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  51. package/src/superlocalmemory/core/engine.py +140 -11
  52. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  53. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  54. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  55. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  56. package/src/superlocalmemory/core/maintenance.py +44 -6
  57. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  58. package/src/superlocalmemory/core/memory_health.py +266 -0
  59. package/src/superlocalmemory/core/mode_capability.py +111 -0
  60. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  61. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  62. package/src/superlocalmemory/core/operation_request.py +1 -1
  63. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  64. package/src/superlocalmemory/core/projection_drain.py +380 -0
  65. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  66. package/src/superlocalmemory/core/recall_worker.py +6 -3
  67. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  68. package/src/superlocalmemory/core/scale_engine.py +16 -2
  69. package/src/superlocalmemory/core/score_contract.py +21 -1
  70. package/src/superlocalmemory/core/session_identity.py +85 -0
  71. package/src/superlocalmemory/core/status_contract.py +108 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  73. package/src/superlocalmemory/core/worker_pool.py +4 -4
  74. package/src/superlocalmemory/core/working_memory.py +288 -0
  75. package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
  76. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  77. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  78. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  79. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  80. package/src/superlocalmemory/encoding/type_router.py +12 -12
  81. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  82. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  83. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  84. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  85. package/src/superlocalmemory/learning/bandit.py +43 -0
  86. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  87. package/src/superlocalmemory/learning/database.py +60 -3
  88. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  89. package/src/superlocalmemory/learning/feedback.py +3 -1
  90. package/src/superlocalmemory/learning/outcomes.py +47 -16
  91. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  92. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  93. package/src/superlocalmemory/learning/pcos.py +291 -0
  94. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  96. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  97. package/src/superlocalmemory/mcp/profiles.py +14 -2
  98. package/src/superlocalmemory/mcp/server.py +1 -1
  99. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  100. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  101. package/src/superlocalmemory/mcp/tools_core.py +71 -42
  102. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  103. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  104. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  105. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  106. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
  107. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  108. package/src/superlocalmemory/retrieval/engine.py +106 -11
  109. package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
  110. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  111. package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
  112. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  113. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  114. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  115. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  116. package/src/superlocalmemory/server/api.py +26 -2
  117. package/src/superlocalmemory/server/asset_versions.py +171 -0
  118. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  119. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  120. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  121. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  122. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  123. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  124. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  125. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  126. package/src/superlocalmemory/server/routes/entity.py +13 -1
  127. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  128. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  129. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  130. package/src/superlocalmemory/server/routes/memories.py +49 -7
  131. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  132. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  133. package/src/superlocalmemory/server/routes/v3_api.py +193 -17
  134. package/src/superlocalmemory/server/ui.py +24 -1
  135. package/src/superlocalmemory/server/unified_daemon.py +292 -9
  136. package/src/superlocalmemory/storage/_migration_internals.py +35 -0
  137. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  138. package/src/superlocalmemory/storage/database.py +598 -82
  139. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  140. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  141. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  142. package/src/superlocalmemory/storage/migration_runner.py +130 -0
  143. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  144. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  145. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  146. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  147. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  148. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  149. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  150. package/src/superlocalmemory/storage/migrations.py +18 -2
  151. package/src/superlocalmemory/storage/models.py +40 -1
  152. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  153. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  154. package/src/superlocalmemory/storage/schema.py +110 -1
  155. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  156. package/src/superlocalmemory/summaries/base.py +1 -1
  157. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  158. package/src/superlocalmemory/trust/scorer.py +43 -1
  159. package/src/superlocalmemory/ui/index.html +10 -19
  160. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  161. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  162. package/src/superlocalmemory/ui/js/od-memories.js +209 -1
  163. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
  164. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  165. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -21,6 +21,7 @@ import threading
21
21
  from collections import defaultdict
22
22
  from typing import TYPE_CHECKING, Any
23
23
 
24
+ from superlocalmemory.retrieval import spreading
24
25
  from superlocalmemory.retrieval.scope_policy import (
25
26
  authorized_fact_ids,
26
27
  filter_authorized_results,
@@ -295,23 +296,58 @@ class EntityGraphChannel:
295
296
  ):
296
297
  return
297
298
  adj: dict[str, list[tuple[str, float]]] = defaultdict(list)
299
+ # The graph projection, when there is one, answers this in 395 ms where
300
+ # SQLite takes 2,477 ms on the same 208k-edge store (hand-measured, see
301
+ # graph/cozo_adjacency for the run; no test reproduces it) -- and this rebuild
302
+ # sits on the recall path, triggered by any edge-count change or a TTL
303
+ # expiry. It declines global and shared scope, because it stores one
304
+ # profile per edge and a short answer here would silently shrink the
305
+ # graph around a candidate. That decline is correct, not a degradation.
306
+ #
307
+ # This is the projection's only reader. Before it, the projection was
308
+ # maintained by the outbox, held at parity, purged on erasure, and
309
+ # queried by nothing.
310
+ triples: list[tuple[str, str, float]] | None = None
311
+ source_name = "sqlite"
298
312
  try:
299
- where, params = _scope_where(
300
- profile_id,
301
- include_global=include_global,
302
- include_shared=include_shared,
303
- )
304
- rows = self._db.execute(
305
- f"SELECT source_id, target_id, weight FROM graph_edges WHERE {where}",
306
- (*params,),
313
+ from superlocalmemory.graph.cozo_adjacency import adjacency_source
314
+
315
+ projection = (
316
+ adjacency_source() if self._projection_is_caught_up(profile_id) else None
307
317
  )
308
- except Exception:
309
- rows = []
310
- for r in rows:
311
- d = dict(r)
312
- s, t, w = d["source_id"], d["target_id"], float(d["weight"])
313
- adj[s].append((t, w))
314
- adj[t].append((s, w))
318
+ if projection is not None:
319
+ triples = projection.edges(
320
+ profile_id,
321
+ include_global=include_global,
322
+ include_shared=include_shared,
323
+ )
324
+ if triples is not None:
325
+ source_name = projection.name
326
+ except Exception: # noqa: BLE001 -- SQLite answers this unconditionally
327
+ triples = None
328
+ if triples is None:
329
+ try:
330
+ where, params = _scope_where(
331
+ profile_id,
332
+ include_global=include_global,
333
+ include_shared=include_shared,
334
+ )
335
+ rows = self._db.execute(
336
+ f"SELECT source_id, target_id, weight FROM graph_edges WHERE {where}",
337
+ (*params,),
338
+ )
339
+ except Exception:
340
+ rows = []
341
+ triples = []
342
+ for r in rows:
343
+ d = dict(r)
344
+ triples.append(
345
+ (d["source_id"], d["target_id"], float(d["weight"])),
346
+ )
347
+ self._adjacency_source_name = source_name
348
+ for edge_source, edge_target, edge_weight in triples:
349
+ adj[edge_source].append((edge_target, edge_weight))
350
+ adj[edge_target].append((edge_source, edge_weight))
315
351
  # Also load entity maps (same staleness lifecycle)
316
352
  self._load_entity_maps(
317
353
  profile_id,
@@ -338,6 +374,32 @@ class EntityGraphChannel:
338
374
  # v3.4.1: Load graph intelligence metrics (P0)
339
375
  self._load_graph_metrics(profile_id)
340
376
 
377
+ # One array-shaped view of the same graph, for the walk to run over.
378
+ # Built here rather than per query because it is derived entirely from
379
+ # the maps above and shares their staleness lifecycle exactly.
380
+ from superlocalmemory.retrieval.graph_adjacency import snapshot_from_maps
381
+
382
+ self._snapshot = snapshot_from_maps(
383
+ self._adj,
384
+ self._entity_to_facts,
385
+ self._fact_to_entities,
386
+ self._graph_metrics,
387
+ source=getattr(self, "_adjacency_source_name", "sqlite"),
388
+ profile_id=profile_id,
389
+ # Every visible fact is a node, including the ones with no edges
390
+ # yet. Ingestion is queryable-first, so a memory stored a moment ago
391
+ # has entities and no edges, and it must still be reachable.
392
+ nodes=self._visible_fact_ids,
393
+ # Only a real count. The staleness check above compares this value
394
+ # with ``==``, which a MagicMock tolerates; an ordering comparison
395
+ # does not, and the mock DBs in the test suite reach here.
396
+ fact_count=(
397
+ current_fact_count
398
+ if isinstance(current_fact_count, int) and current_fact_count >= 0
399
+ else 0
400
+ ),
401
+ )
402
+
341
403
  logger.info(
342
404
  "Loaded adjacency cache: %d nodes, %d edges, %d entity mappings for profile %s",
343
405
  len(self._adj),
@@ -346,6 +408,42 @@ class EntityGraphChannel:
346
408
  profile_id,
347
409
  )
348
410
 
411
+ def _projection_is_caught_up(self, profile_id: str) -> bool:
412
+ """Whether the second graph store has seen every change SQLite has.
413
+
414
+ The graph lives in two stores and no transaction spans them, so the
415
+ durable record of "this fact still needs projecting" is a queue row
416
+ written in the same transaction as the change. A row outstanding for
417
+ this profile is that store telling us, in its own words, that it is
418
+ behind -- and reading a graph that is behind means walking a link the
419
+ store has already removed.
420
+
421
+ This is a primary-key count on a table whose steady state is empty and
422
+ whose size is bounded by the fact count, so it is microseconds. The
423
+ alternative -- comparing the two edge sets -- costs 1.9 s on the
424
+ author's store and 7.7 s on the larger one, which is the whole recall
425
+ budget spent proving a cache is warm.
426
+ """
427
+ try:
428
+ from superlocalmemory.storage import projection_outbox
429
+
430
+ if not projection_outbox.is_available(self._db):
431
+ return True
432
+ rows = self._db.execute(
433
+ "SELECT COUNT(*) AS cnt FROM projection_outbox WHERE profile_id = ?",
434
+ (profile_id,),
435
+ )
436
+ pending = int(dict(rows[0]).get("cnt", 0)) if rows else 0
437
+ except Exception: # noqa: BLE001 -- an unreadable queue means read SQLite
438
+ return False
439
+ if pending:
440
+ logger.debug(
441
+ "adjacency: %d change(s) not yet in the graph projection for "
442
+ "profile %s; reading the store directly", pending, profile_id,
443
+ )
444
+ return False
445
+ return True
446
+
349
447
  def _get_edge_count(
350
448
  self,
351
449
  profile_id: str,
@@ -398,9 +496,15 @@ class EntityGraphChannel:
398
496
  include_global=include_global,
399
497
  include_shared=include_shared,
400
498
  )
499
+ # Withheld rows must not enter the entity map at all. They carry
500
+ # their whole cluster's pooled entity list, which is exactly why
501
+ # they out-ranked real memories here in the first place — leaving
502
+ # them in the map would keep spending this channel's budget on
503
+ # candidates that hydration then discards.
401
504
  rows = self._db.execute(
402
505
  "SELECT fact_id, canonical_entities_json "
403
- f"FROM atomic_facts WHERE {where} "
506
+ f"FROM atomic_facts WHERE {where}"
507
+ f"{self._db.visible_fact_clause()} "
404
508
  "ORDER BY created_at DESC LIMIT ?",
405
509
  (*params, _unbounded_facts_ceiling()),
406
510
  )
@@ -507,113 +611,93 @@ class EntityGraphChannel:
507
611
  include_shared=include_shared,
508
612
  )
509
613
 
510
- # v3.4.5: Route to CozoDB if active
511
- if self._cozo is not None:
512
- return self._search_via_cozo(
513
- query,
514
- raw_entities,
515
- profile_id,
516
- top_k,
517
- include_global=include_global,
518
- include_shared=include_shared,
519
- )
520
-
521
614
  canonical_ids = self._resolve_entities(raw_entities, profile_id)
522
615
  if not canonical_ids:
523
616
  return []
524
617
 
525
- # Seed activation from direct entity-linked facts
526
- # Use in-memory map when available, fall back to SQL for mock/test DBs
618
+ # One walk, over the array-shaped snapshot when there is one. The
619
+ # dict form below is kept for the mock/lightweight DBs that never build
620
+ # an adjacency cache, and it is the only path that still pays a Python
621
+ # loop per edge.
622
+ snapshot = getattr(self, "_snapshot", None)
623
+ if snapshot is not None and snapshot.node_count:
624
+ activation_result = spreading.activate(
625
+ snapshot,
626
+ canonical_ids,
627
+ decay=self._decay,
628
+ threshold=self._threshold,
629
+ max_hops=self._max_hops,
630
+ )
631
+ spreading.apply_community_bias(
632
+ activation_result.scores, snapshot, canonical_ids,
633
+ )
634
+ activation = activation_result.as_mapping(snapshot, threshold=-1.0)
635
+ if activation:
636
+ self._suppress_contradictions(activation, profile_id)
637
+ results = [
638
+ (fid, sc) for fid, sc in activation.items() if sc >= self._threshold
639
+ ]
640
+ if not results:
641
+ return []
642
+ max_score = max(sc for _, sc in results)
643
+ if max_score > 0:
644
+ results = [(fid, sc / max_score) for fid, sc in results]
645
+ results.sort(key=lambda x: (-x[1], x[0]))
646
+ return filter_authorized_results(
647
+ self._db,
648
+ results,
649
+ profile_id,
650
+ include_global=include_global,
651
+ include_shared=include_shared,
652
+ )[:top_k]
653
+
654
+ # Seed activation from direct entity-linked facts (no adjacency cache:
655
+ # mock and lightweight DBs only). Graph intelligence is unavailable on
656
+ # this path by design -- see Phase 7 LLD H-01.
527
657
  activation: dict[str, float] = defaultdict(float)
528
658
  visited_entities: set[str] = set(canonical_ids)
529
-
530
- use_cache = bool(self._entity_to_facts)
659
+ use_cache = False
531
660
  for eid in canonical_ids:
532
- if use_cache:
533
- for fid in self._entity_to_facts.get(eid, ()):
534
- activation[fid] = max(activation[fid], 1.0)
535
- else:
536
- for fact in self._db.get_facts_by_entity(
537
- eid,
538
- profile_id,
539
- include_global=include_global,
540
- include_shared=include_shared,
541
- ):
542
- activation[fact.fact_id] = max(activation[fact.fact_id], 1.0)
661
+ for fact in self._db.get_facts_by_entity(
662
+ eid,
663
+ profile_id,
664
+ include_global=include_global,
665
+ include_shared=include_shared,
666
+ ):
667
+ activation[fact.fact_id] = max(activation[fact.fact_id], 1.0)
543
668
 
544
- # Spreading activation through graph edges (all in-memory O(1) lookups)
545
669
  frontier = set(activation.keys())
546
670
  for hop in range(1, self._max_hops):
547
671
  hop_decay = self._decay**hop
548
672
  if hop_decay < self._threshold:
549
673
  break
550
674
  next_frontier: set[str] = set()
551
-
552
675
  for fid in frontier:
553
- if use_cache:
554
- neighbors = self._adj.get(fid, ())
555
- for neighbor, edge_weight in neighbors:
556
- # v3.4.2: Only apply edge_weight and PageRank bias when
557
- # graph metrics are available. Without metrics, edge_weight
558
- # dampens propagation by ~14% with no compensating boost,
559
- # causing retrieval regression (68.4% vs 70.4% on LoCoMo).
560
- if self._graph_metrics:
561
- weighted = activation[fid] * self._decay * edge_weight
562
- if neighbor in self._graph_metrics:
563
- target_pr = self._graph_metrics[neighbor].get("pagerank_score", 0.0)
564
- pr_boost = min(1.0 + target_pr * 2.0, 2.0)
565
- weighted *= pr_boost
566
- else:
567
- weighted = activation[fid] * self._decay
568
- if weighted >= self._threshold and weighted > activation.get(neighbor, 0.0):
569
- activation[neighbor] = weighted
570
- next_frontier.add(neighbor)
571
- else:
572
- # NOTE: SQL fallback path does NOT use graph intelligence (P1/P2/P3).
573
- # Graph intelligence is only available on the in-memory cache path.
574
- # This fallback exists for mock/test DBs. See Phase 7 LLD H-01.
575
- for edge in self._db.get_edges_for_node(
576
- fid,
577
- profile_id,
578
- include_global=include_global,
579
- include_shared=include_shared,
580
- ):
581
- neighbor = edge.target_id if edge.source_id == fid else edge.source_id
582
- propagated = activation[fid] * self._decay
583
- if propagated >= self._threshold and propagated > activation.get(
584
- neighbor, 0.0
585
- ):
586
- activation[neighbor] = propagated
587
- next_frontier.add(neighbor)
588
-
589
- # Discover new entities from activated facts
590
- if use_cache:
591
- new_eids: list[str] = []
592
- for fid in frontier:
593
- for eid in self._fact_to_entities.get(fid, ()):
594
- if eid not in visited_entities:
595
- visited_entities.add(eid)
596
- new_eids.append(eid)
597
- for eid in new_eids:
598
- for fid in self._entity_to_facts.get(eid, ()):
599
- if hop_decay > activation.get(fid, 0.0):
600
- activation[fid] = hop_decay
601
- next_frontier.add(fid)
602
- else:
603
- # SQL fallback (mock/test DBs)
604
- new_eids_sql = self._discover_entities(frontier, profile_id, visited_entities)
605
- for eid in new_eids_sql:
606
- visited_entities.add(eid)
607
- for fact in self._db.get_facts_by_entity(
608
- eid,
609
- profile_id,
610
- include_global=include_global,
611
- include_shared=include_shared,
676
+ for edge in self._db.get_edges_for_node(
677
+ fid,
678
+ profile_id,
679
+ include_global=include_global,
680
+ include_shared=include_shared,
681
+ ):
682
+ neighbor = edge.target_id if edge.source_id == fid else edge.source_id
683
+ propagated = activation[fid] * self._decay
684
+ if propagated >= self._threshold and propagated > activation.get(
685
+ neighbor, 0.0
612
686
  ):
613
- if hop_decay > activation.get(fact.fact_id, 0.0):
614
- activation[fact.fact_id] = hop_decay
615
- next_frontier.add(fact.fact_id)
616
-
687
+ activation[neighbor] = propagated
688
+ next_frontier.add(neighbor)
689
+ new_eids_sql = self._discover_entities(frontier, profile_id, visited_entities)
690
+ for eid in new_eids_sql:
691
+ visited_entities.add(eid)
692
+ for fact in self._db.get_facts_by_entity(
693
+ eid,
694
+ profile_id,
695
+ include_global=include_global,
696
+ include_shared=include_shared,
697
+ ):
698
+ if hop_decay > activation.get(fact.fact_id, 0.0):
699
+ activation[fact.fact_id] = hop_decay
700
+ next_frontier.add(fact.fact_id)
617
701
  frontier = next_frontier
618
702
  if not frontier:
619
703
  break
@@ -741,77 +825,32 @@ class EntityGraphChannel:
741
825
  if not canonical_ids:
742
826
  return {}
743
827
 
744
- # Run full spreading activation (same as search())
745
- activation: dict[str, float] = defaultdict(float)
746
- visited_entities: set[str] = set(canonical_ids)
747
- use_cache = bool(self._entity_to_facts)
748
-
749
- for eid in canonical_ids:
750
- if use_cache:
751
- for fid in self._entity_to_facts.get(eid, ()):
752
- activation[fid] = max(activation[fid], 1.0)
753
- else:
754
- for fact in self._db.get_facts_by_entity(
755
- eid,
756
- profile_id,
757
- include_global=include_global,
758
- include_shared=include_shared,
759
- ):
760
- activation[fact.fact_id] = max(activation[fact.fact_id], 1.0)
761
-
762
- frontier = set(activation.keys())
763
- for hop in range(1, self._max_hops):
764
- hop_decay = self._decay**hop
765
- if hop_decay < self._threshold:
766
- break
767
- next_frontier: set[str] = set()
768
- for fid in frontier:
769
- if use_cache:
770
- for neighbor, edge_weight in self._adj.get(fid, ()):
771
- if self._graph_metrics:
772
- weighted = activation[fid] * self._decay * edge_weight
773
- if neighbor in self._graph_metrics:
774
- pr = self._graph_metrics[neighbor].get("pagerank_score", 0.0)
775
- weighted *= min(1.0 + pr * 2.0, 2.0)
776
- else:
777
- weighted = activation[fid] * self._decay
778
- if weighted >= self._threshold and weighted > activation.get(neighbor, 0.0):
779
- activation[neighbor] = weighted
780
- next_frontier.add(neighbor)
781
-
782
- if use_cache:
783
- for fid in frontier:
784
- for eid in self._fact_to_entities.get(fid, ()):
785
- if eid not in visited_entities:
786
- visited_entities.add(eid)
787
- for linked_fid in self._entity_to_facts.get(eid, ()):
788
- if hop_decay > activation.get(linked_fid, 0.0):
789
- activation[linked_fid] = hop_decay
790
- next_frontier.add(linked_fid)
791
-
792
- frontier = next_frontier
793
- if not frontier:
794
- break
795
-
796
- # Community-aware boosting (same as search)
797
- if self._graph_metrics and use_cache:
798
- from collections import Counter as _Counter
799
-
800
- seed_communities: _Counter = _Counter()
801
- for eid in canonical_ids:
802
- for fid in self._entity_to_facts.get(eid, ()):
803
- m = self._graph_metrics.get(fid, {})
804
- comm = m.get("community_id")
805
- if comm is not None:
806
- seed_communities[comm] += 1
807
- if seed_communities:
808
- total_seeds = sum(seed_communities.values())
809
- for fid in list(activation.keys()):
810
- m = self._graph_metrics.get(fid, {})
811
- fact_comm = m.get("community_id")
812
- if fact_comm is not None and fact_comm in seed_communities:
813
- boost = min(1.0 + 0.15 * (seed_communities[fact_comm] / total_seeds), 1.3)
814
- activation[fid] *= boost
828
+ # The same walk as search(), over the same snapshot. This method used
829
+ # to carry its own copy of the loop, which is how the two drifted: the
830
+ # community bias here has never applied search()'s outsider penalty, and
831
+ # the only record of that was the absence of six lines.
832
+ snapshot = getattr(self, "_snapshot", None)
833
+ if snapshot is None or not snapshot.node_count:
834
+ return {}
835
+ activation_result = spreading.activate(
836
+ snapshot,
837
+ canonical_ids,
838
+ decay=self._decay,
839
+ threshold=self._threshold,
840
+ max_hops=self._max_hops,
841
+ )
842
+ spreading.apply_community_bias(
843
+ activation_result.scores,
844
+ snapshot,
845
+ canonical_ids,
846
+ # Re-scoring another channel's candidates, so a fact outside every
847
+ # seed community is not damped -- see apply_community_bias.
848
+ penalise_outsiders=False,
849
+ )
850
+ activation = {
851
+ fid: float(activation_result.scores[idx])
852
+ for fid, idx in snapshot.node_index.items()
853
+ }
815
854
 
816
855
  # Extract scores ONLY for the candidate set, normalize to [0, 1]
817
856
  candidate_set = allowed_candidates
@@ -953,82 +992,3 @@ class EntityGraphChannel:
953
992
  return new
954
993
 
955
994
  # v3.4.5: CozoDB-backed search (Sprint 2)
956
- def _search_via_cozo(
957
- self,
958
- query: str,
959
- raw_entities: list[str],
960
- profile_id: str,
961
- top_k: int,
962
- *,
963
- include_global: bool = False,
964
- include_shared: bool = False,
965
- ) -> list[tuple[str, float]]:
966
- """Entity graph search routed through CozoDB.
967
-
968
- Uses CozoDB for spreading activation — avoids loading
969
- the full adjacency graph into memory.
970
- Falls back to in-memory adjacency if CozoDB fails.
971
- """
972
- if not raw_entities:
973
- return []
974
-
975
- canonical_ids = self._resolve_entities(raw_entities, profile_id)
976
- if not canonical_ids:
977
- return []
978
-
979
- # Scoped/global recall has deliberately more complex authorization
980
- # semantics than the promoted default-profile projection. Never let
981
- # a projection broaden that boundary: SQLite remains authoritative.
982
- if include_global or include_shared:
983
- return self._search_without_cozo(query, profile_id, top_k)
984
-
985
- try:
986
- scored = self._cozo.recall_facts(
987
- canonical_ids,
988
- profile_id=profile_id,
989
- depth=self._max_hops,
990
- decay=self._decay,
991
- threshold=self._threshold,
992
- top_k=top_k * 2,
993
- )
994
-
995
- cozo_results = filter_authorized_results(
996
- self._db,
997
- scored,
998
- profile_id,
999
- include_global=include_global,
1000
- include_shared=include_shared,
1001
- )[:top_k]
1002
- # Shadow SQLite before accepting a projected answer. The graph
1003
- # channel has optional PageRank/community enrichments, so exact
1004
- # Score equality is neither required nor useful; result *membership*
1005
- # is the correctness contract. Order within the same fact set is
1006
- # tolerated — requiring identical ordering would fail closed on
1007
- # every query with score ties, leaving Cozo permanently unused.
1008
- # Any membership divergence is recorded and fails closed to SQLite.
1009
- sqlite_results = self._search_without_cozo(query, profile_id, top_k)
1010
- matches = {fact_id for fact_id, _ in cozo_results} == {
1011
- fact_id for fact_id, _ in sqlite_results
1012
- }
1013
- record = getattr(self._cozo, "record_shadow_comparison", None)
1014
- if callable(record):
1015
- record(matches=matches, projected=cozo_results, canonical=sqlite_results)
1016
- return cozo_results if matches else sqlite_results
1017
- except Exception as exc:
1018
- record = getattr(self._cozo, "record_shadow_error", None)
1019
- if callable(record):
1020
- record(str(exc))
1021
- return self._search_without_cozo(query, profile_id, top_k)
1022
-
1023
- def _search_without_cozo(
1024
- self,
1025
- query: str,
1026
- profile_id: str,
1027
- top_k: int,
1028
- ) -> list[tuple[str, float]]:
1029
- """Run canonical SQLite entity recall without recursive projection use."""
1030
- cozo, self._cozo = self._cozo, None
1031
- try:
1032
- return self._search_locked(query, profile_id, top_k)
1033
- finally:
1034
- self._cozo = cozo