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.
- graph_engine/__init__.py +1 -0
- graph_engine/__main__.py +1275 -0
- graph_engine/access.py +202 -0
- graph_engine/agent_memory.py +103 -0
- graph_engine/brain.py +731 -0
- graph_engine/brain_engine.py +559 -0
- graph_engine/communities.py +503 -0
- graph_engine/consolidation.py +191 -0
- graph_engine/context.py +203 -0
- graph_engine/decay.py +65 -0
- graph_engine/demo.py +165 -0
- graph_engine/dream.py +408 -0
- graph_engine/embedder.py +84 -0
- graph_engine/engine.py +58 -0
- graph_engine/entities.py +376 -0
- graph_engine/evals.py +1206 -0
- graph_engine/feedback.py +155 -0
- graph_engine/gaps.py +209 -0
- graph_engine/graph.py +133 -0
- graph_engine/hygiene.py +213 -0
- graph_engine/inference.py +251 -0
- graph_engine/intent.py +207 -0
- graph_engine/mcp/__init__.py +1 -0
- graph_engine/mcp/agent/instructions.md +34 -0
- graph_engine/mcp/format.py +66 -0
- graph_engine/mcp/server.py +407 -0
- graph_engine/merge.py +220 -0
- graph_engine/model.py +56 -0
- graph_engine/namespaces.py +74 -0
- graph_engine/recall.py +161 -0
- graph_engine/report.py +401 -0
- graph_engine/reranker.py +86 -0
- graph_engine/retrieval.py +296 -0
- graph_engine/review.py +95 -0
- graph_engine/runtime.py +83 -0
- graph_engine/server.py +239 -0
- graph_engine/similarity.py +42 -0
- graph_engine/store.py +63 -0
- graph_engine/suggester.py +63 -0
- graph_engine/temporal.py +216 -0
- graph_engine/tiers.py +135 -0
- graph_engine/web/app.js +578 -0
- graph_engine/web/index.html +169 -0
- graph_engine/web/report.html +39 -0
- graph_engine/web/report.js +50 -0
- graph_engine/web/review.html +104 -0
- graph_engine/web/review.js +211 -0
- graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
- graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
- graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
- graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
- graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
- 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
|