turnbook 0.9.44__py3-none-any.whl

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.
@@ -0,0 +1,7 @@
1
+ """Where AI apps registered before 0.8 start capture_cli: it now lives in the graph_mind
2
+ package. turnbook-install registers the new place; this file goes in 0.9."""
3
+ import sys
4
+
5
+ from graph_mind.capture_cli import main
6
+
7
+ sys.exit(main())
graph_mind/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Turnbook: local memory for AI assistants, shared over MCP.
2
+
3
+ Capture saves every conversation on this PC without calling a model; recall hands the model
4
+ the few thousand tokens of the past that answer its question.
5
+ """
6
+ from pathlib import Path
7
+ import os
8
+ import re
9
+
10
+ # Before numpy is imported anywhere: OpenBLAS reserves a buffer per core when it loads, 233 MB
11
+ # of memory on an 8-core PC, in every memory server an app starts. One thread is plenty for the
12
+ # one query-against-pieces product per question this package asks of it.
13
+ os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
14
+
15
+
16
+ def _version() -> str:
17
+ """The checkout's pyproject.toml, else the installed distribution's version: a hand-kept
18
+ constant once drifted to 0.5.0 at release 0.3.1."""
19
+ project = Path(__file__).resolve().parents[2] / "pyproject.toml"
20
+ text = project.read_text(encoding="utf-8") if project.exists() else ""
21
+ if 'name = "turnbook"' in text:
22
+ found = re.search(r'^version = "([^"]+)"', text, re.M)
23
+ if found:
24
+ return found.group(1)
25
+ from importlib.metadata import version
26
+ for name in ("turnbook", "graph-mind-memory"): # graph-mind-memory: up to 0.9.43
27
+ try:
28
+ return version(name)
29
+ except Exception:
30
+ pass
31
+ return "0+unknown"
32
+
33
+
34
+ __version__ = _version()
@@ -0,0 +1,478 @@
1
+ """Bounded sparse associative recall over Turnbook's evidence vault."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ import hashlib
7
+ import json
8
+ import math
9
+ import re
10
+ import sqlite3
11
+ import unicodedata
12
+
13
+ from graph_mind.packing import row_text
14
+ from graph_mind.paths import use_wal
15
+
16
+ INDEX_VERSION = "engram-associative-layer-v0.6"
17
+ STOP_TERMS = frozenset({
18
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in",
19
+ "is", "it", "of", "on", "or", "that", "the", "this", "to", "was", "were",
20
+ "with", "것", "그", "내", "나", "및", "수", "이", "저", "좀", "한", "할",
21
+ })
22
+
23
+
24
+ def _canonical(value):
25
+ return json.dumps(value, ensure_ascii=False, sort_keys=True,
26
+ separators=(",", ":"), allow_nan=False)
27
+
28
+
29
+ def _text(value, error):
30
+ if not isinstance(value, str) or not value.strip():
31
+ raise ValueError(error)
32
+ return value.strip()
33
+
34
+
35
+ def _terms(value):
36
+ normalized = unicodedata.normalize("NFKC", value).casefold()
37
+ found = re.findall(r"[^\W_]{2,}", normalized, re.UNICODE)
38
+ return list(dict.fromkeys(term for term in found if term not in STOP_TERMS))
39
+
40
+
41
+ def _unit(value, error):
42
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
43
+ raise ValueError(error)
44
+ value = float(value)
45
+ if not 0.0 <= value <= 1.0:
46
+ raise ValueError(error)
47
+ return value
48
+
49
+
50
+ class AssociativeMemoryIndex:
51
+ """Associative index; evidence remains owned by the source memory tables."""
52
+
53
+ def __init__(self, path):
54
+ self.path = Path(path)
55
+ self.path.parent.mkdir(parents=True, exist_ok=True)
56
+ # Claude, Codex and the capture service each open this file from their own process.
57
+ # WAL lets them read while one writes; the timeout makes a writer wait its turn
58
+ # instead of failing with "database is locked".
59
+ self.db = sqlite3.connect(str(self.path), timeout=30)
60
+ use_wal(self.db)
61
+ self.db.row_factory = sqlite3.Row
62
+ self.closed = False
63
+ self.db.execute("PRAGMA foreign_keys=ON")
64
+ self.db.executescript("""
65
+ CREATE TABLE IF NOT EXISTS associative_meta(
66
+ key TEXT PRIMARY KEY, value TEXT NOT NULL);
67
+ CREATE TABLE IF NOT EXISTS associative_engrams(
68
+ revision INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ engram_id TEXT NOT NULL UNIQUE,
70
+ source_kind TEXT NOT NULL,
71
+ source_id TEXT NOT NULL,
72
+ scope TEXT NOT NULL,
73
+ happened_at TEXT NOT NULL,
74
+ strength REAL NOT NULL,
75
+ access_count INTEGER NOT NULL DEFAULT 0,
76
+ last_activated_at TEXT,
77
+ signature_json TEXT NOT NULL,
78
+ fingerprint TEXT NOT NULL);
79
+ CREATE INDEX IF NOT EXISTS associative_scope_revision
80
+ ON associative_engrams(scope,revision DESC);
81
+ CREATE TABLE IF NOT EXISTS associative_terms(
82
+ term TEXT NOT NULL,
83
+ engram_id TEXT NOT NULL,
84
+ PRIMARY KEY(term,engram_id),
85
+ FOREIGN KEY(engram_id) REFERENCES associative_engrams(engram_id));
86
+ CREATE INDEX IF NOT EXISTS associative_term_engram
87
+ ON associative_terms(engram_id,term);
88
+ CREATE TABLE IF NOT EXISTS associative_edges(
89
+ source_engram_id TEXT NOT NULL,
90
+ target_engram_id TEXT NOT NULL,
91
+ relation TEXT NOT NULL,
92
+ weight REAL NOT NULL,
93
+ evidence_count INTEGER NOT NULL,
94
+ reinforced_at TEXT NOT NULL,
95
+ PRIMARY KEY(source_engram_id,target_engram_id,relation),
96
+ FOREIGN KEY(source_engram_id) REFERENCES associative_engrams(engram_id),
97
+ FOREIGN KEY(target_engram_id) REFERENCES associative_engrams(engram_id));
98
+ CREATE INDEX IF NOT EXISTS associative_edge_source
99
+ ON associative_edges(source_engram_id,weight DESC);
100
+ CREATE TABLE IF NOT EXISTS associative_pending_edges(
101
+ source_engram_id TEXT NOT NULL,
102
+ target_engram_id TEXT NOT NULL,
103
+ relation TEXT NOT NULL,
104
+ weight REAL NOT NULL,
105
+ reinforced_at TEXT NOT NULL,
106
+ PRIMARY KEY(source_engram_id,target_engram_id,relation));
107
+ CREATE VIRTUAL TABLE IF NOT EXISTS associative_engrams_fts USING fts5(
108
+ engram_id UNINDEXED, text, tokenize='unicode61');
109
+ """)
110
+ with self.db:
111
+ self.db.execute("INSERT OR IGNORE INTO associative_meta VALUES('version',?)",
112
+ (INDEX_VERSION,))
113
+ version = self.db.execute(
114
+ "SELECT value FROM associative_meta WHERE key='version'").fetchone()[0]
115
+ if version != INDEX_VERSION:
116
+ self.close()
117
+ raise ValueError("ASSOCIATIVE_INDEX_VERSION_CHANGED")
118
+
119
+ def __enter__(self):
120
+ return self
121
+
122
+ def __exit__(self, *args):
123
+ self.close()
124
+
125
+ def close(self):
126
+ if not self.closed:
127
+ self.closed = True
128
+ self.db.close()
129
+
130
+ def _link(self, source, target, relation, weight, when):
131
+ if source == target:
132
+ return
133
+ self.db.execute("""INSERT INTO associative_edges(
134
+ source_engram_id,target_engram_id,relation,weight,evidence_count,reinforced_at)
135
+ VALUES(?,?,?,?,1,?)
136
+ ON CONFLICT(source_engram_id,target_engram_id,relation) DO UPDATE SET
137
+ weight=MAX(weight,excluded.weight),
138
+ evidence_count=evidence_count+1,
139
+ reinforced_at=excluded.reinforced_at""",
140
+ (source, target, relation, weight, when))
141
+
142
+ def _resolve_pending(self):
143
+ rows = self.db.execute("""SELECT p.* FROM associative_pending_edges p
144
+ JOIN associative_engrams source ON source.engram_id=p.source_engram_id
145
+ JOIN associative_engrams target ON target.engram_id=p.target_engram_id""").fetchall()
146
+ for row in rows:
147
+ self._link(row["source_engram_id"], row["target_engram_id"],
148
+ row["relation"], row["weight"], row["reinforced_at"])
149
+ self._link(row["target_engram_id"], row["source_engram_id"],
150
+ row["relation"], row["weight"], row["reinforced_at"])
151
+ self.db.execute("""DELETE FROM associative_pending_edges
152
+ WHERE source_engram_id=? AND target_engram_id=? AND relation=?""",
153
+ (row["source_engram_id"], row["target_engram_id"], row["relation"]))
154
+
155
+ def add_engram(self, *, engram_id, source_kind, source_id, scope, happened_at,
156
+ text, cues=None, strength=0.5, related_engram_ids=None,
157
+ max_automatic_links=12):
158
+ normalized = {
159
+ "engram_id": _text(engram_id, "ENGRAM_ID_REQUIRED"),
160
+ "source_kind": _text(source_kind, "ENGRAM_SOURCE_KIND_REQUIRED").upper(),
161
+ "source_id": _text(source_id, "ENGRAM_SOURCE_ID_REQUIRED"),
162
+ "scope": _text(scope, "ENGRAM_SCOPE_REQUIRED"),
163
+ "happened_at": _text(happened_at, "ENGRAM_TIME_REQUIRED"),
164
+ "text": _text(text, "ENGRAM_TEXT_REQUIRED"),
165
+ "strength": _unit(strength, "INVALID_ENGRAM_STRENGTH"),
166
+ }
167
+ cue_values = cues or []
168
+ if not isinstance(cue_values, list) or not all(isinstance(x, str) for x in cue_values):
169
+ raise ValueError("INVALID_ENGRAM_CUES")
170
+ related = related_engram_ids or []
171
+ if not isinstance(related, list) or not all(isinstance(x, str) and x.strip()
172
+ for x in related):
173
+ raise ValueError("INVALID_RELATED_ENGRAM_IDS")
174
+ if type(max_automatic_links) is not int or not 0 <= max_automatic_links <= 50:
175
+ raise ValueError("INVALID_AUTOMATIC_LINK_LIMIT")
176
+ signature = _terms(" ".join([normalized["text"], *cue_values]))
177
+ if not signature:
178
+ raise ValueError("ENGRAM_HAS_NO_SEARCHABLE_SIGNATURE")
179
+ fingerprint_value = {**normalized, "signature": signature,
180
+ "related_engram_ids": sorted(set(related))}
181
+ fingerprint = hashlib.sha256(
182
+ _canonical(fingerprint_value).encode("utf-8")).hexdigest()
183
+ existing = self.db.execute(
184
+ "SELECT revision,fingerprint FROM associative_engrams WHERE engram_id=?",
185
+ (normalized["engram_id"],)).fetchone()
186
+ if existing:
187
+ if existing["fingerprint"] != fingerprint:
188
+ raise ValueError("ENGRAM_ID_COLLISION")
189
+ return {"engram_id": normalized["engram_id"],
190
+ "revision": existing["revision"], "disposition": "ALREADY_INDEXED"}
191
+
192
+ placeholders = ",".join("?" for _ in signature)
193
+ candidates = []
194
+ if max_automatic_links:
195
+ candidates = self.db.execute(f"""SELECT e.engram_id,COUNT(*) AS overlap
196
+ FROM associative_terms t JOIN associative_engrams e
197
+ ON e.engram_id=t.engram_id
198
+ WHERE t.term IN ({placeholders}) AND e.scope=?
199
+ GROUP BY e.engram_id ORDER BY overlap DESC,e.revision DESC LIMIT ?""",
200
+ [*signature, normalized["scope"], max_automatic_links]).fetchall()
201
+ previous = self.db.execute("""SELECT engram_id FROM associative_engrams
202
+ WHERE scope=? ORDER BY happened_at DESC,revision DESC LIMIT 1""",
203
+ (normalized["scope"],)).fetchone()
204
+ now = datetime.now().isoformat(timespec="microseconds")
205
+ with self.db:
206
+ cursor = self.db.execute("""INSERT INTO associative_engrams(
207
+ engram_id,source_kind,source_id,scope,happened_at,strength,signature_json,
208
+ fingerprint) VALUES(?,?,?,?,?,?,?,?)""", (
209
+ normalized["engram_id"], normalized["source_kind"],
210
+ normalized["source_id"], normalized["scope"], normalized["happened_at"],
211
+ normalized["strength"], _canonical(signature), fingerprint))
212
+ self.db.execute("INSERT INTO associative_engrams_fts(engram_id,text) VALUES(?,?)",
213
+ (normalized["engram_id"], normalized["text"]))
214
+ self.db.executemany("INSERT INTO associative_terms(term,engram_id) VALUES(?,?)",
215
+ [(term, normalized["engram_id"]) for term in signature])
216
+ for candidate in candidates:
217
+ weight = min(0.75, 0.2 + 0.1 * candidate["overlap"])
218
+ self._link(normalized["engram_id"], candidate["engram_id"],
219
+ "SHARED_CUE", weight, now)
220
+ self._link(candidate["engram_id"], normalized["engram_id"],
221
+ "SHARED_CUE", weight, now)
222
+ if previous:
223
+ self._link(normalized["engram_id"], previous["engram_id"],
224
+ "TEMPORAL_ADJACENT", 0.3, now)
225
+ self._link(previous["engram_id"], normalized["engram_id"],
226
+ "TEMPORAL_ADJACENT", 0.3, now)
227
+ for target in sorted(set(related)):
228
+ if self.db.execute("SELECT 1 FROM associative_engrams WHERE engram_id=?",
229
+ (target,)).fetchone():
230
+ self._link(normalized["engram_id"], target, "EXPLICIT", 1.0, now)
231
+ self._link(target, normalized["engram_id"], "EXPLICIT", 1.0, now)
232
+ else:
233
+ self.db.execute("""INSERT OR IGNORE INTO associative_pending_edges(
234
+ source_engram_id,target_engram_id,relation,weight,reinforced_at)
235
+ VALUES(?,?,?,?,?)""", (normalized["engram_id"], target,
236
+ "EXPLICIT", 1.0, now))
237
+ self._resolve_pending()
238
+ return {"engram_id": normalized["engram_id"], "revision": cursor.lastrowid,
239
+ "disposition": "INDEXED", "automatic_links": len(candidates),
240
+ "temporal_link": bool(previous)}
241
+
242
+ def _table_exists(self, table):
243
+ return bool(self.db.execute("SELECT 1 FROM sqlite_master WHERE name=?",
244
+ (table,)).fetchone())
245
+
246
+ def sync_sources(self, *, limit=None):
247
+ """Incrementally index curated memories, conversations and development events."""
248
+ if limit is not None and (type(limit) is not int or not 1 <= limit <= 100000):
249
+ raise ValueError("INVALID_ENGRAM_SYNC_LIMIT")
250
+ remaining = limit
251
+ counts = {"LOCAL_MEMORY": 0, "CONVERSATION": 0, "DEVELOPMENT_EVENT": 0}
252
+
253
+ def allowed():
254
+ return remaining is None or sum(counts.values()) < remaining
255
+
256
+ def left():
257
+ # in SQL, not after fetchall(): an imported history fetched every unindexed row at once
258
+ return -1 if remaining is None else max(0, remaining - sum(counts.values()))
259
+
260
+ if self._table_exists("local_brain_memories"):
261
+ rows = self.db.execute("""SELECT m.*,p.importance,p.related_memory_ids_json
262
+ FROM local_brain_memories m LEFT JOIN local_brain_memory_profiles p
263
+ ON p.memory_id=m.memory_id
264
+ WHERE NOT EXISTS(SELECT 1 FROM associative_engrams a
265
+ WHERE a.engram_id='memory:' || m.memory_id)
266
+ ORDER BY m.revision LIMIT ?""", (left(),)).fetchall()
267
+ for row in rows:
268
+ if not allowed():
269
+ break
270
+ related = json.loads(row["related_memory_ids_json"]) \
271
+ if row["related_memory_ids_json"] else []
272
+ related = [self._source_to_engram(item) for item in related]
273
+ result = self.add_engram(engram_id="memory:" + row["memory_id"],
274
+ source_kind="LOCAL_MEMORY", source_id=row["memory_id"],
275
+ scope=row["scope"], happened_at=row["effective_at"],
276
+ text=row["title"] + "\n" + row["content"],
277
+ cues=json.loads(row["tags_json"]) + json.loads(row["entities_json"])
278
+ + [row["memory_type"]],
279
+ strength=float(row["importance"] or 0.5),
280
+ related_engram_ids=related)
281
+ if result["disposition"] == "INDEXED":
282
+ counts["LOCAL_MEMORY"] += 1
283
+ if allowed() and self._table_exists("conversation_turns"):
284
+ rows = self.db.execute("""SELECT t.* FROM conversation_turns t
285
+ WHERE NOT EXISTS(SELECT 1 FROM associative_engrams a
286
+ WHERE a.engram_id='conversation:' || t.turn_id)
287
+ ORDER BY t.revision LIMIT ?""", (left(),)).fetchall()
288
+ for row in rows:
289
+ if not allowed():
290
+ break
291
+ result = self.add_engram(engram_id="conversation:" + row["turn_id"],
292
+ source_kind="CONVERSATION", source_id=row["turn_id"],
293
+ scope=row["scope"], happened_at=row["happened_at"],
294
+ text=row_text(row),
295
+ cues=[row["client"], row["role"], row["client_session_id"]],
296
+ strength=0.4)
297
+ if result["disposition"] == "INDEXED":
298
+ counts["CONVERSATION"] += 1
299
+ if allowed() and self._table_exists("development_events"):
300
+ rows = self.db.execute("""SELECT d.* FROM development_events d
301
+ WHERE NOT EXISTS(SELECT 1 FROM associative_engrams a
302
+ WHERE a.engram_id='development:' || d.event_id)
303
+ ORDER BY d.revision LIMIT ?""", (left(),)).fetchall()
304
+ for row in rows:
305
+ if not allowed():
306
+ break
307
+ result = self.add_engram(engram_id="development:" + row["event_id"],
308
+ source_kind="DEVELOPMENT_EVENT", source_id=row["event_id"],
309
+ scope=row["project_id"], happened_at=row["effective_at"],
310
+ text=row["event_type"] + " " + row["subject_id"] + " "
311
+ + row["payload_json"],
312
+ cues=[row["event_type"], row["subject_id"], row["session_id"]],
313
+ strength=0.65)
314
+ if result["disposition"] == "INDEXED":
315
+ counts["DEVELOPMENT_EVENT"] += 1
316
+ return {"status": "ENGRAMS_INDEXED" if sum(counts.values()) else "NO_NEW_ENGRAMS",
317
+ "indexed": counts, "indexed_total": sum(counts.values())}
318
+
319
+ @staticmethod
320
+ def _source_to_engram(source_id):
321
+ if source_id.startswith("conversation-turn:"):
322
+ return "conversation:" + source_id.removeprefix("conversation-turn:")
323
+ if source_id.startswith(("memory:", "conversation:", "development:")):
324
+ return source_id
325
+ return "memory:" + source_id
326
+
327
+ def activate(self, query, *, scopes=None, concept_cues=None, seed_limit=12, hops=2,
328
+ fanout=12, max_nodes=40, limit=8, minimum_concept_coverage=0.0,
329
+ coverage_mode="single"):
330
+ query = _text(query, "ASSOCIATIVE_QUERY_REQUIRED")
331
+ cue_list = concept_cues or []
332
+ if not isinstance(cue_list, list) or not all(isinstance(x, str) and x.strip()
333
+ for x in cue_list):
334
+ raise ValueError("INVALID_ASSOCIATIVE_CONCEPT_CUES")
335
+ scope_list = scopes or []
336
+ if not isinstance(scope_list, list) or not all(isinstance(x, str) and x.strip()
337
+ for x in scope_list):
338
+ raise ValueError("INVALID_ASSOCIATIVE_SCOPES")
339
+ bounds = ((seed_limit, 1, 50, "INVALID_SEED_LIMIT"),
340
+ (hops, 0, 4, "INVALID_ASSOCIATIVE_HOPS"),
341
+ (fanout, 1, 30, "INVALID_ASSOCIATIVE_FANOUT"),
342
+ (max_nodes, 1, 100, "INVALID_ACTIVE_NODE_LIMIT"),
343
+ (limit, 1, 30, "INVALID_ASSOCIATIVE_RESULT_LIMIT"))
344
+ for value, lower, upper, error in bounds:
345
+ if type(value) is not int or not lower <= value <= upper:
346
+ raise ValueError(error)
347
+ minimum_concept_coverage = _unit(
348
+ minimum_concept_coverage, "INVALID_MINIMUM_CONCEPT_COVERAGE")
349
+ if coverage_mode not in {"single", "union"}:
350
+ raise ValueError("INVALID_COVERAGE_MODE")
351
+ terms = _terms(" ".join([query, *cue_list]))
352
+ concept_terms = set(_terms(" ".join(cue_list)))
353
+ if not terms:
354
+ return {"status": "UNKNOWN", "reason": "NO_SEARCHABLE_QUERY_TERMS",
355
+ "results": [], "visited_nodes": 0, "traversed_edges": 0}
356
+ expression = " OR ".join('"' + term.replace('"', '""') + '"'
357
+ for term in terms)
358
+ where, parameters = ["associative_engrams_fts MATCH ?"], [expression]
359
+ if scope_list:
360
+ where.append("e.scope IN (" + ",".join("?" for _ in scope_list) + ")")
361
+ parameters.extend(scope_list)
362
+ parameters.append(seed_limit)
363
+ seeds = self.db.execute("""SELECT e.*,associative_engrams_fts.text,
364
+ bm25(associative_engrams_fts) AS rank
365
+ FROM associative_engrams_fts JOIN associative_engrams e
366
+ ON e.engram_id=associative_engrams_fts.engram_id WHERE """ + " AND ".join(where)
367
+ + " ORDER BY rank,e.revision DESC LIMIT ?", parameters).fetchall()
368
+ if not seeds:
369
+ return {"status": "UNKNOWN", "reason": "NO_ACTIVATION_SEED",
370
+ "results": [], "visited_nodes": 0, "traversed_edges": 0}
371
+ activations, parents, rows = {}, {}, {}
372
+ query_set = set(terms)
373
+ maximum_query_coverage = 0.0
374
+ maximum_concept_coverage = 0.0
375
+ covered_concept_terms = set() # union of matched cue terms across all seeds
376
+ for row in seeds:
377
+ signature = set(json.loads(row["signature_json"]))
378
+ overlap = len(query_set & signature)
379
+ maximum_query_coverage = max(
380
+ maximum_query_coverage, overlap / max(1, len(query_set)))
381
+ if concept_terms:
382
+ maximum_concept_coverage = max(maximum_concept_coverage,
383
+ len(concept_terms & signature) / len(concept_terms))
384
+ covered_concept_terms |= (concept_terms & signature)
385
+ lexical = overlap / math.sqrt(max(1, len(query_set) * len(signature)))
386
+ activation = min(1.0, 0.55 + lexical)
387
+ activations[row["engram_id"]] = activation
388
+ parents[row["engram_id"]] = None
389
+ rows[row["engram_id"]] = row
390
+ union_concept_coverage = (len(covered_concept_terms) / len(concept_terms)
391
+ if concept_terms else 0.0)
392
+ gate_coverage = (union_concept_coverage if coverage_mode == "union"
393
+ else maximum_concept_coverage)
394
+ if (concept_terms and gate_coverage < minimum_concept_coverage):
395
+ return {"status": "UNKNOWN", "reason": "INSUFFICIENT_CONCEPT_COVERAGE",
396
+ "index_version": INDEX_VERSION, "seed_count": len(seeds),
397
+ "concept_cues_used": cue_list, "coverage_mode": coverage_mode,
398
+ "maximum_query_coverage": round(maximum_query_coverage, 6),
399
+ "maximum_concept_coverage": round(maximum_concept_coverage, 6),
400
+ "union_concept_coverage": round(union_concept_coverage, 6),
401
+ "minimum_concept_coverage": minimum_concept_coverage,
402
+ "visited_nodes": len(seeds), "traversed_edges": 0,
403
+ "results": []}
404
+ frontier = set(activations)
405
+ traversed = 0
406
+ allowed_scopes = set(scope_list)
407
+ for hop in range(1, hops + 1):
408
+ contributions = {}
409
+ sources = sorted(frontier, key=lambda item: activations[item], reverse=True)
410
+ placeholders = ",".join("?" for _ in sources)
411
+ edges = self.db.execute(f"""SELECT edge.source_engram_id,
412
+ edge.target_engram_id,edge.relation,edge.weight,target.*
413
+ FROM associative_edges edge JOIN associative_engrams target
414
+ ON target.engram_id=edge.target_engram_id
415
+ WHERE edge.source_engram_id IN ({placeholders})
416
+ ORDER BY edge.source_engram_id,edge.weight DESC""", sources).fetchall()
417
+ per_source = {}
418
+ for edge in edges:
419
+ source = edge["source_engram_id"]
420
+ per_source[source] = per_source.get(source, 0) + 1
421
+ if per_source[source] > fanout:
422
+ continue
423
+ if allowed_scopes and edge["scope"] not in allowed_scopes:
424
+ continue
425
+ traversed += 1
426
+ contribution = activations[source] * edge["weight"] * (0.65 ** hop)
427
+ old = contributions.get(edge["target_engram_id"], 0.0)
428
+ combined = 1.0 - (1.0 - old) * (1.0 - contribution)
429
+ contributions[edge["target_engram_id"]] = combined
430
+ rows[edge["target_engram_id"]] = edge
431
+ if (edge["target_engram_id"] not in parents
432
+ or contribution > activations.get(edge["target_engram_id"], 0.0)):
433
+ parents[edge["target_engram_id"]] = {
434
+ "from": source, "relation": edge["relation"],
435
+ "weight": edge["weight"]}
436
+ for target, value in contributions.items():
437
+ activations[target] = max(activations.get(target, 0.0), value)
438
+ top = sorted(activations, key=activations.get, reverse=True)[:max_nodes]
439
+ activations = {key: activations[key] for key in top}
440
+ frontier = set(contributions) & set(top)
441
+ if not frontier:
442
+ break
443
+ ranked = sorted(activations,
444
+ key=lambda key: activations[key] * (0.5 + rows[key]["strength"]),
445
+ reverse=True)[:limit]
446
+ text_placeholders = ",".join("?" for _ in ranked)
447
+ text_rows = self.db.execute(f"""SELECT engram_id,text
448
+ FROM associative_engrams_fts WHERE engram_id IN ({text_placeholders})""",
449
+ ranked).fetchall()
450
+ excerpts = {row["engram_id"]: row["text"][:500] for row in text_rows}
451
+ results = []
452
+ for engram_id in ranked:
453
+ row = rows[engram_id]
454
+ path, cursor, seen = [], engram_id, set()
455
+ while parents.get(cursor) and cursor not in seen:
456
+ seen.add(cursor)
457
+ step = parents[cursor]
458
+ path.append({"from": step["from"], "to": cursor,
459
+ "relation": step["relation"], "weight": step["weight"]})
460
+ cursor = step["from"]
461
+ path.reverse()
462
+ results.append({"engram_id": engram_id, "source_kind": row["source_kind"],
463
+ "source_id": row["source_id"], "scope": row["scope"],
464
+ "happened_at": row["happened_at"],
465
+ "activation": round(activations[engram_id], 6),
466
+ "strength": row["strength"],
467
+ "text_excerpt": excerpts.get(engram_id, ""),
468
+ "activation_path": path})
469
+ return {"status": "KNOWN", "reason": "ASSOCIATIVE_ACTIVATION_COMPLETED",
470
+ "index_version": INDEX_VERSION, "seed_count": len(seeds),
471
+ "concept_cues_used": cue_list, "coverage_mode": coverage_mode,
472
+ "maximum_query_coverage": round(maximum_query_coverage, 6),
473
+ "maximum_concept_coverage": round(maximum_concept_coverage, 6),
474
+ "union_concept_coverage": round(union_concept_coverage, 6),
475
+ "minimum_concept_coverage": minimum_concept_coverage,
476
+ "visited_nodes": len(activations), "traversed_edges": traversed,
477
+ "bounds": {"hops": hops, "fanout": fanout, "max_nodes": max_nodes},
478
+ "results": results}