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
graph_engine/dream.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""The dream pass: what the brain does when nobody is ingesting.
|
|
2
|
+
|
|
3
|
+
The ingest half of this engine is strong (dedupe, typed edges, provenance,
|
|
4
|
+
evals). The other half of a memory system — consolidation — was missing: nothing
|
|
5
|
+
promoted, nothing decayed, nothing was ever distilled, `consolidate()` was dead
|
|
6
|
+
code. This module is that half, split into three explicitly requested steps so
|
|
7
|
+
nothing happens implicitly:
|
|
8
|
+
|
|
9
|
+
plan() — the eligibility report (`ig dream` / `--dry-run`). No writes.
|
|
10
|
+
Every gate is a flag; the numbers tell you what a pass WOULD do
|
|
11
|
+
before it does anything.
|
|
12
|
+
refresh() — deterministic maintenance: re-derive similarity kinds from the
|
|
13
|
+
stored cosine (suggester edges only — manual edges are
|
|
14
|
+
user-authored), fold the recall ledger, rebuild INDEX, refresh
|
|
15
|
+
BRAIN_REPORT. Never destructive.
|
|
16
|
+
distill() — one abstraction node per community (extractive by default;
|
|
17
|
+
`summarizer=` / `--llm` swaps in a model). Marked
|
|
18
|
+
`origin="consolidator"` so the next pass can tell what a pass
|
|
19
|
+
wrote.
|
|
20
|
+
lifecycle()— promotion and decay from the recall signal: probation → active
|
|
21
|
+
(used and connected), probation/active → stale (unused, weakly
|
|
22
|
+
connected, old), stale → active again (used once more). Never
|
|
23
|
+
destructive: `stale` is a demotion, the node stays in the file.
|
|
24
|
+
|
|
25
|
+
Reference points (both are the same shape): OpenClaw's dreaming (light/REM/deep,
|
|
26
|
+
promotion only through gates, consolidation turn that merges duplicates and
|
|
27
|
+
retires superseded entries) and the Hermes curator (deterministic transitions
|
|
28
|
+
that never delete, pinned/cron-referenced entries protected, LLM consolidation
|
|
29
|
+
OFF by default). The gates are DERIVED FROM THE LIVE DISTRIBUTION, not copied
|
|
30
|
+
from those systems — see `lifecycle_plan` for the measurement behind each
|
|
31
|
+
number. The merge axis (97 pairs in the review band) is a REVIEW list, not an
|
|
32
|
+
auto-merge: the top pairs are demonstrably related-but-distinct.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import collections
|
|
38
|
+
import datetime
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import pathlib
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from typing import Callable
|
|
44
|
+
|
|
45
|
+
from .brain import Brain, Edge, Node
|
|
46
|
+
from .communities import analyze_communities
|
|
47
|
+
from .consolidation import ConsolidationConfig, consolidation_plan
|
|
48
|
+
from .hygiene import connectivity, near_dup_pairs
|
|
49
|
+
from .intent import INTENT_KINDS
|
|
50
|
+
from .similarity import cosine
|
|
51
|
+
|
|
52
|
+
# --- Lifecycle gates (measured 2026-09-18 on the live brain, not guessed) -----
|
|
53
|
+
# recall distribution: 5 nodes with recall_count > 0, all of them degree >= 2;
|
|
54
|
+
# degree: min 2 / p25 3 / median 5 / p75 7; age: median 11.9 d, oldest 26.7 d.
|
|
55
|
+
# So: "used at least once AND connected" is the honest promotion bar at this
|
|
56
|
+
# size (a gate of recall >= 3 would have promoted exactly 0 nodes), the corpus
|
|
57
|
+
# minimum degree 2 means "not an island", 30 days is a month of silence, and
|
|
58
|
+
# degree <= 2 is the weakest tail (24 nodes). Decay cannot fire before the
|
|
59
|
+
# corpus is 30 days old — that is the honest outcome, not a reason to lower it.
|
|
60
|
+
PROMOTE_MIN_RECALL = 1
|
|
61
|
+
PROMOTE_MIN_DEGREE = 2
|
|
62
|
+
DECAY_DAYS = 30
|
|
63
|
+
DECAY_MAX_DEGREE = 2
|
|
64
|
+
STALE_STATUS = "stale"
|
|
65
|
+
|
|
66
|
+
# Similarity kinds are re-derived from the stored cosine with the band rule the
|
|
67
|
+
# suggester uses (`suggester.py`): >= 0.75 same topic, below that builds-on.
|
|
68
|
+
SIMILAR_BAND = 0.75
|
|
69
|
+
# Communities of at least this size are distillable; a pass writes at most
|
|
70
|
+
# `DREAM_MAX_DISTILL` summary nodes so one run stays reviewable.
|
|
71
|
+
DREAM_MIN_COMMUNITY = 10
|
|
72
|
+
DREAM_MAX_DISTILL = 10
|
|
73
|
+
# Members a summary links to (strongest by degree inside the community).
|
|
74
|
+
DREAM_MEMBERS_PER_SUMMARY = 5
|
|
75
|
+
SUMMARY_TAG = "community-summary"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class DreamPlan:
|
|
80
|
+
"""What a pass WOULD do under the given gates (nothing written)."""
|
|
81
|
+
|
|
82
|
+
nodes: int = 0
|
|
83
|
+
edges: int = 0
|
|
84
|
+
promotion_candidates: list[str] = field(default_factory=list)
|
|
85
|
+
decay_candidates: list[str] = field(default_factory=list)
|
|
86
|
+
merge_candidates: list[tuple[str, str, float]] = field(default_factory=list)
|
|
87
|
+
distill_candidates: list[tuple[int, int]] = field(default_factory=list) # (cid, size)
|
|
88
|
+
refresh: dict = field(default_factory=dict)
|
|
89
|
+
consolidation: dict = field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
def render(self) -> str:
|
|
92
|
+
lines = ["# DREAM PLAN (dry run — nothing written)", ""]
|
|
93
|
+
lines.append(f"brain: {self.nodes} live nodes · {self.edges} live edges")
|
|
94
|
+
lines.append("")
|
|
95
|
+
lines.append(f"## Promotion candidates: {len(self.promotion_candidates)}")
|
|
96
|
+
lines.append(f"## Decay candidates: {len(self.decay_candidates)}")
|
|
97
|
+
lines.append(f"## Episodic extraction candidates: {len(self.consolidation.get('candidates', []))}")
|
|
98
|
+
lines.append(f" gates: {self.consolidation.get('gates', {})}")
|
|
99
|
+
lines.append(f"## Merge candidates (review, never auto): {len(self.merge_candidates)}")
|
|
100
|
+
for a, b, score in self.merge_candidates[:5]:
|
|
101
|
+
lines.append(f" {score:.3f} {a!r}")
|
|
102
|
+
lines.append(f" {b!r}")
|
|
103
|
+
lines.append(f"## Distill candidates: {len(self.distill_candidates)} communities")
|
|
104
|
+
for cid, size in self.distill_candidates[:10]:
|
|
105
|
+
lines.append(f" C{cid} size {size}")
|
|
106
|
+
lines.append("## Refresh")
|
|
107
|
+
for key, value in sorted(self.refresh.items()):
|
|
108
|
+
lines.append(f" {key}: {value}")
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def node_age_days(node: Node, now: "datetime.datetime | None" = None) -> float:
|
|
113
|
+
"""Age in days from `created`; 0.0 when the timestamp is unparseable."""
|
|
114
|
+
now = now or datetime.datetime.now(datetime.timezone.utc)
|
|
115
|
+
try:
|
|
116
|
+
t = datetime.datetime.fromisoformat((node.created or "").replace("Z", "+00:00"))
|
|
117
|
+
except ValueError:
|
|
118
|
+
return 0.0
|
|
119
|
+
return (now - t).total_seconds() / 86400
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _live_graph(brain: Brain) -> tuple[dict[str, Node], collections.Counter, list[Edge]]:
|
|
123
|
+
"""Live nodes (no tombstones) + degree over live edges + those edges."""
|
|
124
|
+
nodes = {n.id: n for n in brain.read_nodes() if n.status != "tombstone"}
|
|
125
|
+
edges = [e for e in brain.read_edges(include_rejected=True)
|
|
126
|
+
if not e.pending and not e.rejected and e.is_current]
|
|
127
|
+
degree: collections.Counter = collections.Counter()
|
|
128
|
+
for e in edges:
|
|
129
|
+
degree[e.source] += 1
|
|
130
|
+
degree[e.target] += 1
|
|
131
|
+
return nodes, degree, edges
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def lifecycle_plan(brain: Brain, *, min_recall: int = PROMOTE_MIN_RECALL,
|
|
135
|
+
min_degree: int = PROMOTE_MIN_DEGREE,
|
|
136
|
+
stale_days: int = DECAY_DAYS,
|
|
137
|
+
max_degree: int = DECAY_MAX_DEGREE) -> dict:
|
|
138
|
+
"""Who the lifecycle pass would touch under these gates. Read-only.
|
|
139
|
+
|
|
140
|
+
Gates are data-derived (measured 2026-09-18 on the live brain), not copied
|
|
141
|
+
from another system: `min_recall=1` because the ledger is young and "used
|
|
142
|
+
once and connected" is the honest bar at this size; `min_degree=2` is the
|
|
143
|
+
corpus minimum (every live node has >= 2 edges, so it means "not an island");
|
|
144
|
+
`stale_days=30` is a month of silence; `max_degree=2` is the weakest tail.
|
|
145
|
+
"""
|
|
146
|
+
nodes, degree, _ = _live_graph(brain)
|
|
147
|
+
summaries = _summary_ids(brain)
|
|
148
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
149
|
+
|
|
150
|
+
def used(nid: str) -> bool:
|
|
151
|
+
return getattr(nodes[nid], "recall_count", 0) >= min_recall
|
|
152
|
+
|
|
153
|
+
promote, revive, decay = [], [], []
|
|
154
|
+
for nid, n in nodes.items():
|
|
155
|
+
if nid in summaries:
|
|
156
|
+
continue # derived nodes: a pass never re-grades its own output
|
|
157
|
+
deg = degree.get(nid, 0)
|
|
158
|
+
if n.status == "probation" and used(nid) and deg >= min_degree:
|
|
159
|
+
promote.append(nid)
|
|
160
|
+
elif n.status == STALE_STATUS and used(nid) and deg >= min_degree:
|
|
161
|
+
revive.append(nid) # decay is reversible: being used again revives
|
|
162
|
+
elif (n.status in ("probation", "active")
|
|
163
|
+
and getattr(n, "recall_count", 0) == 0
|
|
164
|
+
and deg <= max_degree
|
|
165
|
+
and node_age_days(n, now) >= stale_days):
|
|
166
|
+
decay.append(nid)
|
|
167
|
+
return {"promote": promote, "revive": revive, "decay": decay,
|
|
168
|
+
"gates": {"min_recall": min_recall, "min_degree": min_degree,
|
|
169
|
+
"stale_days": stale_days, "max_degree": max_degree}}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def lifecycle(brain: Brain, *, min_recall: int = PROMOTE_MIN_RECALL,
|
|
173
|
+
min_degree: int = PROMOTE_MIN_DEGREE, stale_days: int = DECAY_DAYS,
|
|
174
|
+
max_degree: int = DECAY_MAX_DEGREE, dry_run: bool = False,
|
|
175
|
+
commit: bool = True) -> dict:
|
|
176
|
+
"""Promotion and decay: the status lifecycle, one commit, never destructive.
|
|
177
|
+
|
|
178
|
+
ROADMAP_CASE `roadmap-dream-lifecycle`.
|
|
179
|
+
|
|
180
|
+
probation → active (used and connected), active/probation → stale (unused,
|
|
181
|
+
weakly connected, old), stale → active again (used once more). Nothing is
|
|
182
|
+
deleted and nothing leaves the file: `stale` is a demotion, not a removal —
|
|
183
|
+
it takes the node out of the promotion pool and marks it for the report.
|
|
184
|
+
"""
|
|
185
|
+
brain.authorize("admin")
|
|
186
|
+
plan_ = lifecycle_plan(brain, min_recall=min_recall, min_degree=min_degree,
|
|
187
|
+
stale_days=stale_days, max_degree=max_degree)
|
|
188
|
+
promote, revive, decay = plan_["promote"], plan_["revive"], plan_["decay"]
|
|
189
|
+
|
|
190
|
+
if not dry_run and (promote or revive or decay):
|
|
191
|
+
nodes = {n.id: n for n in brain.read_nodes()}
|
|
192
|
+
for nid in promote + revive:
|
|
193
|
+
nodes[nid].status = "active" # used + connected = consolidated
|
|
194
|
+
brain.write_node(nodes[nid])
|
|
195
|
+
for nid in decay:
|
|
196
|
+
nodes[nid].status = STALE_STATUS # demotion, never a removal
|
|
197
|
+
brain.write_node(nodes[nid])
|
|
198
|
+
brain.rebuild_index()
|
|
199
|
+
if commit:
|
|
200
|
+
brain.commit_and_push(
|
|
201
|
+
f"dream lifecycle: {len(promote)} promoted, {len(revive)} revived, "
|
|
202
|
+
f"{len(decay)} stale (recall >= {min_recall} and degree >= "
|
|
203
|
+
f"{min_degree}; unused, degree <= {max_degree}, age >= {stale_days}d)")
|
|
204
|
+
|
|
205
|
+
return {"promoted": len(promote), "revived": len(revive), "staled": len(decay),
|
|
206
|
+
"candidates": {"promote": len(promote), "revive": len(revive),
|
|
207
|
+
"decay": len(decay)},
|
|
208
|
+
"dry_run": dry_run}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def plan(brain: Brain, *, min_recall: int = PROMOTE_MIN_RECALL,
|
|
212
|
+
min_degree: int = PROMOTE_MIN_DEGREE, stale_days: int = DECAY_DAYS,
|
|
213
|
+
min_community: int = DREAM_MIN_COMMUNITY,
|
|
214
|
+
merge_band: tuple[float, float] = (0.78, 0.92),
|
|
215
|
+
consolidation_config: ConsolidationConfig | None = None) -> DreamPlan:
|
|
216
|
+
"""Eligibility report for every axis. Read-only."""
|
|
217
|
+
nodes, degree, edges = _live_graph(brain)
|
|
218
|
+
life = lifecycle_plan(brain, min_recall=min_recall, min_degree=min_degree,
|
|
219
|
+
stale_days=stale_days)
|
|
220
|
+
promotion = life["promote"] + life["revive"]
|
|
221
|
+
decay = life["decay"]
|
|
222
|
+
|
|
223
|
+
merges = [(f"{nodes[p.a].text.split(chr(10))[0][:46]}"
|
|
224
|
+
if p.a in nodes else p.a,
|
|
225
|
+
f"{nodes[p.b].text.split(chr(10))[0][:46]}"
|
|
226
|
+
if p.b in nodes else p.b, p.score)
|
|
227
|
+
for p in near_dup_pairs(brain, lo=merge_band[0], hi=merge_band[1])]
|
|
228
|
+
rep = analyze_communities(brain, min_size=min_community, top=DREAM_MAX_DISTILL,
|
|
229
|
+
exclude_ids=_summary_ids(brain))
|
|
230
|
+
distill = [(c.id, c.size) for c in rep.communities if c.size >= min_community]
|
|
231
|
+
|
|
232
|
+
refresh = refresh_plan(brain)
|
|
233
|
+
return DreamPlan(nodes=len(nodes), edges=len(edges),
|
|
234
|
+
promotion_candidates=promotion, decay_candidates=decay,
|
|
235
|
+
merge_candidates=merges, distill_candidates=distill,
|
|
236
|
+
refresh=refresh,
|
|
237
|
+
consolidation=consolidation_plan(brain, config=consolidation_config))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def refresh_plan(brain: Brain) -> dict:
|
|
241
|
+
"""What `refresh()` would change (read-only)."""
|
|
242
|
+
from .recall import read_ledger
|
|
243
|
+
edges = [e for e in brain.read_edges(include_rejected=True)
|
|
244
|
+
if not e.pending and not e.rejected and e.is_current]
|
|
245
|
+
mismatched = [e for e in edges
|
|
246
|
+
if e.origin == "suggester" and e.confidence is not None
|
|
247
|
+
and e.kind in ("extends", "similar")
|
|
248
|
+
and e.kind != _expected_kind(e.confidence)]
|
|
249
|
+
conn = connectivity(brain)
|
|
250
|
+
return {
|
|
251
|
+
"kind_mismatches": len(mismatched),
|
|
252
|
+
"islands": len(conn.islands),
|
|
253
|
+
"orphans": len(conn.orphans),
|
|
254
|
+
"recall_ledger_entries": len(read_ledger(brain)),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _expected_kind(confidence: float) -> str:
|
|
259
|
+
return "similar" if confidence >= SIMILAR_BAND else "extends"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def refresh(brain: Brain, *, dry_run: bool = False, commit: bool = True) -> dict:
|
|
263
|
+
"""Deterministic maintenance. Never destructive, never touches manual edges.
|
|
264
|
+
|
|
265
|
+
1. Re-derive the kind of SUGGESTER similarity edges from the stored cosine
|
|
266
|
+
(the band rule). Manual edges keep whatever kind the user chose — that is
|
|
267
|
+
the whole point of the provenance field.
|
|
268
|
+
2. Fold the recall ledger into the node counters (`recall.aggregate`).
|
|
269
|
+
3. Rebuild INDEX.md and regenerate the tracked BRAIN_REPORT.md.
|
|
270
|
+
One commit for the whole pass.
|
|
271
|
+
"""
|
|
272
|
+
brain.authorize("admin")
|
|
273
|
+
from .recall import aggregate as aggregate_recalls
|
|
274
|
+
from .report import write_report
|
|
275
|
+
|
|
276
|
+
changed = []
|
|
277
|
+
edges = brain.read_edges(include_rejected=True)
|
|
278
|
+
for edge in edges:
|
|
279
|
+
if (edge.origin == "suggester" and edge.confidence is not None
|
|
280
|
+
and edge.kind in ("extends", "similar")
|
|
281
|
+
and edge.is_current and not edge.rejected):
|
|
282
|
+
expected = _expected_kind(edge.confidence)
|
|
283
|
+
if edge.kind != expected:
|
|
284
|
+
changed.append((edge.id, edge.kind, expected))
|
|
285
|
+
if not dry_run:
|
|
286
|
+
edge.kind = expected
|
|
287
|
+
if changed and not dry_run:
|
|
288
|
+
brain.write_edges(edges)
|
|
289
|
+
|
|
290
|
+
recall_res = {"nodes": 0, "recalls": 0}
|
|
291
|
+
if not dry_run:
|
|
292
|
+
recall_res = aggregate_recalls(brain, commit=False)
|
|
293
|
+
brain.rebuild_index()
|
|
294
|
+
try:
|
|
295
|
+
write_report(brain, write=True)
|
|
296
|
+
except Exception:
|
|
297
|
+
# A report failure must never fail the pass (same rule as the cycle).
|
|
298
|
+
pass
|
|
299
|
+
if commit:
|
|
300
|
+
brain.commit_and_push(
|
|
301
|
+
f"dream refresh: {len(changed)} kind(s) re-derived, "
|
|
302
|
+
f"{recall_res['recalls']} recall(s) folded, INDEX + report refreshed")
|
|
303
|
+
|
|
304
|
+
return {"kind_changes": len(changed), "kinds": changed[:10],
|
|
305
|
+
"recall_nodes": recall_res["nodes"], "recalls": recall_res["recalls"],
|
|
306
|
+
"dry_run": dry_run}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _summary_ids(brain: Brain) -> set[str]:
|
|
310
|
+
"""Ids of nodes a previous pass wrote — excluded from the next partition."""
|
|
311
|
+
return {n.id for n in brain.read_nodes() if SUMMARY_TAG in (n.tags or [])}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def distill(brain: Brain, *, min_size: int = DREAM_MIN_COMMUNITY,
|
|
315
|
+
limit: int = DREAM_MAX_DISTILL, members_per_summary: int = DREAM_MEMBERS_PER_SUMMARY,
|
|
316
|
+
summarizer: Callable[[str], str] | None = None,
|
|
317
|
+
dry_run: bool = False, commit: bool = True) -> dict:
|
|
318
|
+
"""One abstraction node per community — the actual "dreaming" step.
|
|
319
|
+
|
|
320
|
+
Extractive by default (deterministic, free, reproducible): the digest names
|
|
321
|
+
the community size, its dominant taxonomy area, the internal edge mix and its
|
|
322
|
+
strongest members. `summarizer=` swaps in a model (the CLI wires `--llm`);
|
|
323
|
+
the digest is then handed to it as the prompt, so the model sees the same
|
|
324
|
+
evidence the extractive path uses.
|
|
325
|
+
|
|
326
|
+
Idempotent: the summary id is derived from the member set, so a second pass
|
|
327
|
+
over an unchanged community finds the node and skips it. Summary nodes are
|
|
328
|
+
`status="active"` (a distillation is a deliberate consolidation act) and
|
|
329
|
+
carry the `community-summary` tag; their edges are `origin="consolidator"`.
|
|
330
|
+
"""
|
|
331
|
+
brain.authorize("admin")
|
|
332
|
+
nodes = {n.id: n for n in brain.read_nodes() if n.status != "tombstone"}
|
|
333
|
+
edges = [e for e in brain.read_edges(include_rejected=True)
|
|
334
|
+
if not e.pending and not e.rejected and e.is_current]
|
|
335
|
+
degree: collections.Counter = collections.Counter()
|
|
336
|
+
for e in edges:
|
|
337
|
+
degree[e.source] += 1
|
|
338
|
+
degree[e.target] += 1
|
|
339
|
+
|
|
340
|
+
rep = analyze_communities(brain, min_size=min_size, top=limit, with_members=True,
|
|
341
|
+
exclude_ids=_summary_ids(brain))
|
|
342
|
+
candidates = [c for c in rep.communities if c.size >= min_size][:limit]
|
|
343
|
+
|
|
344
|
+
summaries = 0
|
|
345
|
+
new_edges: list[Edge] = []
|
|
346
|
+
for community in candidates:
|
|
347
|
+
members = [m for m in community.members if m in nodes]
|
|
348
|
+
if not members:
|
|
349
|
+
continue
|
|
350
|
+
summary_id = _summary_id(members)
|
|
351
|
+
if summary_id in nodes:
|
|
352
|
+
continue # already distilled (idempotent)
|
|
353
|
+
digest = _community_digest(brain, members, nodes, degree, edges, community.label)
|
|
354
|
+
text = summarizer(digest) if summarizer is not None else digest
|
|
355
|
+
summaries += 1
|
|
356
|
+
if dry_run:
|
|
357
|
+
continue
|
|
358
|
+
brain.write_node(Node(id=summary_id, text=text, source="consolidator",
|
|
359
|
+
tags=[SUMMARY_TAG], ntype="semantic", status="active"))
|
|
360
|
+
nodes[summary_id] = Node(id=summary_id, text=text)
|
|
361
|
+
strongest = sorted(members, key=lambda m: (-degree.get(m, 0), m))[:members_per_summary]
|
|
362
|
+
for member in strongest:
|
|
363
|
+
new_edges.append(Edge(source=summary_id, target=member, kind="extends",
|
|
364
|
+
pending=False, origin="consolidator"))
|
|
365
|
+
|
|
366
|
+
if new_edges and not dry_run:
|
|
367
|
+
all_edges = brain.read_edges(include_rejected=True) + new_edges
|
|
368
|
+
brain.write_edges(all_edges)
|
|
369
|
+
brain.rebuild_index()
|
|
370
|
+
if commit:
|
|
371
|
+
brain.commit_and_push(
|
|
372
|
+
f"dream distill: {summaries} community summar{'y' if summaries == 1 else 'ies'}, "
|
|
373
|
+
f"{len(new_edges)} consolidator edges")
|
|
374
|
+
|
|
375
|
+
return {"summaries": summaries, "edges": len(new_edges),
|
|
376
|
+
"communities": len(candidates), "used_llm": summarizer is not None,
|
|
377
|
+
"dry_run": dry_run}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _community_digest(brain: Brain, member_ids: list[str], nodes: dict[str, Node],
|
|
381
|
+
degree: collections.Counter, edges: list[Edge],
|
|
382
|
+
dominant_area: str) -> str:
|
|
383
|
+
"""Deterministic (extractive) digest of a community — the default summary."""
|
|
384
|
+
member_set = set(member_ids)
|
|
385
|
+
members = [nodes[m] for m in member_ids if m in nodes]
|
|
386
|
+
hubs = sorted(member_ids, key=lambda m: -degree.get(m, 0))[:8]
|
|
387
|
+
# The edge-mix census counts every non-rejected edge (pending included):
|
|
388
|
+
# it describes what the community looks like, it is not a validity claim.
|
|
389
|
+
census_edges = brain.read_edges(include_rejected=True)
|
|
390
|
+
kinds = collections.Counter(e.kind for e in census_edges
|
|
391
|
+
if e.source in member_set or e.target in member_set)
|
|
392
|
+
titles = [nodes[h].text.split("\n")[0][:90] for h in hubs if h in nodes]
|
|
393
|
+
mix = ", ".join(f"{k} {v}" for k, v in kinds.most_common(6)) or "none yet"
|
|
394
|
+
lines = [
|
|
395
|
+
f"Community abstraction ({len(members)} nodes, dominant area: {dominant_area}).",
|
|
396
|
+
f"Edge mix inside the community: {mix}.",
|
|
397
|
+
"Strongest members:",
|
|
398
|
+
]
|
|
399
|
+
lines += [f"- {t}" for t in titles[:5]]
|
|
400
|
+
lines.append("This node is a consolidator summary: it exists to make the "
|
|
401
|
+
"community retrievable as a unit, not to replace its members.")
|
|
402
|
+
return "\n".join(lines)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _summary_id(member_ids: list[str]) -> str:
|
|
406
|
+
"""Deterministic id from the member set: re-running a pass is idempotent."""
|
|
407
|
+
digest = hashlib.sha1("|".join(sorted(member_ids)).encode("utf-8")).hexdigest()
|
|
408
|
+
return "c" + digest[:11]
|
graph_engine/embedder.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Embeddings: local via sentence-transformers (default: all-MiniLM-L6-v2).
|
|
2
|
+
|
|
3
|
+
HashEmbedder is the deterministic test embedder — no model download.
|
|
4
|
+
Audit #60: embedders expose embed_batch() so cold caches embed N nodes in
|
|
5
|
+
ONE model call instead of N sequential calls (sentence-transformers
|
|
6
|
+
encode() accepts lists natively).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Embedder:
|
|
15
|
+
"""Loads sentence-transformers lazily — only on the first real embedding."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
|
|
18
|
+
self.model_name = model_name
|
|
19
|
+
self._model = None
|
|
20
|
+
|
|
21
|
+
def _ensure_model(self):
|
|
22
|
+
if self._model is None:
|
|
23
|
+
from sentence_transformers import SentenceTransformer
|
|
24
|
+
self._model = SentenceTransformer(self.model_name)
|
|
25
|
+
return self._model
|
|
26
|
+
|
|
27
|
+
def embed(self, text: str) -> list[float]:
|
|
28
|
+
model = self._ensure_model()
|
|
29
|
+
return model.encode(text).tolist()
|
|
30
|
+
|
|
31
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
32
|
+
model = self._ensure_model()
|
|
33
|
+
return model.encode(texts).tolist()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HashEmbedder:
|
|
37
|
+
"""Deterministic test embedder: bag-of-words projection onto a fixed dim.
|
|
38
|
+
|
|
39
|
+
No model, no network — same text → same vector,
|
|
40
|
+
similar text (word overlap) → similar vector.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
DIM = 64
|
|
44
|
+
|
|
45
|
+
def embed(self, text: str) -> list[float]:
|
|
46
|
+
vec = [0.0] * self.DIM
|
|
47
|
+
for word in text.lower().split():
|
|
48
|
+
h = int(hashlib.sha256(word.encode()).hexdigest(), 16)
|
|
49
|
+
idx = h % self.DIM
|
|
50
|
+
sign = 1.0 if (h >> 8) % 2 == 0 else -1.0
|
|
51
|
+
vec[idx] += sign
|
|
52
|
+
norm = sum(v * v for v in vec) ** 0.5
|
|
53
|
+
if norm > 0:
|
|
54
|
+
vec = [v / norm for v in vec]
|
|
55
|
+
return vec
|
|
56
|
+
|
|
57
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
58
|
+
return [self.embed(t) for t in texts]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_embedder(name: str = "st", model: str | None = None) -> Embedder | HashEmbedder:
|
|
62
|
+
"""Audit #60: model override is honored (default: all-MiniLM-L6-v2).
|
|
63
|
+
|
|
64
|
+
'st' degrades gracefully when sentence-transformers is not installed
|
|
65
|
+
(the [st] extra is optional): falls back to HashEmbedder with a clear
|
|
66
|
+
notice instead of crashing the CLI/server on a light install.
|
|
67
|
+
"""
|
|
68
|
+
if name == "hash":
|
|
69
|
+
return HashEmbedder()
|
|
70
|
+
try:
|
|
71
|
+
import sentence_transformers # noqa: F401 — availability probe only
|
|
72
|
+
except ImportError:
|
|
73
|
+
# stderr, not stdout: a stdio MCP server's stdout IS the JSON-RPC
|
|
74
|
+
# transport, and even for plain CLI users informational notices
|
|
75
|
+
# must never corrupt piped output (e.g. `ig search --json`).
|
|
76
|
+
import sys
|
|
77
|
+
print(
|
|
78
|
+
"NOTE: sentence-transformers is not installed — falling back to the "
|
|
79
|
+
"deterministic HashEmbedder (64-dim, lower quality). Install the "
|
|
80
|
+
"real embedder with: pip install 'graph-engine-memory[st]'",
|
|
81
|
+
file=sys.stderr,
|
|
82
|
+
)
|
|
83
|
+
return HashEmbedder()
|
|
84
|
+
return Embedder(model or "all-MiniLM-L6-v2")
|
graph_engine/engine.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Ingest: text in → node + embedding + edge suggestions out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .model import Node
|
|
6
|
+
from .store import Store
|
|
7
|
+
from .embedder import get_embedder
|
|
8
|
+
from .suggester import suggest_edges
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Engine:
|
|
12
|
+
"""The growth loop as a class — store + embedder bundled."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, store: Store, embedder=None):
|
|
15
|
+
self.store = store
|
|
16
|
+
self.embedder = embedder if embedder is not None else get_embedder()
|
|
17
|
+
|
|
18
|
+
def ingest(self, text: str) -> tuple[Node, list]:
|
|
19
|
+
text = text.strip()
|
|
20
|
+
if not text:
|
|
21
|
+
raise ValueError("Empty text cannot be ingested.")
|
|
22
|
+
node = Node(text=text)
|
|
23
|
+
vec = self.embedder.embed(text)
|
|
24
|
+
self.store.add_node(node)
|
|
25
|
+
candidates = {
|
|
26
|
+
n.id: self.embedder.embed(n.text)
|
|
27
|
+
for n in self.store.nodes if n.id != node.id
|
|
28
|
+
}
|
|
29
|
+
edges = suggest_edges(node.id, vec, candidates)
|
|
30
|
+
for e in edges:
|
|
31
|
+
self.store.add_edge(e)
|
|
32
|
+
return node, edges
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main() -> None:
|
|
36
|
+
import sys
|
|
37
|
+
embedder_name = "hash" if "--hash" in sys.argv else "st"
|
|
38
|
+
args = [a for a in sys.argv[1:] if a != "--hash"]
|
|
39
|
+
if "-f" in args:
|
|
40
|
+
path = args[args.index("-f") + 1]
|
|
41
|
+
text = open(path, encoding="utf-8").read()
|
|
42
|
+
elif "-" in args:
|
|
43
|
+
text = sys.stdin.read()
|
|
44
|
+
else:
|
|
45
|
+
text = " ".join(args)
|
|
46
|
+
if not text.strip():
|
|
47
|
+
print("Nothing to ingest. Usage: python -m graph_engine <text|->")
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
store = Store()
|
|
50
|
+
engine = Engine(store, get_embedder(embedder_name))
|
|
51
|
+
node, edges = engine.ingest(text)
|
|
52
|
+
print(f"Node {node.id}: {node.text}")
|
|
53
|
+
for e in edges:
|
|
54
|
+
print(f" Suggestion: --[{e.kind}]--> {e.target} (pending)")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|