superlocalmemory 3.8.6 → 3.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +7 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/core/backend_orchestrator.py +7 -1
- package/src/superlocalmemory/core/embeddings.py +73 -11
- package/src/superlocalmemory/core/engine.py +6 -1
- package/src/superlocalmemory/core/ollama_embedder.py +5 -0
- package/src/superlocalmemory/core/recall_gate.py +39 -4
- package/src/superlocalmemory/core/recall_pipeline.py +40 -0
- package/src/superlocalmemory/encoding/scene_builder.py +105 -15
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/retrieval/entity_channel.py +201 -56
- package/src/superlocalmemory/retrieval/vector_store.py +238 -123
- package/src/superlocalmemory/server/recall_health.py +3 -1
- package/src/superlocalmemory/server/unified_daemon.py +104 -16
- package/src/superlocalmemory/storage/embedding_migrator.py +88 -60
|
@@ -38,6 +38,8 @@ class SceneBuilder:
|
|
|
38
38
|
def __init__(self, db, embedder=None) -> None:
|
|
39
39
|
self._db = db
|
|
40
40
|
self._embedder = embedder
|
|
41
|
+
# Key by scene ID, never theme. Themes are deliberately non-unique,
|
|
42
|
+
# while eligibility and durable anchor membership are scene-specific.
|
|
41
43
|
self._scene_embeddings_cache: dict[str, list[float]] = {}
|
|
42
44
|
|
|
43
45
|
def assign_to_scene(
|
|
@@ -53,8 +55,12 @@ class SceneBuilder:
|
|
|
53
55
|
if self._embedder is None:
|
|
54
56
|
return self._create_scene(new_fact, profile_id)
|
|
55
57
|
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
+
# Canonical ingestion already embeds the fact before scene assignment.
|
|
59
|
+
# Reuse that vector so scene clustering does not issue a duplicate model
|
|
60
|
+
# request for every remembered fact.
|
|
61
|
+
fact_emb = new_fact.embedding
|
|
62
|
+
if fact_emb is None:
|
|
63
|
+
fact_emb = self._embedder.embed(new_fact.content)
|
|
58
64
|
|
|
59
65
|
# v3.4.38: Defensive None guard. embedder.embed() returns None when
|
|
60
66
|
# the embedding worker is unavailable (timeout, crash). Without this
|
|
@@ -69,30 +75,58 @@ class SceneBuilder:
|
|
|
69
75
|
if not scenes:
|
|
70
76
|
return self._create_scene(new_fact, profile_id)
|
|
71
77
|
|
|
78
|
+
live_scene_embeddings = self._load_live_scene_embeddings(profile_id)
|
|
79
|
+
live_scene_ids = set(live_scene_embeddings)
|
|
80
|
+
self._scene_embeddings_cache.update({
|
|
81
|
+
scene_id: embedding
|
|
82
|
+
for scene_id, embedding in live_scene_embeddings.items()
|
|
83
|
+
if embedding is not None
|
|
84
|
+
})
|
|
85
|
+
# Old consolidation/deletion paths left scene rows whose fact IDs no
|
|
86
|
+
# longer exist. They are not evidence and must not trigger thousands of
|
|
87
|
+
# replacement model calls after restart. A cache hit cannot prove that
|
|
88
|
+
# a scene still has a surviving fact, so eligibility is always derived
|
|
89
|
+
# from the current database state.
|
|
90
|
+
scenes = [
|
|
91
|
+
scene for scene in scenes if scene.scene_id in live_scene_ids
|
|
92
|
+
]
|
|
93
|
+
if not scenes:
|
|
94
|
+
return self._create_scene(new_fact, profile_id)
|
|
95
|
+
|
|
72
96
|
# Find best matching scene
|
|
73
97
|
best_scene: MemoryScene | None = None
|
|
74
98
|
best_sim = -1.0
|
|
75
99
|
|
|
76
|
-
#
|
|
100
|
+
# A scene's theme is derived from its first (anchor) fact. On daemon
|
|
101
|
+
# restart the in-memory cache is empty, but the anchor embeddings remain
|
|
102
|
+
# durable in atomic_facts. Prime from those vectors before calling the
|
|
103
|
+
# model; otherwise a mature database re-embeds thousands of themes and
|
|
104
|
+
# repeatedly recycles the shared foreground worker.
|
|
105
|
+
# V3.3.27: Batch-embed all still-uncached scene themes in ONE call.
|
|
77
106
|
# Previously: 200+ individual embed() calls per fact (30s on Mode B).
|
|
78
107
|
# Now: 1 batch call for all uncached themes, then cache hits for the rest.
|
|
79
|
-
|
|
80
|
-
|
|
108
|
+
uncached_scenes = [
|
|
109
|
+
scene for scene in scenes
|
|
110
|
+
if scene.scene_id not in self._scene_embeddings_cache
|
|
111
|
+
]
|
|
112
|
+
if uncached_scenes and hasattr(self._embedder, 'embed_batch'):
|
|
81
113
|
try:
|
|
82
|
-
batch_embs = self._embedder.embed_batch(
|
|
83
|
-
|
|
114
|
+
batch_embs = self._embedder.embed_batch(
|
|
115
|
+
[scene.theme for scene in uncached_scenes]
|
|
116
|
+
)
|
|
117
|
+
for scene, emb in zip(uncached_scenes, batch_embs):
|
|
84
118
|
if emb is not None:
|
|
85
|
-
self._scene_embeddings_cache[
|
|
119
|
+
self._scene_embeddings_cache[scene.scene_id] = emb
|
|
86
120
|
except Exception:
|
|
87
121
|
pass # Fall through to individual embeds below
|
|
88
122
|
|
|
89
123
|
for scene in scenes:
|
|
90
|
-
if scene.
|
|
91
|
-
theme_emb = self._scene_embeddings_cache[scene.
|
|
124
|
+
if scene.scene_id in self._scene_embeddings_cache:
|
|
125
|
+
theme_emb = self._scene_embeddings_cache[scene.scene_id]
|
|
92
126
|
else:
|
|
93
127
|
theme_emb = self._embedder.embed(scene.theme)
|
|
94
128
|
if theme_emb is not None:
|
|
95
|
-
self._scene_embeddings_cache[scene.
|
|
129
|
+
self._scene_embeddings_cache[scene.scene_id] = theme_emb
|
|
96
130
|
if theme_emb is None:
|
|
97
131
|
continue
|
|
98
132
|
sim = _cosine(fact_emb, theme_emb)
|
|
@@ -130,10 +164,6 @@ class SceneBuilder:
|
|
|
130
164
|
comparisons in assign_to_scene.
|
|
131
165
|
"""
|
|
132
166
|
theme = fact.content[:200]
|
|
133
|
-
# Pre-compute theme embedding for future comparisons
|
|
134
|
-
if self._embedder is not None:
|
|
135
|
-
self._scene_embeddings_cache[theme] = self._embedder.embed(theme)
|
|
136
|
-
|
|
137
167
|
scene = MemoryScene(
|
|
138
168
|
profile_id=profile_id,
|
|
139
169
|
theme=theme,
|
|
@@ -142,6 +172,14 @@ class SceneBuilder:
|
|
|
142
172
|
created_at=datetime.now(UTC).isoformat(),
|
|
143
173
|
last_updated=datetime.now(UTC).isoformat(),
|
|
144
174
|
)
|
|
175
|
+
# Pre-compute theme embedding for future comparisons. The canonical fact
|
|
176
|
+
# vector represents this exact theme and avoids a duplicate model call.
|
|
177
|
+
if self._embedder is not None:
|
|
178
|
+
theme_embedding = fact.embedding
|
|
179
|
+
if theme_embedding is None:
|
|
180
|
+
theme_embedding = self._embedder.embed(theme)
|
|
181
|
+
if theme_embedding is not None:
|
|
182
|
+
self._scene_embeddings_cache[scene.scene_id] = theme_embedding
|
|
145
183
|
self._save_scene(scene)
|
|
146
184
|
return scene
|
|
147
185
|
|
|
@@ -171,6 +209,58 @@ class SceneBuilder:
|
|
|
171
209
|
)
|
|
172
210
|
return [self._row_to_scene(dict(r)) for r in rows]
|
|
173
211
|
|
|
212
|
+
def _load_live_scene_embeddings(
|
|
213
|
+
self,
|
|
214
|
+
profile_id: str,
|
|
215
|
+
) -> dict[str, list[float] | None]:
|
|
216
|
+
"""Load one durable anchor embedding for every live scene.
|
|
217
|
+
|
|
218
|
+
``json_each`` resolves the first still-existing fact in each scene, so
|
|
219
|
+
scenes whose original anchor was consolidated away can still reuse a
|
|
220
|
+
surviving member. The result also identifies fully stale scene rows,
|
|
221
|
+
which are ignored by assignment instead of being re-embedded.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
rows = self._db.execute(
|
|
225
|
+
"""
|
|
226
|
+
WITH live_scene_facts AS (
|
|
227
|
+
SELECT
|
|
228
|
+
ms.scene_id,
|
|
229
|
+
ms.theme,
|
|
230
|
+
af.embedding,
|
|
231
|
+
ROW_NUMBER() OVER (
|
|
232
|
+
PARTITION BY ms.scene_id
|
|
233
|
+
ORDER BY CAST(member.key AS INTEGER)
|
|
234
|
+
) AS member_rank
|
|
235
|
+
FROM memory_scenes AS ms
|
|
236
|
+
JOIN json_each(ms.fact_ids_json) AS member
|
|
237
|
+
JOIN atomic_facts AS af
|
|
238
|
+
ON af.fact_id = member.value
|
|
239
|
+
AND af.profile_id = ms.profile_id
|
|
240
|
+
WHERE ms.profile_id = ?
|
|
241
|
+
)
|
|
242
|
+
SELECT scene_id, embedding
|
|
243
|
+
FROM live_scene_facts
|
|
244
|
+
WHERE member_rank = 1
|
|
245
|
+
""",
|
|
246
|
+
(profile_id,),
|
|
247
|
+
)
|
|
248
|
+
except Exception:
|
|
249
|
+
return {}
|
|
250
|
+
|
|
251
|
+
result: dict[str, list[float] | None] = {}
|
|
252
|
+
for row in rows:
|
|
253
|
+
data = dict(row)
|
|
254
|
+
raw_embedding = data.get("embedding")
|
|
255
|
+
embedding = None
|
|
256
|
+
if raw_embedding:
|
|
257
|
+
try:
|
|
258
|
+
embedding = json.loads(raw_embedding)
|
|
259
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
260
|
+
embedding = None
|
|
261
|
+
result[str(data["scene_id"])] = embedding
|
|
262
|
+
return result
|
|
263
|
+
|
|
174
264
|
def _save_scene(self, scene: MemoryScene) -> None:
|
|
175
265
|
"""Upsert scene to DB."""
|
|
176
266
|
self._db.execute(
|
|
@@ -75,12 +75,12 @@ class _CozoResult:
|
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
class _CozoClientAdapter:
|
|
78
|
-
"""Bridge legacy PyCozo responses and the
|
|
78
|
+
"""Bridge legacy PyCozo responses and the store-compatible client surface.
|
|
79
79
|
|
|
80
|
-
PyCozo 0.
|
|
81
|
-
and
|
|
82
|
-
|
|
83
|
-
and row results, so normalize both forms here.
|
|
80
|
+
SLM graph stores were established with PyCozo 0.3.0. That client returns
|
|
81
|
+
dictionaries and exposes ``import_relations`` rather than ``put``; later
|
|
82
|
+
clients return dataframe-like values and add ``put``. SLM only needs
|
|
83
|
+
relation upserts and row results, so normalize both forms here.
|
|
84
84
|
"""
|
|
85
85
|
|
|
86
86
|
def __init__(self, client: Any) -> None:
|
|
@@ -10,6 +10,7 @@ with decay. Handles BOTH uppercase and lowercase entity mentions.
|
|
|
10
10
|
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
11
11
|
License: AGPL-3.0-or-later
|
|
12
12
|
"""
|
|
13
|
+
|
|
13
14
|
from __future__ import annotations
|
|
14
15
|
|
|
15
16
|
import json
|
|
@@ -24,7 +25,10 @@ from superlocalmemory.retrieval.scope_policy import (
|
|
|
24
25
|
authorized_fact_ids,
|
|
25
26
|
filter_authorized_results,
|
|
26
27
|
)
|
|
27
|
-
from superlocalmemory.storage.database import
|
|
28
|
+
from superlocalmemory.storage.database import (
|
|
29
|
+
_scope_where,
|
|
30
|
+
_unbounded_facts_ceiling,
|
|
31
|
+
)
|
|
28
32
|
|
|
29
33
|
if TYPE_CHECKING:
|
|
30
34
|
from superlocalmemory.encoding.entity_resolver import EntityResolver
|
|
@@ -50,26 +54,126 @@ def _adj_ttl_seconds() -> float:
|
|
|
50
54
|
except (TypeError, ValueError):
|
|
51
55
|
return 3600.0
|
|
52
56
|
|
|
57
|
+
|
|
53
58
|
_PROPER_NOUN_RE = re.compile(r"\b[A-Z][a-z]{1,}\b")
|
|
54
59
|
|
|
55
|
-
_ENTITY_STOP: frozenset[str] = frozenset(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
60
|
+
_ENTITY_STOP: frozenset[str] = frozenset(
|
|
61
|
+
{
|
|
62
|
+
# Expanded stop list for query entity extraction
|
|
63
|
+
"what",
|
|
64
|
+
"when",
|
|
65
|
+
"where",
|
|
66
|
+
"who",
|
|
67
|
+
"which",
|
|
68
|
+
"how",
|
|
69
|
+
"does",
|
|
70
|
+
"did",
|
|
71
|
+
"the",
|
|
72
|
+
"that",
|
|
73
|
+
"this",
|
|
74
|
+
"there",
|
|
75
|
+
"then",
|
|
76
|
+
"than",
|
|
77
|
+
"they",
|
|
78
|
+
"them",
|
|
79
|
+
"have",
|
|
80
|
+
"has",
|
|
81
|
+
"had",
|
|
82
|
+
"been",
|
|
83
|
+
"being",
|
|
84
|
+
"about",
|
|
85
|
+
"after",
|
|
86
|
+
"before",
|
|
87
|
+
"from",
|
|
88
|
+
"into",
|
|
89
|
+
"with",
|
|
90
|
+
"some",
|
|
91
|
+
"other",
|
|
92
|
+
"would",
|
|
93
|
+
"could",
|
|
94
|
+
"should",
|
|
95
|
+
"will",
|
|
96
|
+
"because",
|
|
97
|
+
"also",
|
|
98
|
+
"just",
|
|
99
|
+
"like",
|
|
100
|
+
"know",
|
|
101
|
+
"think",
|
|
102
|
+
"feel",
|
|
103
|
+
"want",
|
|
104
|
+
"need",
|
|
105
|
+
"make",
|
|
106
|
+
"take",
|
|
107
|
+
"give",
|
|
108
|
+
"tell",
|
|
109
|
+
"said",
|
|
110
|
+
"wow",
|
|
111
|
+
"gonna",
|
|
112
|
+
"got",
|
|
113
|
+
"by",
|
|
114
|
+
"thanks",
|
|
115
|
+
"thank",
|
|
116
|
+
"hey",
|
|
117
|
+
"hi",
|
|
118
|
+
"hello",
|
|
119
|
+
"bye",
|
|
120
|
+
"good",
|
|
121
|
+
"great",
|
|
122
|
+
"nice",
|
|
123
|
+
"cool",
|
|
124
|
+
"right",
|
|
125
|
+
"let",
|
|
126
|
+
"can",
|
|
127
|
+
"might",
|
|
128
|
+
"much",
|
|
129
|
+
"many",
|
|
130
|
+
"more",
|
|
131
|
+
"most",
|
|
132
|
+
"something",
|
|
133
|
+
"anything",
|
|
134
|
+
"everything",
|
|
135
|
+
"nothing",
|
|
136
|
+
"someone",
|
|
137
|
+
"it",
|
|
138
|
+
"my",
|
|
139
|
+
"your",
|
|
140
|
+
"our",
|
|
141
|
+
"their",
|
|
142
|
+
"me",
|
|
143
|
+
"you",
|
|
144
|
+
"we",
|
|
145
|
+
"us",
|
|
146
|
+
"do",
|
|
147
|
+
"if",
|
|
148
|
+
"or",
|
|
149
|
+
"no",
|
|
150
|
+
"to",
|
|
151
|
+
"at",
|
|
152
|
+
"on",
|
|
153
|
+
"in",
|
|
154
|
+
"so",
|
|
155
|
+
"go",
|
|
156
|
+
"come",
|
|
157
|
+
"see",
|
|
158
|
+
"look",
|
|
159
|
+
"say",
|
|
160
|
+
"ask",
|
|
161
|
+
"try",
|
|
162
|
+
"keep",
|
|
163
|
+
"yes",
|
|
164
|
+
"yeah",
|
|
165
|
+
"sure",
|
|
166
|
+
"okay",
|
|
167
|
+
"ok",
|
|
168
|
+
"really",
|
|
169
|
+
"actually",
|
|
170
|
+
"maybe",
|
|
171
|
+
"well",
|
|
172
|
+
"still",
|
|
173
|
+
"even",
|
|
174
|
+
"very",
|
|
175
|
+
}
|
|
176
|
+
)
|
|
73
177
|
|
|
74
178
|
|
|
75
179
|
def extract_query_entities(query: str) -> list[str]:
|
|
@@ -94,10 +198,10 @@ def extract_query_entities(query: str) -> list[str]:
|
|
|
94
198
|
for m in re.finditer(r'"([^"]+)"', query):
|
|
95
199
|
_add(m.group(1).strip())
|
|
96
200
|
# Also extract multi-word capitalized sequences (e.g. "New York", "San Francisco")
|
|
97
|
-
for m in re.finditer(r
|
|
201
|
+
for m in re.finditer(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b", query):
|
|
98
202
|
_add(m.group(1))
|
|
99
203
|
# Extract all-caps abbreviations (e.g. NYU, MIT, UCLA) — min 2 chars
|
|
100
|
-
for m in re.finditer(r
|
|
204
|
+
for m in re.finditer(r"\b([A-Z]{2,})\b", query):
|
|
101
205
|
_add(m.group(1))
|
|
102
206
|
|
|
103
207
|
return candidates
|
|
@@ -113,9 +217,11 @@ class EntityGraphChannel:
|
|
|
113
217
|
"""
|
|
114
218
|
|
|
115
219
|
def __init__(
|
|
116
|
-
self,
|
|
220
|
+
self,
|
|
221
|
+
db: DatabaseManager,
|
|
117
222
|
entity_resolver: EntityResolver | None = None,
|
|
118
|
-
decay: float = 0.7,
|
|
223
|
+
decay: float = 0.7,
|
|
224
|
+
activation_threshold: float = 0.05,
|
|
119
225
|
max_hops: int = 4,
|
|
120
226
|
graph_metrics: dict[str, dict] | None = None,
|
|
121
227
|
cozo_backend: Any = None, # v3.4.5: optional CozoDB backend
|
|
@@ -174,18 +280,19 @@ class EntityGraphChannel:
|
|
|
174
280
|
# the graph without changing the count (e.g. store_edge MAX-merge), and a
|
|
175
281
|
# count-stable window would otherwise serve a stale adjacency map.
|
|
176
282
|
import time as _t_ec
|
|
283
|
+
|
|
177
284
|
_now_ec = _t_ec.monotonic()
|
|
178
285
|
_ttl = _adj_ttl_seconds()
|
|
179
286
|
# TTL=0 disables the time-based reload entirely (count-based correctness
|
|
180
287
|
# reload still applies); otherwise the cache is fresh within the TTL.
|
|
181
|
-
_fresh = _ttl <= 0.0 or (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
288
|
+
_fresh = _ttl <= 0.0 or ((_now_ec - getattr(self, "_adj_loaded_at", 0.0)) < _ttl)
|
|
289
|
+
if (
|
|
290
|
+
self._adj_scope_key == scope_key
|
|
291
|
+
and (self._adj or self._visible_fact_ids)
|
|
292
|
+
and self._adj_edge_count == current_count
|
|
293
|
+
and self._adj_fact_count == current_fact_count
|
|
294
|
+
and _fresh
|
|
295
|
+
):
|
|
189
296
|
return
|
|
190
297
|
adj: dict[str, list[tuple[str, float]]] = defaultdict(list)
|
|
191
298
|
try:
|
|
@@ -195,8 +302,7 @@ class EntityGraphChannel:
|
|
|
195
302
|
include_shared=include_shared,
|
|
196
303
|
)
|
|
197
304
|
rows = self._db.execute(
|
|
198
|
-
"SELECT source_id, target_id, weight FROM graph_edges "
|
|
199
|
-
f"WHERE {where}",
|
|
305
|
+
f"SELECT source_id, target_id, weight FROM graph_edges WHERE {where}",
|
|
200
306
|
(*params,),
|
|
201
307
|
)
|
|
202
308
|
except Exception:
|
|
@@ -234,8 +340,10 @@ class EntityGraphChannel:
|
|
|
234
340
|
|
|
235
341
|
logger.info(
|
|
236
342
|
"Loaded adjacency cache: %d nodes, %d edges, %d entity mappings for profile %s",
|
|
237
|
-
len(self._adj),
|
|
238
|
-
len(self.
|
|
343
|
+
len(self._adj),
|
|
344
|
+
sum(len(v) for v in self._adj.values()) // 2,
|
|
345
|
+
len(self._entity_to_facts),
|
|
346
|
+
profile_id,
|
|
239
347
|
)
|
|
240
348
|
|
|
241
349
|
def _get_edge_count(
|
|
@@ -272,7 +380,11 @@ class EntityGraphChannel:
|
|
|
272
380
|
"""Pre-load entity→fact and fact→entity maps into memory.
|
|
273
381
|
|
|
274
382
|
Eliminates per-entity and per-fact SQL in the spreading activation loop.
|
|
275
|
-
|
|
383
|
+
Fetch only the two columns this index consumes. Loading full AtomicFact
|
|
384
|
+
objects also deserializes every 768-d embedding and Fisher vector; on a
|
|
385
|
+
mature database that turned one new fact into a 5-second recall stall.
|
|
386
|
+
The scope predicate and configurable 50k safety ceiling are identical
|
|
387
|
+
to ``get_all_facts``; only heavyweight unused columns are omitted.
|
|
276
388
|
"""
|
|
277
389
|
# entity_id -> [fact_id, ...]
|
|
278
390
|
self._entity_to_facts: dict[str, list[str]] = defaultdict(list)
|
|
@@ -281,22 +393,41 @@ class EntityGraphChannel:
|
|
|
281
393
|
self._visible_fact_ids = set()
|
|
282
394
|
|
|
283
395
|
try:
|
|
284
|
-
|
|
396
|
+
where, params = _scope_where(
|
|
285
397
|
profile_id,
|
|
286
398
|
include_global=include_global,
|
|
287
399
|
include_shared=include_shared,
|
|
288
400
|
)
|
|
401
|
+
rows = self._db.execute(
|
|
402
|
+
"SELECT fact_id, canonical_entities_json "
|
|
403
|
+
f"FROM atomic_facts WHERE {where} "
|
|
404
|
+
"ORDER BY created_at DESC LIMIT ?",
|
|
405
|
+
(*params, _unbounded_facts_ceiling()),
|
|
406
|
+
)
|
|
289
407
|
except Exception:
|
|
290
|
-
|
|
291
|
-
for
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
408
|
+
rows = []
|
|
409
|
+
for row in rows:
|
|
410
|
+
data = dict(row)
|
|
411
|
+
fact_id = str(data.get("fact_id") or "")
|
|
412
|
+
if not fact_id:
|
|
413
|
+
continue
|
|
414
|
+
self._visible_fact_ids.add(fact_id)
|
|
415
|
+
try:
|
|
416
|
+
entity_ids = json.loads(
|
|
417
|
+
data.get("canonical_entities_json") or "[]",
|
|
418
|
+
)
|
|
419
|
+
except (TypeError, ValueError):
|
|
420
|
+
entity_ids = []
|
|
421
|
+
for entity_id in entity_ids:
|
|
422
|
+
if not isinstance(entity_id, str) or not entity_id:
|
|
423
|
+
continue
|
|
424
|
+
self._entity_to_facts[entity_id].append(fact_id)
|
|
425
|
+
self._fact_to_entities[fact_id].append(entity_id)
|
|
296
426
|
|
|
297
427
|
logger.info(
|
|
298
428
|
"Loaded entity maps: %d entities, %d facts with entities",
|
|
299
|
-
len(self._entity_to_facts),
|
|
429
|
+
len(self._entity_to_facts),
|
|
430
|
+
len(self._fact_to_entities),
|
|
300
431
|
)
|
|
301
432
|
|
|
302
433
|
def _load_graph_metrics(self, profile_id: str) -> None:
|
|
@@ -324,7 +455,8 @@ class EntityGraphChannel:
|
|
|
324
455
|
}
|
|
325
456
|
logger.info(
|
|
326
457
|
"Loaded graph metrics: %d facts for profile %s",
|
|
327
|
-
len(self._graph_metrics),
|
|
458
|
+
len(self._graph_metrics),
|
|
459
|
+
profile_id,
|
|
328
460
|
)
|
|
329
461
|
except Exception as exc:
|
|
330
462
|
logger.debug("Graph metrics load failed (graceful degradation): %s", exc)
|
|
@@ -412,7 +544,7 @@ class EntityGraphChannel:
|
|
|
412
544
|
# Spreading activation through graph edges (all in-memory O(1) lookups)
|
|
413
545
|
frontier = set(activation.keys())
|
|
414
546
|
for hop in range(1, self._max_hops):
|
|
415
|
-
hop_decay = self._decay
|
|
547
|
+
hop_decay = self._decay**hop
|
|
416
548
|
if hop_decay < self._threshold:
|
|
417
549
|
break
|
|
418
550
|
next_frontier: set[str] = set()
|
|
@@ -448,9 +580,8 @@ class EntityGraphChannel:
|
|
|
448
580
|
):
|
|
449
581
|
neighbor = edge.target_id if edge.source_id == fid else edge.source_id
|
|
450
582
|
propagated = activation[fid] * self._decay
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
and propagated > activation.get(neighbor, 0.0)
|
|
583
|
+
if propagated >= self._threshold and propagated > activation.get(
|
|
584
|
+
neighbor, 0.0
|
|
454
585
|
):
|
|
455
586
|
activation[neighbor] = propagated
|
|
456
587
|
next_frontier.add(neighbor)
|
|
@@ -490,6 +621,7 @@ class EntityGraphChannel:
|
|
|
490
621
|
# v3.4.1 P2: Community-aware boosting
|
|
491
622
|
if self._graph_metrics and use_cache:
|
|
492
623
|
from collections import Counter as _Counter
|
|
624
|
+
|
|
493
625
|
seed_communities: _Counter = _Counter()
|
|
494
626
|
for eid in canonical_ids:
|
|
495
627
|
for fid in self._entity_to_facts.get(eid, ()):
|
|
@@ -629,7 +761,7 @@ class EntityGraphChannel:
|
|
|
629
761
|
|
|
630
762
|
frontier = set(activation.keys())
|
|
631
763
|
for hop in range(1, self._max_hops):
|
|
632
|
-
hop_decay = self._decay
|
|
764
|
+
hop_decay = self._decay**hop
|
|
633
765
|
if hop_decay < self._threshold:
|
|
634
766
|
break
|
|
635
767
|
next_frontier: set[str] = set()
|
|
@@ -664,6 +796,7 @@ class EntityGraphChannel:
|
|
|
664
796
|
# Community-aware boosting (same as search)
|
|
665
797
|
if self._graph_metrics and use_cache:
|
|
666
798
|
from collections import Counter as _Counter
|
|
799
|
+
|
|
667
800
|
seed_communities: _Counter = _Counter()
|
|
668
801
|
for eid in canonical_ids:
|
|
669
802
|
for fid in self._entity_to_facts.get(eid, ()):
|
|
@@ -691,7 +824,9 @@ class EntityGraphChannel:
|
|
|
691
824
|
return scored
|
|
692
825
|
|
|
693
826
|
def _suppress_contradictions(
|
|
694
|
-
self,
|
|
827
|
+
self,
|
|
828
|
+
activation: dict[str, float],
|
|
829
|
+
profile_id: str,
|
|
695
830
|
) -> None:
|
|
696
831
|
"""P3: Penalize older fact in contradiction pairs, heavy-penalize superseded.
|
|
697
832
|
|
|
@@ -783,7 +918,10 @@ class EntityGraphChannel:
|
|
|
783
918
|
return ids
|
|
784
919
|
|
|
785
920
|
def _discover_entities(
|
|
786
|
-
self,
|
|
921
|
+
self,
|
|
922
|
+
fact_ids: set[str],
|
|
923
|
+
profile_id: str,
|
|
924
|
+
visited: set[str],
|
|
787
925
|
) -> list[str]:
|
|
788
926
|
"""Find new canonical entity IDs referenced by a set of facts."""
|
|
789
927
|
new: list[str] = []
|
|
@@ -797,7 +935,8 @@ class EntityGraphChannel:
|
|
|
797
935
|
)
|
|
798
936
|
for fid in allowed_fact_ids:
|
|
799
937
|
rows = self._db.execute(
|
|
800
|
-
"SELECT canonical_entities_json FROM atomic_facts WHERE fact_id = ?",
|
|
938
|
+
"SELECT canonical_entities_json FROM atomic_facts WHERE fact_id = ?",
|
|
939
|
+
(fid,),
|
|
801
940
|
)
|
|
802
941
|
if not rows:
|
|
803
942
|
continue
|
|
@@ -815,8 +954,11 @@ class EntityGraphChannel:
|
|
|
815
954
|
|
|
816
955
|
# v3.4.5: CozoDB-backed search (Sprint 2)
|
|
817
956
|
def _search_via_cozo(
|
|
818
|
-
self,
|
|
819
|
-
|
|
957
|
+
self,
|
|
958
|
+
query: str,
|
|
959
|
+
raw_entities: list[str],
|
|
960
|
+
profile_id: str,
|
|
961
|
+
top_k: int,
|
|
820
962
|
*,
|
|
821
963
|
include_global: bool = False,
|
|
822
964
|
include_shared: bool = False,
|
|
@@ -879,7 +1021,10 @@ class EntityGraphChannel:
|
|
|
879
1021
|
return self._search_without_cozo(query, profile_id, top_k)
|
|
880
1022
|
|
|
881
1023
|
def _search_without_cozo(
|
|
882
|
-
self,
|
|
1024
|
+
self,
|
|
1025
|
+
query: str,
|
|
1026
|
+
profile_id: str,
|
|
1027
|
+
top_k: int,
|
|
883
1028
|
) -> list[tuple[str, float]]:
|
|
884
1029
|
"""Run canonical SQLite entity recall without recursive projection use."""
|
|
885
1030
|
cozo, self._cozo = self._cozo, None
|