superlocalmemory 4.0.10 → 4.1.2

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  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 +357 -18
  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 +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. 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,
@@ -513,113 +611,93 @@ class EntityGraphChannel:
513
611
  include_shared=include_shared,
514
612
  )
515
613
 
516
- # v3.4.5: Route to CozoDB if active
517
- if self._cozo is not None:
518
- return self._search_via_cozo(
519
- query,
520
- raw_entities,
521
- profile_id,
522
- top_k,
523
- include_global=include_global,
524
- include_shared=include_shared,
525
- )
526
-
527
614
  canonical_ids = self._resolve_entities(raw_entities, profile_id)
528
615
  if not canonical_ids:
529
616
  return []
530
617
 
531
- # Seed activation from direct entity-linked facts
532
- # 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.
533
657
  activation: dict[str, float] = defaultdict(float)
534
658
  visited_entities: set[str] = set(canonical_ids)
535
-
536
- use_cache = bool(self._entity_to_facts)
659
+ use_cache = False
537
660
  for eid in canonical_ids:
538
- if use_cache:
539
- for fid in self._entity_to_facts.get(eid, ()):
540
- activation[fid] = max(activation[fid], 1.0)
541
- else:
542
- for fact in self._db.get_facts_by_entity(
543
- eid,
544
- profile_id,
545
- include_global=include_global,
546
- include_shared=include_shared,
547
- ):
548
- 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)
549
668
 
550
- # Spreading activation through graph edges (all in-memory O(1) lookups)
551
669
  frontier = set(activation.keys())
552
670
  for hop in range(1, self._max_hops):
553
671
  hop_decay = self._decay**hop
554
672
  if hop_decay < self._threshold:
555
673
  break
556
674
  next_frontier: set[str] = set()
557
-
558
675
  for fid in frontier:
559
- if use_cache:
560
- neighbors = self._adj.get(fid, ())
561
- for neighbor, edge_weight in neighbors:
562
- # v3.4.2: Only apply edge_weight and PageRank bias when
563
- # graph metrics are available. Without metrics, edge_weight
564
- # dampens propagation by ~14% with no compensating boost,
565
- # causing retrieval regression (68.4% vs 70.4% on LoCoMo).
566
- if self._graph_metrics:
567
- weighted = activation[fid] * self._decay * edge_weight
568
- if neighbor in self._graph_metrics:
569
- target_pr = self._graph_metrics[neighbor].get("pagerank_score", 0.0)
570
- pr_boost = min(1.0 + target_pr * 2.0, 2.0)
571
- weighted *= pr_boost
572
- else:
573
- weighted = activation[fid] * self._decay
574
- if weighted >= self._threshold and weighted > activation.get(neighbor, 0.0):
575
- activation[neighbor] = weighted
576
- next_frontier.add(neighbor)
577
- else:
578
- # NOTE: SQL fallback path does NOT use graph intelligence (P1/P2/P3).
579
- # Graph intelligence is only available on the in-memory cache path.
580
- # This fallback exists for mock/test DBs. See Phase 7 LLD H-01.
581
- for edge in self._db.get_edges_for_node(
582
- fid,
583
- profile_id,
584
- include_global=include_global,
585
- include_shared=include_shared,
586
- ):
587
- neighbor = edge.target_id if edge.source_id == fid else edge.source_id
588
- propagated = activation[fid] * self._decay
589
- if propagated >= self._threshold and propagated > activation.get(
590
- neighbor, 0.0
591
- ):
592
- activation[neighbor] = propagated
593
- next_frontier.add(neighbor)
594
-
595
- # Discover new entities from activated facts
596
- if use_cache:
597
- new_eids: list[str] = []
598
- for fid in frontier:
599
- for eid in self._fact_to_entities.get(fid, ()):
600
- if eid not in visited_entities:
601
- visited_entities.add(eid)
602
- new_eids.append(eid)
603
- for eid in new_eids:
604
- for fid in self._entity_to_facts.get(eid, ()):
605
- if hop_decay > activation.get(fid, 0.0):
606
- activation[fid] = hop_decay
607
- next_frontier.add(fid)
608
- else:
609
- # SQL fallback (mock/test DBs)
610
- new_eids_sql = self._discover_entities(frontier, profile_id, visited_entities)
611
- for eid in new_eids_sql:
612
- visited_entities.add(eid)
613
- for fact in self._db.get_facts_by_entity(
614
- eid,
615
- profile_id,
616
- include_global=include_global,
617
- 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
618
686
  ):
619
- if hop_decay > activation.get(fact.fact_id, 0.0):
620
- activation[fact.fact_id] = hop_decay
621
- next_frontier.add(fact.fact_id)
622
-
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)
623
701
  frontier = next_frontier
624
702
  if not frontier:
625
703
  break
@@ -747,77 +825,32 @@ class EntityGraphChannel:
747
825
  if not canonical_ids:
748
826
  return {}
749
827
 
750
- # Run full spreading activation (same as search())
751
- activation: dict[str, float] = defaultdict(float)
752
- visited_entities: set[str] = set(canonical_ids)
753
- use_cache = bool(self._entity_to_facts)
754
-
755
- for eid in canonical_ids:
756
- if use_cache:
757
- for fid in self._entity_to_facts.get(eid, ()):
758
- activation[fid] = max(activation[fid], 1.0)
759
- else:
760
- for fact in self._db.get_facts_by_entity(
761
- eid,
762
- profile_id,
763
- include_global=include_global,
764
- include_shared=include_shared,
765
- ):
766
- activation[fact.fact_id] = max(activation[fact.fact_id], 1.0)
767
-
768
- frontier = set(activation.keys())
769
- for hop in range(1, self._max_hops):
770
- hop_decay = self._decay**hop
771
- if hop_decay < self._threshold:
772
- break
773
- next_frontier: set[str] = set()
774
- for fid in frontier:
775
- if use_cache:
776
- for neighbor, edge_weight in self._adj.get(fid, ()):
777
- if self._graph_metrics:
778
- weighted = activation[fid] * self._decay * edge_weight
779
- if neighbor in self._graph_metrics:
780
- pr = self._graph_metrics[neighbor].get("pagerank_score", 0.0)
781
- weighted *= min(1.0 + pr * 2.0, 2.0)
782
- else:
783
- weighted = activation[fid] * self._decay
784
- if weighted >= self._threshold and weighted > activation.get(neighbor, 0.0):
785
- activation[neighbor] = weighted
786
- next_frontier.add(neighbor)
787
-
788
- if use_cache:
789
- for fid in frontier:
790
- for eid in self._fact_to_entities.get(fid, ()):
791
- if eid not in visited_entities:
792
- visited_entities.add(eid)
793
- for linked_fid in self._entity_to_facts.get(eid, ()):
794
- if hop_decay > activation.get(linked_fid, 0.0):
795
- activation[linked_fid] = hop_decay
796
- next_frontier.add(linked_fid)
797
-
798
- frontier = next_frontier
799
- if not frontier:
800
- break
801
-
802
- # Community-aware boosting (same as search)
803
- if self._graph_metrics and use_cache:
804
- from collections import Counter as _Counter
805
-
806
- seed_communities: _Counter = _Counter()
807
- for eid in canonical_ids:
808
- for fid in self._entity_to_facts.get(eid, ()):
809
- m = self._graph_metrics.get(fid, {})
810
- comm = m.get("community_id")
811
- if comm is not None:
812
- seed_communities[comm] += 1
813
- if seed_communities:
814
- total_seeds = sum(seed_communities.values())
815
- for fid in list(activation.keys()):
816
- m = self._graph_metrics.get(fid, {})
817
- fact_comm = m.get("community_id")
818
- if fact_comm is not None and fact_comm in seed_communities:
819
- boost = min(1.0 + 0.15 * (seed_communities[fact_comm] / total_seeds), 1.3)
820
- 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
+ }
821
854
 
822
855
  # Extract scores ONLY for the candidate set, normalize to [0, 1]
823
856
  candidate_set = allowed_candidates
@@ -959,82 +992,3 @@ class EntityGraphChannel:
959
992
  return new
960
993
 
961
994
  # v3.4.5: CozoDB-backed search (Sprint 2)
962
- def _search_via_cozo(
963
- self,
964
- query: str,
965
- raw_entities: list[str],
966
- profile_id: str,
967
- top_k: int,
968
- *,
969
- include_global: bool = False,
970
- include_shared: bool = False,
971
- ) -> list[tuple[str, float]]:
972
- """Entity graph search routed through CozoDB.
973
-
974
- Uses CozoDB for spreading activation — avoids loading
975
- the full adjacency graph into memory.
976
- Falls back to in-memory adjacency if CozoDB fails.
977
- """
978
- if not raw_entities:
979
- return []
980
-
981
- canonical_ids = self._resolve_entities(raw_entities, profile_id)
982
- if not canonical_ids:
983
- return []
984
-
985
- # Scoped/global recall has deliberately more complex authorization
986
- # semantics than the promoted default-profile projection. Never let
987
- # a projection broaden that boundary: SQLite remains authoritative.
988
- if include_global or include_shared:
989
- return self._search_without_cozo(query, profile_id, top_k)
990
-
991
- try:
992
- scored = self._cozo.recall_facts(
993
- canonical_ids,
994
- profile_id=profile_id,
995
- depth=self._max_hops,
996
- decay=self._decay,
997
- threshold=self._threshold,
998
- top_k=top_k * 2,
999
- )
1000
-
1001
- cozo_results = filter_authorized_results(
1002
- self._db,
1003
- scored,
1004
- profile_id,
1005
- include_global=include_global,
1006
- include_shared=include_shared,
1007
- )[:top_k]
1008
- # Shadow SQLite before accepting a projected answer. The graph
1009
- # channel has optional PageRank/community enrichments, so exact
1010
- # Score equality is neither required nor useful; result *membership*
1011
- # is the correctness contract. Order within the same fact set is
1012
- # tolerated — requiring identical ordering would fail closed on
1013
- # every query with score ties, leaving Cozo permanently unused.
1014
- # Any membership divergence is recorded and fails closed to SQLite.
1015
- sqlite_results = self._search_without_cozo(query, profile_id, top_k)
1016
- matches = {fact_id for fact_id, _ in cozo_results} == {
1017
- fact_id for fact_id, _ in sqlite_results
1018
- }
1019
- record = getattr(self._cozo, "record_shadow_comparison", None)
1020
- if callable(record):
1021
- record(matches=matches, projected=cozo_results, canonical=sqlite_results)
1022
- return cozo_results if matches else sqlite_results
1023
- except Exception as exc:
1024
- record = getattr(self._cozo, "record_shadow_error", None)
1025
- if callable(record):
1026
- record(str(exc))
1027
- return self._search_without_cozo(query, profile_id, top_k)
1028
-
1029
- def _search_without_cozo(
1030
- self,
1031
- query: str,
1032
- profile_id: str,
1033
- top_k: int,
1034
- ) -> list[tuple[str, float]]:
1035
- """Run canonical SQLite entity recall without recursive projection use."""
1036
- cozo, self._cozo = self._cozo, None
1037
- try:
1038
- return self._search_locked(query, profile_id, top_k)
1039
- finally:
1040
- self._cozo = cozo