superlocalmemory 4.0.10 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +170 -0
- package/README.md +7 -7
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- 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 +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- 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-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- 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/cli/commands.py +263 -18
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +24 -2
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +31 -3
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/server/api.py +24 -5
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +191 -15
- package/src/superlocalmemory/server/ui.py +20 -4
- package/src/superlocalmemory/server/unified_daemon.py +186 -5
- package/src/superlocalmemory/storage/_migration_internals.py +31 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +477 -59
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +119 -0
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +12 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +9 -18
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +19 -0
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- 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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
#
|
|
532
|
-
#
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
#
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
for fid in
|
|
775
|
-
|
|
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
|