graph-engine-memory 0.6.1__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.
Files changed (53) hide show
  1. graph_engine/__init__.py +1 -0
  2. graph_engine/__main__.py +1275 -0
  3. graph_engine/access.py +202 -0
  4. graph_engine/agent_memory.py +103 -0
  5. graph_engine/brain.py +731 -0
  6. graph_engine/brain_engine.py +559 -0
  7. graph_engine/communities.py +503 -0
  8. graph_engine/consolidation.py +191 -0
  9. graph_engine/context.py +203 -0
  10. graph_engine/decay.py +65 -0
  11. graph_engine/demo.py +165 -0
  12. graph_engine/dream.py +408 -0
  13. graph_engine/embedder.py +84 -0
  14. graph_engine/engine.py +58 -0
  15. graph_engine/entities.py +376 -0
  16. graph_engine/evals.py +1206 -0
  17. graph_engine/feedback.py +155 -0
  18. graph_engine/gaps.py +209 -0
  19. graph_engine/graph.py +133 -0
  20. graph_engine/hygiene.py +213 -0
  21. graph_engine/inference.py +251 -0
  22. graph_engine/intent.py +207 -0
  23. graph_engine/mcp/__init__.py +1 -0
  24. graph_engine/mcp/agent/instructions.md +34 -0
  25. graph_engine/mcp/format.py +66 -0
  26. graph_engine/mcp/server.py +407 -0
  27. graph_engine/merge.py +220 -0
  28. graph_engine/model.py +56 -0
  29. graph_engine/namespaces.py +74 -0
  30. graph_engine/recall.py +161 -0
  31. graph_engine/report.py +401 -0
  32. graph_engine/reranker.py +86 -0
  33. graph_engine/retrieval.py +296 -0
  34. graph_engine/review.py +95 -0
  35. graph_engine/runtime.py +83 -0
  36. graph_engine/server.py +239 -0
  37. graph_engine/similarity.py +42 -0
  38. graph_engine/store.py +63 -0
  39. graph_engine/suggester.py +63 -0
  40. graph_engine/temporal.py +216 -0
  41. graph_engine/tiers.py +135 -0
  42. graph_engine/web/app.js +578 -0
  43. graph_engine/web/index.html +169 -0
  44. graph_engine/web/report.html +39 -0
  45. graph_engine/web/report.js +50 -0
  46. graph_engine/web/review.html +104 -0
  47. graph_engine/web/review.js +211 -0
  48. graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
  49. graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
  50. graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
  51. graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
  52. graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
  53. graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,503 @@
1
+ """Topology-based community detection and structural gap analysis.
2
+
3
+ A MACRO view over the brain's graph: which dense regions exist, which
4
+ nodes route between them (god nodes), and which pairs of well-developed
5
+ communities are suspiciously disconnected (structural gaps). Read-only —
6
+ nothing here writes to the brain, and no derived artifact is persisted
7
+ (the partition changes on every ingest; persisting it would be pure
8
+ git churn in a repo where every commit is knowledge).
9
+
10
+ Algorithms are hand-rolled stdlib on purpose:
11
+ - Label propagation (LPA) instead of Louvain/Leiden: deterministic with
12
+ a fixed iteration order and smallest-label tie-break, ~40 lines, and
13
+ measured Q=0.6685 on the live graph — a hand-rolled modularity
14
+ optimizer would buy little for ~200 lines of untested numerics.
15
+ - Brandes betweenness, exact by default (measured 11.3 s at ~1.8k
16
+ nodes) with a deterministic pivot sample for brains an order of
17
+ magnitude larger.
18
+ - Configuration-model null for gap ranking: expected cross edges
19
+ comdeg_a * comdeg_b / (2m); deficit = (expected - observed)/expected.
20
+
21
+ networkx is deliberately NOT used: it is not a declared dependency and
22
+ absent in CI (it only appears locally as a transitive dep of torch).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ from collections import Counter, deque
29
+ from dataclasses import dataclass, field
30
+
31
+ from .brain import Brain
32
+ from .gaps import DEFAULT_TAXONOMY, _node_text, normalize
33
+
34
+ # LPA converges fast on small-world graphs (measured: 17 iterations on
35
+ # the live graph); the cap only guards pathological cycles.
36
+ MAX_LPA_ITER = 50
37
+ DEFAULT_MIN_COMMUNITY = 15
38
+ DEFAULT_TOP = 10
39
+
40
+
41
+ @dataclass
42
+ class Community:
43
+ id: int
44
+ members: list[str] = field(default_factory=list) # sorted node ids
45
+ size: int = 0
46
+ internal_edges: int = 0
47
+ degree_sum: int = 0
48
+ label: str = "UNCLASSIFIED"
49
+ sample: str = ""
50
+
51
+
52
+ @dataclass
53
+ class GodNode:
54
+ id: str
55
+ degree: int
56
+ betweenness: float
57
+ text: str
58
+
59
+
60
+ @dataclass
61
+ class StructuralGap:
62
+ a: int
63
+ b: int
64
+ a_label: str = ""
65
+ b_label: str = ""
66
+ a_size: int = 0
67
+ b_size: int = 0
68
+ observed_edges: int = 0
69
+ expected_edges: float = 0.0
70
+ deficit: float = 0.0
71
+ bridge_nodes: list[str] = field(default_factory=list)
72
+ a_samples: list[str] = field(default_factory=list)
73
+ b_samples: list[str] = field(default_factory=list)
74
+
75
+
76
+ @dataclass
77
+ class CommunityReport:
78
+ nodes: int
79
+ edges: int
80
+ modularity: float
81
+ communities: list[Community]
82
+ god_nodes: list[GodNode]
83
+ gaps: list[StructuralGap]
84
+ isolated: list[str]
85
+ betweenness_sample: int | None # None = exact
86
+ unclassified: int = 0 # share of nodes without a taxonomy label
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Graph construction
91
+ # ---------------------------------------------------------------------------
92
+
93
+ def build_graph(brain: Brain, include_pending: bool = True
94
+ ) -> tuple[list[str], dict[str, set[str]], Counter]:
95
+ """Undirected adjacency over live nodes and live edges.
96
+
97
+ Excludes: tombstoned nodes (they are edge-less append-only history —
98
+ including them manufactures phantom singleton communities, the exact
99
+ bug that once made the autonomous cycle try to re-link a dead node),
100
+ rejected edges (read_edges default), invalidated or out-of-window edges
101
+ (hygiene precedent: they no longer count toward connectivity),
102
+ dangling endpoints, and self-loops. A->B and B->A deduplicate into
103
+ ONE undirected edge (link() is direction-sensitive, so both can
104
+ coexist in storage). Pending edges are included by default: on a
105
+ fresh brain every similarity edge is pending, and topology over zero
106
+ edges is useless — the --no-pending variant exists for strictness.
107
+ """
108
+ nodes = [n for n in brain.read_nodes() if n.status != "tombstone"]
109
+ id_set = {n.id for n in nodes}
110
+ adj: dict[str, set[str]] = {nid: set() for nid in id_set}
111
+ seen: set[frozenset[str]] = set()
112
+ deg: Counter = Counter()
113
+ m = 0
114
+ for e in brain.read_edges():
115
+ if not e.is_current or e.rejected:
116
+ continue
117
+ if not include_pending and e.pending:
118
+ continue
119
+ if e.source == e.target:
120
+ continue
121
+ if e.source not in id_set or e.target not in id_set:
122
+ continue
123
+ key = frozenset((e.source, e.target))
124
+ if key in seen:
125
+ continue
126
+ seen.add(key)
127
+ adj[e.source].add(e.target)
128
+ adj[e.target].add(e.source)
129
+ deg[e.source] += 1
130
+ deg[e.target] += 1
131
+ m += 1
132
+ ordered = sorted(id_set) # deterministic iteration everywhere
133
+ return ordered, adj, deg
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Label propagation (deterministic)
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def label_propagation(nodes: list[str], adj: dict[str, set[str]],
141
+ max_iter: int = MAX_LPA_ITER) -> dict[str, int]:
142
+ """Deterministic LPA returning CANONICAL labels 0..k-1.
143
+
144
+ Iterates sorted(nodes); each node adopts the neighbour label with the
145
+ highest count, ties broken by the SMALLEST label id; converges on a
146
+ fixpoint or max_iter. Raw labels never escape this function: they are
147
+ canonicalized once at the boundary (communities sorted by size desc,
148
+ then smallest member id) — mixing raw and canonical indices is a real
149
+ ZeroDivisionError trap (it happened in the measurement script that
150
+ produced report #3).
151
+ """
152
+ if not nodes:
153
+ return {}
154
+ labels: dict[str, int] = {n: i for i, n in enumerate(sorted(nodes))}
155
+ for _ in range(max_iter):
156
+ changed = False
157
+ for node in sorted(nodes):
158
+ counts: Counter = Counter()
159
+ for nb in adj.get(node, ()): # deterministic: sets are only summed
160
+ counts[labels[nb]] += 1
161
+ if not counts:
162
+ continue
163
+ best = max(counts.values())
164
+ # smallest label id wins ties
165
+ target = min(l for l, c in counts.items() if c == best)
166
+ if labels[node] != target:
167
+ labels[node] = target
168
+ changed = True
169
+ if not changed:
170
+ break
171
+ # Canonicalize: group by raw label, order communities by size desc then
172
+ # smallest member id, renumber 0..k-1.
173
+ groups: dict[int, list[str]] = {}
174
+ for node, lab in labels.items():
175
+ groups.setdefault(lab, []).append(node)
176
+ ordered = sorted(groups.values(),
177
+ key=lambda ms: (-len(ms), min(ms)))
178
+ canon: dict[str, int] = {}
179
+ for new_id, members in enumerate(ordered):
180
+ for node in members:
181
+ canon[node] = new_id
182
+ return canon
183
+
184
+
185
+ def modularity(nodes: list[str], adj: dict[str, set[str]],
186
+ labels: dict[str, int]) -> float:
187
+ """Newman modularity of the partition over the undirected graph."""
188
+ m = sum(len(nbs) for nbs in adj.values()) / 2.0
189
+ if m == 0:
190
+ return 0.0
191
+ degrees = {n: len(adj.get(n, ())) for n in nodes}
192
+ same: Counter = Counter()
193
+ for n in nodes:
194
+ for nb in adj.get(n, ()):
195
+ if labels[nb] == labels[n]:
196
+ same[labels[n]] += 1
197
+ q = 0.0
198
+ for cid in set(labels.values()):
199
+ members = [n for n in nodes if labels[n] == cid]
200
+ intra = same[cid] / 2.0
201
+ deg_sum = sum(degrees[n] for n in members)
202
+ q += intra / m - (deg_sum / (2.0 * m)) ** 2
203
+ return q
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Betweenness (Brandes)
208
+ # ---------------------------------------------------------------------------
209
+
210
+ def betweenness(nodes: list[str], adj: dict[str, set[str]],
211
+ sample: int | None = None) -> dict[str, float]:
212
+ """Brandes betweenness, normalized to [0, 1] (undirected convention:
213
+ divide by (n-1)(n-2)/2). sample=None = exact, sample=N = N deterministic
214
+ pivots (every ceil(len/N)-th id in sorted order) — a documented
215
+ approximation, never a random one."""
216
+ if not nodes:
217
+ return {}
218
+ ordered = sorted(nodes)
219
+ if sample is not None and 0 < sample < len(ordered):
220
+ step = math.ceil(len(ordered) / sample)
221
+ sources = ordered[::step]
222
+ else:
223
+ sources = ordered
224
+ bc: dict[str, float] = {n: 0.0 for n in ordered}
225
+ for s in sources:
226
+ # Brandes single-source pass
227
+ stack: list[str] = []
228
+ preds: dict[str, list[str]] = {n: [] for n in ordered}
229
+ sigma: dict[str, float] = {n: 0.0 for n in ordered}
230
+ sigma[s] = 1.0
231
+ dist: dict[str, int] = {n: -1 for n in ordered}
232
+ dist[s] = 0
233
+ queue: deque[str] = deque([s])
234
+ while queue:
235
+ v = queue.popleft()
236
+ stack.append(v)
237
+ for w in sorted(adj.get(v, ())): # sorted for determinism
238
+ if dist[w] < 0:
239
+ dist[w] = dist[v] + 1
240
+ queue.append(w)
241
+ if dist[w] == dist[v] + 1:
242
+ sigma[w] += sigma[v]
243
+ preds[w].append(v)
244
+ delta: dict[str, float] = {n: 0.0 for n in ordered}
245
+ while stack:
246
+ w = stack.pop()
247
+ for v in preds[w]:
248
+ delta[v] += sigma[v] / sigma[w] * (1.0 + delta[w])
249
+ if w != s:
250
+ bc[w] += delta[w]
251
+ if sample is not None and len(sources) < len(ordered):
252
+ scale = len(ordered) / len(sources)
253
+ for n in bc:
254
+ bc[n] *= scale
255
+ # Undirected: running BFS from every source counts each unordered pair
256
+ # twice — halve, then normalize to [0, 1] by (n-1)(n-2)/2.
257
+ for n in bc:
258
+ bc[n] /= 2.0
259
+ norm = (len(ordered) - 1) * (len(ordered) - 2) / 2.0
260
+ if norm > 0:
261
+ for n in bc:
262
+ bc[n] /= norm
263
+ return bc
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Analysis
268
+ # ---------------------------------------------------------------------------
269
+
270
+ def _classify(label_counts: Counter) -> str:
271
+ if not label_counts:
272
+ return "UNCLASSIFIED"
273
+ return label_counts.most_common(1)[0][0]
274
+
275
+
276
+ def analyze_communities(brain: Brain, min_size: int = DEFAULT_MIN_COMMUNITY,
277
+ top: int = DEFAULT_TOP, betweenness_sample: int | None = None,
278
+ include_pending: bool = True, taxonomy: dict | None = None,
279
+ with_members: bool = False,
280
+ exclude_ids: set[str] | None = None) -> CommunityReport:
281
+ """Full topology report: partition, god nodes, structural gaps.
282
+
283
+ `exclude_ids` removes nodes from the partition entirely. A derived node (a
284
+ consolidator summary) must not feed back into the structure it summarizes:
285
+ otherwise a second dream pass partitions a different graph and cannot be
286
+ idempotent — the summary nodes join their own communities and shift them.
287
+ """
288
+ nodes, adj, deg = build_graph(brain, include_pending=include_pending)
289
+ if exclude_ids:
290
+ keep = [n for n in nodes if n not in exclude_ids]
291
+ keep_set = set(keep)
292
+ adj = {n: {m for m in adj[n] if m in keep_set} for n in keep}
293
+ deg = Counter({n: d for n, d in deg.items() if n in keep_set})
294
+ nodes = keep
295
+ id2text = {n.id: n.text for n in brain.read_nodes() if n.status != "tombstone"}
296
+ labels = label_propagation(nodes, adj)
297
+ q = modularity(nodes, adj, labels)
298
+ tax = taxonomy or DEFAULT_TAXONOMY
299
+
300
+ # Pre-compute per-node taxonomy hits once (normalize + _node_text reused
301
+ # from gaps.py so both views classify identically).
302
+ norm_tax = {area: [normalize(k) for k in kws] for area, kws in tax.items()}
303
+ node_area: dict[str, Counter] = {}
304
+ for nid in nodes:
305
+ text = normalize(_node_text_raw(id2text.get(nid, "")))
306
+ hits: Counter = Counter()
307
+ for area, kws in norm_tax.items():
308
+ if any(kw in text for kw in kws):
309
+ hits[area] += 1
310
+ node_area[nid] = hits
311
+
312
+ communities: list[Community] = []
313
+ groups: dict[int, list[str]] = {}
314
+ for nid in nodes:
315
+ groups.setdefault(labels[nid], []).append(nid)
316
+ for cid, members in groups.items():
317
+ members = sorted(members)
318
+ internal = sum(1 for n in members for nb in adj[n] if labels[nb] == cid) // 2
319
+ label_counts: Counter = Counter()
320
+ for n in members:
321
+ label_counts.update(node_area[n].keys())
322
+ # Highest-degree member's text as the sample (degree is what the
323
+ # partition actually routed on; text of a random member would not).
324
+ hub = max(members, key=lambda n: (deg[n], n))
325
+ communities.append(Community(
326
+ id=cid, members=members if with_members else [],
327
+ size=len(members), internal_edges=internal,
328
+ degree_sum=sum(deg[n] for n in members),
329
+ label=_classify(label_counts),
330
+ sample=_short_text(id2text.get(hub, "")),
331
+ ))
332
+
333
+ # God nodes: degree AND betweenness (their rankings diverge substantially —
334
+ # mean |delta rank| 247 of 1761 on the live graph — so degree alone would
335
+ # misname the hubs the brain actually routes through).
336
+ bc = betweenness(nodes, adj, sample=betweenness_sample)
337
+ top_bc = sorted(nodes, key=lambda n: (-bc[n], n))[:5]
338
+ god_nodes = [GodNode(id=n, degree=deg[n], betweenness=round(bc[n], 1),
339
+ text=_short_text(id2text.get(n, ""))) for n in top_bc]
340
+
341
+ # Structural gaps vs the configuration-model null.
342
+ gaps = _structural_gaps(communities, labels, adj, deg, id2text, min_size, top)
343
+
344
+ isolated = sorted(n for n in nodes if deg[n] == 0)
345
+ unclassified = sum(1 for n in nodes if _classify(node_area[n]) == "UNCLASSIFIED")
346
+ return CommunityReport(
347
+ nodes=len(nodes), edges=sum(deg.values()) // 2, modularity=q,
348
+ communities=communities, god_nodes=god_nodes, gaps=gaps,
349
+ isolated=isolated, betweenness_sample=betweenness_sample,
350
+ unclassified=unclassified,
351
+ )
352
+
353
+
354
+ def _structural_gaps(communities: list[Community], labels: dict[str, int],
355
+ adj: dict[str, set[str]], deg: Counter,
356
+ id2text: dict[str, str], min_size: int,
357
+ top: int) -> list[StructuralGap]:
358
+ """Deficit-ranked missing links between well-developed communities.
359
+
360
+ deficit = (expected - observed) / expected with
361
+ expected = comdeg_a * comdeg_b / (2m) (configuration-model null).
362
+ Tie-break by expected desc. Pairs with expected <= 0 are skipped
363
+ (division-by-zero trap hit by the report's own measurement script).
364
+ """
365
+ big = [c for c in communities if c.size >= min_size]
366
+ m = sum(deg.values()) / 2.0
367
+ if m == 0 or len(big) < 2:
368
+ return []
369
+ # Cross-edge counts per community pair + bridge nodes (touch both sides).
370
+ cross: dict[tuple[int, int], int] = {}
371
+ bridges: dict[int, set[str]] = {c.id: set() for c in big}
372
+ member_of = labels
373
+ big_ids = {c.id for c in big}
374
+ for n in member_of:
375
+ if member_of[n] not in big_ids:
376
+ continue
377
+ for nb in adj.get(n, ()):
378
+ other = member_of.get(nb)
379
+ if other is None or other == member_of[n]:
380
+ continue
381
+ if other not in big_ids:
382
+ continue
383
+ key = (min(member_of[n], other), max(member_of[n], other))
384
+ cross[key] = cross.get(key, 0) + 1
385
+ bridges[member_of[n]].add(n)
386
+ bridges[other].add(nb)
387
+ # observed pairs were counted twice (once per endpoint side)
388
+ cross = {k: v // 2 for k, v in cross.items()}
389
+
390
+ comdeg = {c.id: c.degree_sum for c in big}
391
+ by_id = {c.id: c for c in big}
392
+ candidates = []
393
+ for i, a in enumerate(big):
394
+ for b in big[i + 1:]:
395
+ expected = comdeg[a.id] * comdeg[b.id] / (2.0 * m)
396
+ if expected <= 0:
397
+ continue
398
+ observed = cross.get((a.id, b.id), 0)
399
+ deficit = (expected - observed) / expected
400
+ candidates.append((deficit, expected, a, b, observed))
401
+ candidates.sort(key=lambda t: (-t[0], -t[1]))
402
+ result = []
403
+ for deficit, expected, a, b, observed in candidates[:max(top, 0)]:
404
+ result.append(StructuralGap(
405
+ a=a.id, b=b.id, a_label=a.label, b_label=b.label,
406
+ a_size=a.size, b_size=b.size, observed_edges=observed,
407
+ expected_edges=round(expected, 1), deficit=round(deficit, 2),
408
+ bridge_nodes=sorted(bridges[a.id] & bridges[b.id]),
409
+ a_samples=_samples(a.id, member_of, id2text, deg),
410
+ b_samples=_samples(b.id, member_of, id2text, deg),
411
+ ))
412
+ return result
413
+
414
+
415
+ def _samples(cid: int, labels: dict[str, int], id2text: dict[str, str],
416
+ deg: Counter, k: int = 2) -> list[str]:
417
+ """k highest-degree members' short texts as evidence for a gap side."""
418
+ members = sorted((n for n, c in labels.items() if c == cid),
419
+ key=lambda n: (-deg[n], n))
420
+ return [_short_text(id2text.get(n, "")) for n in members[:k]]
421
+
422
+
423
+ def _short_text(text: str, limit: int = 72) -> str:
424
+ text = " ".join((text or "").split())
425
+ return text[:limit] + ("…" if len(text) > limit else "")
426
+
427
+
428
+ def _node_text_raw(text: str) -> str:
429
+ """gaps._node_text operates on Node objects; here we hold raw texts."""
430
+ import re
431
+ m = re.match(r"^---\n.*?\n---\n\n?(.*)$", text or "", re.DOTALL)
432
+ if m:
433
+ return m.group(1).strip()
434
+ return (text or "").strip()
435
+
436
+
437
+ def structural_gaps(brain: Brain, min_size: int = DEFAULT_MIN_COMMUNITY,
438
+ top: int = 5) -> list[dict]:
439
+ """Provider seam for the BRAIN_REPORT (report #7's contract).
440
+
441
+ Returns rows of {"kind", "label", "node_ids", "score", "hint"} — the
442
+ report renders them and never computes communities itself. Reuses
443
+ analyze_communities (one pass, no duplicate logic).
444
+ """
445
+ rep = analyze_communities(brain, min_size=min_size, top=top,
446
+ betweenness_sample=200)
447
+ id2text = {n.id: n.text for n in brain.read_nodes() if n.status != "tombstone"}
448
+ rows: list[dict] = []
449
+ big = [c for c in rep.communities if c.size >= min_size][:top]
450
+ for c in big:
451
+ rows.append({"kind": "community", "label": c.label,
452
+ "node_ids": c.members or [], "score": float(c.size),
453
+ "hint": c.sample})
454
+ # NOTE: no 'god' rows — the report's own Hubs section already renders
455
+ # hub nodes from degree; emitting them here double-renders and, worse,
456
+ # makes the provider non-empty on tiny brains where the rows are noise.
457
+ for gp in rep.gaps:
458
+ hint = (f"C{gp.a} ({gp.a_label}, n={gp.a_size}) <-> "
459
+ f"C{gp.b} ({gp.b_label}, n={gp.b_size}): "
460
+ f"{gp.observed_edges} of {gp.expected_edges} expected edges; "
461
+ f"evidence: {' / '.join((gp.a_samples + gp.b_samples)[:2])}")
462
+ rows.append({"kind": "hole", "label": f"{gp.a_label} <-> {gp.b_label}",
463
+ "node_ids": [], "score": float(gp.deficit), "hint": hint})
464
+ return rows
465
+
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # Rendering
469
+ # ---------------------------------------------------------------------------
470
+
471
+ def render_communities(report: CommunityReport) -> str:
472
+ lines: list[str] = []
473
+ uncl = f", {report.unclassified} unclassified" if report.unclassified else ""
474
+ lines.append(f"Communities ({report.nodes} nodes / {report.edges} edges · "
475
+ f"{len(report.communities)} communities · Q={report.modularity:.3f}{uncl}):")
476
+ lines.append(f"{'#':>4}{'Nodes':>7}{'Degree':>8} Area (dominant)".ljust(0)
477
+ + f"{'':2}Sample")
478
+ for c in report.communities:
479
+ lines.append(f"{c.id:>4}{c.size:>7}{c.degree_sum:>8} {c.label:<38} {c.sample}")
480
+ if report.god_nodes:
481
+ lines.append("")
482
+ lines.append("God nodes (degree / betweenness):")
483
+ for g in report.god_nodes:
484
+ lines.append(f"{g.degree:>4} / {g.betweenness:>9.1f} {g.id} {g.text}")
485
+ if report.gaps:
486
+ lines.append("")
487
+ lines.append(f"Structural gaps (top {len(report.gaps)} · communities >= "
488
+ f"{DEFAULT_MIN_COMMUNITY} nodes, deficit vs. configuration null):")
489
+ for gp in report.gaps:
490
+ # C-ids disambiguate: several communities can share one dominant
491
+ # label, so a label-only line can render the same pair twice.
492
+ lines.append(f"[{gp.deficit:.2f}] C{gp.a} ({gp.a_label}, n={gp.a_size})"
493
+ f" <-> C{gp.b} ({gp.b_label}, n={gp.b_size}) "
494
+ f"{gp.observed_edges} edges, {gp.expected_edges} expected")
495
+ for side, cid, samples in (("a", gp.a, gp.a_samples),
496
+ ("b", gp.b, gp.b_samples)):
497
+ if samples:
498
+ lines.append(f" C{cid}: {' / '.join(samples)}")
499
+ if report.isolated:
500
+ lines.append("")
501
+ lines.append(f"Isolated nodes ({len(report.isolated)}): "
502
+ + ", ".join(report.isolated[:10]))
503
+ return "\n".join(lines)
@@ -0,0 +1,191 @@
1
+ """Episodic → semantic extraction, separate from dual-buffer deduplication.
2
+
3
+ Selection is deterministic: recall count, observation age, then eligible pool
4
+ size. Original events stay intact; provenance links make repeated passes safe.
5
+ The default extractor preserves the evidence verbatim, just like `ig extract`.
6
+ An optional extractor can refine it or return None to decline extraction.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ import json
13
+ import math
14
+ from dataclasses import asdict, dataclass
15
+ from typing import Callable
16
+
17
+ from .brain import Brain, Edge, Node, _atomic_write
18
+
19
+ CURSOR_FILE = "consolidation-cursor.json"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ConsolidationConfig:
24
+ threshold: int = 1
25
+ min_age_days: float = 1.0
26
+ min_count: int = 1
27
+ limit: int = 50
28
+
29
+ def __post_init__(self) -> None:
30
+ for key, minimum in (("threshold", 0), ("min_count", 1), ("limit", 1)):
31
+ value = getattr(self, key)
32
+ if not isinstance(value, int) or value < minimum:
33
+ raise ValueError(f"{key} must be an integer >= {minimum}")
34
+ if not math.isfinite(self.min_age_days) or self.min_age_days < 0:
35
+ raise ValueError("min_age_days must be finite and >= 0")
36
+
37
+
38
+ def _key(text: str) -> str:
39
+ return " ".join(text.casefold().split())
40
+
41
+
42
+ def _timestamp(node: Node) -> datetime.datetime | None:
43
+ try:
44
+ stamp = datetime.datetime.fromisoformat(
45
+ (node.observed_at or node.created).replace("Z", "+00:00"))
46
+ return stamp.replace(tzinfo=datetime.timezone.utc) if stamp.tzinfo is None else stamp
47
+ except (ValueError, TypeError):
48
+ return None
49
+
50
+
51
+ def _read_cursor(brain: Brain) -> tuple[datetime.datetime, str] | None:
52
+ """Resume after the last examined event, including events that were skipped."""
53
+ brain.authorize("admin")
54
+ try:
55
+ data = json.loads((brain.path / CURSOR_FILE).read_text(encoding="utf-8"))
56
+ stamp = datetime.datetime.fromisoformat(data["observed_at"])
57
+ nid = data["id"]
58
+ if stamp.tzinfo is not None and isinstance(nid, str):
59
+ return stamp, nid
60
+ except (FileNotFoundError, ValueError, TypeError, KeyError):
61
+ pass
62
+ return None
63
+
64
+
65
+ def consolidation_plan(brain: Brain, *, config: ConsolidationConfig | None = None,
66
+ now: datetime.datetime | None = None) -> dict:
67
+ """Read-only gates and source IDs; no sync, cache writes, or extractor calls."""
68
+ if config is None:
69
+ from .brain_engine import consolidation_config_from_env
70
+ config = consolidation_config_from_env()
71
+ now = now or datetime.datetime.now(datetime.timezone.utc)
72
+ if now.tzinfo is None:
73
+ now = now.replace(tzinfo=datetime.timezone.utc)
74
+ nodes = {n.id: n for n in brain.read_nodes()}
75
+ # Keep reviewed/forgotten extractions processed too: a scheduled pass must
76
+ # not undo a user's rejection or resurrect an explicitly forgotten fact.
77
+ # Community summaries are not per-event fact extractions.
78
+ processed = {e.target for e in brain.read_edges(include_rejected=True)
79
+ if e.origin == "consolidator" and e.kind == "extends"
80
+ and e.source in nodes and nodes[e.source].ntype == "semantic"
81
+ and "community-summary" not in nodes[e.source].tags}
82
+ episodic = [n for n in nodes.values()
83
+ if n.ntype == "episodic" and n.status != "tombstone"]
84
+ eligible: list[tuple[datetime.datetime, str]] = []
85
+ for node in episodic:
86
+ if node.id in processed or not node.text.strip():
87
+ continue
88
+ stamp = _timestamp(node)
89
+ if stamp is None:
90
+ continue
91
+ age = (now - stamp).total_seconds() / 86400
92
+ if age >= config.min_age_days and node.recall_count >= config.threshold:
93
+ eligible.append((stamp, node.id))
94
+ eligible.sort()
95
+ cursor = _read_cursor(brain)
96
+ if cursor is not None:
97
+ # Walk the chronological queue in rounds. A decline remains retryable,
98
+ # but cannot consume the first slot of every bounded pass forever.
99
+ eligible = ([item for item in eligible if item > cursor]
100
+ + [item for item in eligible if item <= cursor])
101
+ triggered = len(eligible) >= config.min_count
102
+ return {"episodic": len(episodic),
103
+ "processed": sum(n.id in processed for n in episodic),
104
+ "eligible": len(eligible), "triggered": triggered,
105
+ "candidates": [nid for _, nid in eligible[:config.limit]] if triggered else [],
106
+ "gates": asdict(config)}
107
+
108
+
109
+ def consolidate(brain: Brain, *, config: ConsolidationConfig | None = None,
110
+ extractor: Callable[[str], str | None] | None = None,
111
+ dry_run: bool = False, commit: bool = True,
112
+ now: datetime.datetime | None = None) -> dict:
113
+ """One bounded pass, one commit. Validate all extraction before writing.
114
+
115
+ New facts enter probation. Exact normalized semantic matches are reused
116
+ (never episodic/procedural nodes); tombstoned matches are never revived.
117
+ Dry runs report source candidates, not model-dependent output counts.
118
+ """
119
+ brain.authorize("admin")
120
+ from .brain_engine import BRAIN_LOCK, consolidation_config_from_env
121
+ config = config or consolidation_config_from_env()
122
+ with BRAIN_LOCK:
123
+ if not dry_run:
124
+ brain.ensure_ready()
125
+ brain.pull()
126
+ plan = consolidation_plan(brain, config=config, now=now)
127
+ result = {**plan, "created": 0, "reused": 0, "edges": 0,
128
+ "skipped": 0, "dry_run": dry_run,
129
+ "used_llm": extractor is not None}
130
+ if dry_run or not plan["candidates"]:
131
+ return result
132
+
133
+ nodes = {n.id: n for n in brain.read_nodes()}
134
+ semantic: dict[str, Node] = {}
135
+ forgotten = {_key(n.text) for n in nodes.values()
136
+ if n.ntype == "semantic" and n.status == "tombstone"}
137
+ for node in sorted(nodes.values(), key=lambda n: n.id):
138
+ if (node.ntype == "semantic" and node.status != "tombstone"
139
+ and "community-summary" not in node.tags):
140
+ semantic.setdefault(_key(node.text), node)
141
+ changed: dict[str, Node] = {}
142
+ links: list[Edge] = []
143
+ for nid in plan["candidates"]:
144
+ event = nodes[nid]
145
+ text = extractor(event.text) if extractor is not None else event.text
146
+ if text is not None and not isinstance(text, str):
147
+ raise ValueError("consolidation extractor must return text or None")
148
+ if text is None or not text.strip() or _key(text) in forgotten:
149
+ result["skipped"] += 1
150
+ continue
151
+ text = text.strip()
152
+ key = _key(text)
153
+ fact = semantic.get(key)
154
+ if fact is None:
155
+ # Node identity must survive edits/merges independently of its
156
+ # text. Exact reuse is handled by `semantic`, not a content ID.
157
+ fact = Node(text=text, source="consolidator",
158
+ tags=["episodic-extraction"], ntype="semantic",
159
+ status="probation")
160
+ semantic[key] = fact
161
+ nodes[fact.id] = fact
162
+ changed[fact.id] = fact
163
+ result["created"] += 1
164
+ else:
165
+ result["reused"] += 1
166
+ if "consolidator" not in fact.sources:
167
+ fact.sources.append("consolidator")
168
+ changed[fact.id] = fact
169
+ links.append(Edge(source=fact.id, target=nid, kind="extends",
170
+ pending=False, origin="consolidator"))
171
+
172
+ # No file writes happen until every extractor result has been validated.
173
+ for fact in changed.values():
174
+ brain.write_node(fact)
175
+ if links:
176
+ brain.write_edges(brain.read_edges(include_rejected=True) + links)
177
+ brain.rebuild_index()
178
+ cursor_changed = False
179
+ if plan["eligible"] > len(plan["candidates"]):
180
+ last_id = plan["candidates"][-1]
181
+ stamp = _timestamp(nodes[last_id])
182
+ if (stamp, last_id) != _read_cursor(brain):
183
+ _atomic_write(brain.path / CURSOR_FILE, json.dumps(
184
+ {"observed_at": stamp.isoformat(), "id": last_id}) + "\n")
185
+ cursor_changed = True
186
+ if commit and (links or cursor_changed):
187
+ brain.commit_and_push(
188
+ f"consolidate episodic: {result['created']} facts, "
189
+ f"{result['reused']} reused, {len(links)} provenance edges")
190
+ result["edges"] = len(links)
191
+ return result