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
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]
@@ -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()