graph-engine-memory 0.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. graph_engine/__init__.py +1 -0
  2. graph_engine/__main__.py +1275 -0
  3. graph_engine/access.py +202 -0
  4. graph_engine/agent_memory.py +103 -0
  5. graph_engine/brain.py +731 -0
  6. graph_engine/brain_engine.py +559 -0
  7. graph_engine/communities.py +503 -0
  8. graph_engine/consolidation.py +191 -0
  9. graph_engine/context.py +203 -0
  10. graph_engine/decay.py +65 -0
  11. graph_engine/demo.py +165 -0
  12. graph_engine/dream.py +408 -0
  13. graph_engine/embedder.py +84 -0
  14. graph_engine/engine.py +58 -0
  15. graph_engine/entities.py +376 -0
  16. graph_engine/evals.py +1206 -0
  17. graph_engine/feedback.py +155 -0
  18. graph_engine/gaps.py +209 -0
  19. graph_engine/graph.py +133 -0
  20. graph_engine/hygiene.py +213 -0
  21. graph_engine/inference.py +251 -0
  22. graph_engine/intent.py +207 -0
  23. graph_engine/mcp/__init__.py +1 -0
  24. graph_engine/mcp/agent/instructions.md +34 -0
  25. graph_engine/mcp/format.py +66 -0
  26. graph_engine/mcp/server.py +407 -0
  27. graph_engine/merge.py +220 -0
  28. graph_engine/model.py +56 -0
  29. graph_engine/namespaces.py +74 -0
  30. graph_engine/recall.py +161 -0
  31. graph_engine/report.py +401 -0
  32. graph_engine/reranker.py +86 -0
  33. graph_engine/retrieval.py +296 -0
  34. graph_engine/review.py +95 -0
  35. graph_engine/runtime.py +83 -0
  36. graph_engine/server.py +239 -0
  37. graph_engine/similarity.py +42 -0
  38. graph_engine/store.py +63 -0
  39. graph_engine/suggester.py +63 -0
  40. graph_engine/temporal.py +216 -0
  41. graph_engine/tiers.py +135 -0
  42. graph_engine/web/app.js +578 -0
  43. graph_engine/web/index.html +169 -0
  44. graph_engine/web/report.html +39 -0
  45. graph_engine/web/report.js +50 -0
  46. graph_engine/web/review.html +104 -0
  47. graph_engine/web/review.js +211 -0
  48. graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
  49. graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
  50. graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
  51. graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
  52. graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
  53. graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,203 @@
1
+ """Context window management (Phase 3): smart context assembly.
2
+
3
+ The engine has hybrid retrieval (BM25 + Dense + RRF) but no way to build
4
+ an optimized prompt context. This module adds:
5
+
6
+ build_context(engine, query, budget) — assembles a context window
7
+ hierarchical_context(engine, query) — main → recall → archival tiers
8
+ estimate_tokens(text) — rough token estimation
9
+
10
+ All functions are read-only. They never modify the brain.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from .brain import Brain, Node
20
+ from .brain_engine import BrainEngine
21
+
22
+
23
+ def estimate_tokens(text: str) -> int:
24
+ """Rough token estimation (1 token ≈ 4 chars for English, 3 for CJK).
25
+
26
+ This is a heuristic, not a real tokenizer. Good enough for budget
27
+ planning without adding a dependency.
28
+ """
29
+ if not text:
30
+ return 0
31
+ # Count CJK characters (roughly 1 token each)
32
+ cjk = len(re.findall(r'[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]', text))
33
+ # Count non-CJK characters (roughly 4 chars per token)
34
+ non_cjk = len(text) - cjk
35
+ return cjk + (non_cjk // 4)
36
+
37
+
38
+ def build_context(engine: "BrainEngine", query: str, budget: int = 4000,
39
+ *, k: int = 5, include_metadata: bool = True) -> dict:
40
+ """Assemble a context window for a given query.
41
+
42
+ Returns a dict with:
43
+ - context: the assembled context string
44
+ - tokens: estimated token count
45
+ - nodes: list of included node ids
46
+ - budget: the budget that was used
47
+ - truncated: whether the context was truncated to fit the budget
48
+
49
+ The context is built hierarchically:
50
+ 1. Main context: top-k most relevant nodes (full text)
51
+ 2. Recall context: next tier (snippets only)
52
+ 3. Archival context: community summaries (if budget allows)
53
+ """
54
+ from .retrieval import retrieve
55
+
56
+ # Get candidates (more than k — we need tiers)
57
+ results = retrieve(engine, query, k=k * 3, track=False)
58
+ if not results:
59
+ return {
60
+ "context": "",
61
+ "tokens": 0,
62
+ "nodes": [],
63
+ "budget": budget,
64
+ "truncated": False,
65
+ }
66
+
67
+ # Tier 1: Main context (top-k, full text)
68
+ main_ids = [nid for nid, _ in results[:k]]
69
+ # Tier 2: Recall context (next k, snippets)
70
+ recall_ids = [nid for nid, _ in results[k:k * 2]]
71
+ # Tier 3: Archival context (community summaries)
72
+ archival_ids = [nid for nid, _ in results[k * 2:k * 3]]
73
+
74
+ context_parts: list[str] = []
75
+ included_nodes: list[str] = []
76
+ total_tokens = 0
77
+ truncated = False
78
+
79
+ # Helper to add a section if it fits the budget
80
+ def add_section(header: str, texts: list[str]) -> bool:
81
+ nonlocal total_tokens, truncated
82
+ section = f"## {header}\n\n" + "\n\n".join(texts)
83
+ section_tokens = estimate_tokens(section)
84
+ if total_tokens + section_tokens > budget:
85
+ truncated = True
86
+ return False
87
+ context_parts.append(section)
88
+ total_tokens += section_tokens
89
+ return True
90
+
91
+ # Tier 1: Main context (full text)
92
+ main_texts = []
93
+ for nid in main_ids:
94
+ node = engine.brain.read_node(nid)
95
+ if node is None:
96
+ continue
97
+ text = node.text
98
+ if include_metadata:
99
+ meta = f"[{node.ntype}, {node.status}]"
100
+ text = f"{meta} {text}"
101
+ main_texts.append(text)
102
+ included_nodes.append(nid)
103
+ if main_texts:
104
+ if not add_section("Main Context", main_texts):
105
+ return _assemble(context_parts, included_nodes, budget, truncated)
106
+
107
+ # Tier 2: Recall context (snippets)
108
+ recall_texts = []
109
+ for nid in recall_ids:
110
+ node = engine.brain.read_node(nid)
111
+ if node is None:
112
+ continue
113
+ # Snippet: first 200 chars
114
+ snippet = node.text[:200] + ("…" if len(node.text) > 200 else "")
115
+ recall_texts.append(snippet)
116
+ included_nodes.append(nid)
117
+ if recall_texts:
118
+ if not add_section("Recall Context", recall_texts):
119
+ return _assemble(context_parts, included_nodes, budget, truncated)
120
+
121
+ # Tier 3: Archival context (community summaries)
122
+ archival_texts = []
123
+ for nid in archival_ids:
124
+ node = engine.brain.read_node(nid)
125
+ if node is None:
126
+ continue
127
+ if "community-summary" in (node.tags or []):
128
+ archival_texts.append(node.text[:300])
129
+ included_nodes.append(nid)
130
+ if archival_texts:
131
+ add_section("Archival Context", archival_texts)
132
+
133
+ return _assemble(context_parts, included_nodes, budget, truncated)
134
+
135
+
136
+ def _assemble(parts: list[str], nodes: list[str], budget: int,
137
+ truncated: bool) -> dict:
138
+ """Assemble the final context string."""
139
+ context = "\n\n---\n\n".join(parts)
140
+ return {
141
+ "context": context,
142
+ "tokens": estimate_tokens(context),
143
+ "nodes": nodes,
144
+ "budget": budget,
145
+ "truncated": truncated,
146
+ }
147
+
148
+
149
+ def hierarchical_context(engine: "BrainEngine", query: str) -> dict:
150
+ """Build a hierarchical context (main → recall → archival).
151
+
152
+ This is a convenience wrapper around build_context with a default
153
+ budget of 4000 tokens. Returns the same dict as build_context.
154
+ """
155
+ return build_context(engine, query, budget=4000)
156
+
157
+
158
+ def context_for_prompt(engine: "BrainEngine", query: str,
159
+ max_tokens: int = 2000) -> str:
160
+ """Build a context string ready to be inserted into a prompt.
161
+
162
+ This is the simplest interface: query in, context string out.
163
+ """
164
+ result = build_context(engine, query, budget=max_tokens)
165
+ return result["context"]
166
+
167
+
168
+ def context_stats(engine: "BrainEngine", query: str, budget: int = 4000) -> dict:
169
+ """Statistics about what a context build would include.
170
+
171
+ Useful for debugging: shows how many nodes fit in each tier,
172
+ how many tokens each tier uses, etc.
173
+ """
174
+ from .retrieval import retrieve
175
+
176
+ results = retrieve(engine, query, k=15, track=False)
177
+ if not results:
178
+ return {"query": query, "budget": budget, "tiers": {}}
179
+
180
+ tiers = {
181
+ "main": {"nodes": [], "tokens": 0},
182
+ "recall": {"nodes": [], "tokens": 0},
183
+ "archival": {"nodes": [], "tokens": 0},
184
+ }
185
+
186
+ for i, (nid, _) in enumerate(results):
187
+ node = engine.brain.read_node(nid)
188
+ if node is None:
189
+ continue
190
+ if i < 5:
191
+ tier = "main"
192
+ text = node.text
193
+ elif i < 10:
194
+ tier = "recall"
195
+ text = node.text[:200]
196
+ else:
197
+ tier = "archival"
198
+ text = node.text[:300] if "community-summary" in (node.tags or []) else ""
199
+ if text:
200
+ tiers[tier]["nodes"].append(nid)
201
+ tiers[tier]["tokens"] += estimate_tokens(text)
202
+
203
+ return {"query": query, "budget": budget, "tiers": tiers}
graph_engine/decay.py ADDED
@@ -0,0 +1,65 @@
1
+ """Forgetting strategy (Roadmap V2#2).
2
+
3
+ Weibull decay on retrieval frequency instead of hard deletion + graceful
4
+ degradation ladder: record -> summary -> gist -> tombstone.
5
+
6
+ Pure functions — easy to test, no dependencies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ # Levels of graceful degradation, descending by retention.
14
+ DEGRADATION_LADDER = ("record", "summary", "gist", "tombstone")
15
+
16
+ # Default thresholds for level selection (score in [0,1]).
17
+ DEGRADATION_THRESHOLDS = (0.6, 0.4, 0.2)
18
+
19
+
20
+ def weibull_decay(age: float, scale: float = 30.0, shape: float = 1.5) -> float:
21
+ """Weibull survival: exp(-(age/scale)^shape) in (0,1].
22
+
23
+ Age in the same units as `scale` (e.g. days). As age increases the
24
+ value sinks toward 0 — a gentle forgetting, no hard cutoff.
25
+ """
26
+ if age <= 0:
27
+ return 1.0
28
+ return math.exp(-((age / scale) ** shape))
29
+
30
+
31
+ def retention_from_frequency(retrievals: int, freq_scale: float = 10.0) -> float:
32
+ """Retention share from retrieval frequency (saturating upward).
33
+
34
+ The more often an item is retrieved, the more of it is retained —
35
+ but it never decays to 0 with at least one retrieval.
36
+ """
37
+ if retrievals <= 0:
38
+ return 0.0
39
+ return 1.0 - math.exp(-retrievals / freq_scale)
40
+
41
+
42
+ def degradation_level(
43
+ score: float,
44
+ thresholds: tuple[float, float, float] = DEGRADATION_THRESHOLDS,
45
+ ) -> str:
46
+ """Maps a decay score to a level (high = stays, low = dies)."""
47
+ if score >= thresholds[0]:
48
+ return "record"
49
+ if score >= thresholds[1]:
50
+ return "summary"
51
+ if score >= thresholds[2]:
52
+ return "gist"
53
+ return "tombstone"
54
+
55
+
56
+ def decay_score(retrievals: int, age: float, freq_scale: float = 10.0,
57
+ age_scale: float = 30.0, shape: float = 1.5) -> float:
58
+ """Combines retrieval frequency (retains) with age (forgets) → [0,1]."""
59
+ return retention_from_frequency(retrievals, freq_scale) * weibull_decay(age, age_scale, shape)
60
+
61
+
62
+ def decay_level(retrievals: int, age: float, freq_scale: float = 10.0,
63
+ age_scale: float = 30.0, shape: float = 1.5) -> str:
64
+ """Graceful degradation level for an item with (retrievals, age)."""
65
+ return degradation_level(decay_score(retrievals, age, freq_scale, age_scale, shape))
graph_engine/demo.py ADDED
@@ -0,0 +1,165 @@
1
+ """graph_engine.demo — a small, generic seed brain for onboarding (`ig init --demo`).
2
+
3
+ 13 nodes over generic LLM/agent topics (no user-specific content), linked with
4
+ every edge type (similar, extends, contradicts, supersedes, same_as),
5
+ 2 pending edges for the HITL review flow, an orphan island to demo `ig status`,
6
+ and 1 near-dup pair (0.78-0.92 band) to demo `ig near-dup` + `ig merge`.
7
+
8
+ Everything is deterministic and public-safe: no private data, no personal
9
+ defaults, neutral English texts that avoid the intent-marker vocabulary
10
+ (the demo mirrors the marker-free finding style used by the self-evolving
11
+ pipeline). Edge KIND values are canonical English (`similar`, `extends`,
12
+ `contradicts`, `supersedes`, `continues`, `same_as`) — matching the
13
+ engine emission since the v0.5 kind migration.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import datetime as _dt
18
+
19
+ from .brain import Brain, Edge, Node
20
+
21
+ # (text, ntype, tags) — texts reuse each other's vocabulary so the edges are
22
+ # semantically honest, not arbitrary. Near-dup pair measured at cos 0.847.
23
+ _NODES: list[tuple[str, str, list[str]]] = [
24
+ # 0: RAG hub
25
+ ("Retrieval-Augmented Generation connects a language model to an external"
26
+ " knowledge source: for a query, the retriever fetches relevant passages"
27
+ " from a corpus, and the language model composes the answer grounded on"
28
+ " those passages. RAG complements parametric knowledge with addressable,"
29
+ " updatable knowledge and reduces hallucinations on knowledge-intensive"
30
+ " tasks.", "semantic", ["rag"]),
31
+ # 1: dense retrieval
32
+ ("Dense Retrieval computes dense vectors (embeddings) for queries and"
33
+ " documents and retrieves by cosine similarity. Bi-encoders encode query"
34
+ " and document independently, which allows approximate-nearest-neighbor"
35
+ " indexes over large corpora. Dense Retrieval is the fetch layer that"
36
+ " RAG relies on.", "semantic", ["retrieval"]),
37
+ # 2: BM25
38
+ ("BM25 is a lexical ranking function over inverted indexes: term"
39
+ " frequency, inverse document frequency, and length normalization weight"
40
+ " the matching terms. BM25 complements Dense Retrieval for exact term"
41
+ " matches such as names, identifiers, and code symbols, which vector"
42
+ " similarity can miss.", "semantic", ["retrieval", "bm25"]),
43
+ # 3: hybrid search (near-dup pair with 2)
44
+ ("Hybrid Search combines lexical ranking with Dense Retrieval via"
45
+ " Reciprocal Rank Fusion: each ranker produces a ranked list, and the"
46
+ " fusion merges them without a calibration step. Hybrid Search adds"
47
+ " exact term matching on top of vector similarity and serves as a fetch"
48
+ " layer for RAG.", "semantic", ["retrieval", "hybrid"]),
49
+ # 4: agent memory
50
+ ("Agent Memory stores an agent's experience across sessions in a"
51
+ " persistent store: episodic entries, semantic entries, and procedural"
52
+ " entries. Agent Memory relates to RAG, which reuses the same retrieval"
53
+ " infrastructure for recall.", "semantic", ["memory"]),
54
+ # 5: episodic memory
55
+ ("Episodic Memory records events with timestamp and context: sessions,"
56
+ " decisions, observations. It adds the temporal dimension to semantic"
57
+ " memory and enables consolidation: recurring episodes generalize into"
58
+ " semantic knowledge.", "episodic", ["memory", "episodic"]),
59
+ # 6: semantic memory
60
+ ("Semantic Memory holds generalized knowledge: concepts, facts, rules."
61
+ " It forms through consolidation of episodic entries and serves as the"
62
+ " long-term knowledge base of an agent.",
63
+ "semantic", ["memory", "semantic"]),
64
+ # 7: graph-RAG
65
+ ("Graph-RAG extends RAG with a knowledge graph: entities and relations"
66
+ " are extracted from the corpus, and the retriever traverses the graph"
67
+ " for multi-hop questions. Graph-RAG shows the limit of purely local"
68
+ " retrieval on questions that span several documents.",
69
+ "semantic", ["rag", "graph"]),
70
+ # 8: consolidation (ISLAND — 0 edges, demos `ig status`)
71
+ ("Knowledge graph consolidation merges semantically identical entities"
72
+ " and keeps the graph compact: near-duplicate detection via embedding"
73
+ " similarity, redirection of edges onto the survivor, with full"
74
+ " provenance preserved in the git history.", "semantic", ["hygiene"]),
75
+ # 9: hallucination
76
+ ("Hallucination denotes fluent-sounding, factually unsupported output of"
77
+ " language models. Grounding on retrieved passages reduces"
78
+ " hallucinations, and citing sources makes answers verifiable."
79
+ " Hallucination is the main motivation for RAG.",
80
+ "semantic", ["safety"]),
81
+ # 10: enterprise RAG
82
+ ("Enterprise RAG indexes internal documents such as wikis, manuals, and"
83
+ " tickets, and answers employee questions grounded on those documents"
84
+ " with citations. Enterprise RAG is the most common production use of"
85
+ " RAG.", "semantic", ["rag", "enterprise"]),
86
+ # 11: multi-hop (near-dup pair with 7 — measured cos 0.847 on the demo texts)
87
+ ("Multi-hop questions over a knowledge graph are answered by Graph-RAG"
88
+ " through traversal of extracted entities and relations: find entity A,"
89
+ " then follow the relation from A to B, then on to C. The Graph-RAG"
90
+ " approach traverses the graph for this; pure Dense Retrieval falls"
91
+ " short because a single passage rarely holds the full answer.",
92
+ "semantic", ["rag", "multi-hop"]),
93
+ # 12: same_as demo pair (EN twin of node 0) — demos `ig link` + same_as
94
+ ("Retrieval-Augmented Generation (RAG) verbindet ein Sprachmodell mit"
95
+ " einer externen Wissensquelle: der Retriever holt fuer eine Anfrage"
96
+ " relevante Passagen aus einem Korpus, und das Sprachmodell erzeugt die"
97
+ " Antwort grounding auf diesen Passagen.", "semantic", ["rag", "de"]),
98
+ ]
99
+
100
+ # (source_idx, target_idx, kind, pending, confidence)
101
+ _EDGES: list[tuple[int, int, str, bool, float | None]] = [
102
+ (1, 0, "extends", False, None),
103
+ (2, 0, "extends", False, None),
104
+ (2, 1, "extends", False, None), # BM25 complements dense retrieval
105
+ (3, 1, "extends", False, None), # hybrid search builds on dense retrieval
106
+ (3, 0, "extends", False, None),
107
+ (4, 0, "extends", False, None),
108
+ (9, 0, "extends", False, None),
109
+ (10, 0, "extends", False, None),
110
+ (11, 7, "extends", False, None),
111
+ (4, 5, "extends", False, None),
112
+ (4, 6, "extends", False, None),
113
+ (5, 6, "similar", False, None),
114
+ (7, 0, "supersedes", False, None), # intent demo: graph-RAG supersedes plain RAG
115
+ (9, 10, "contradicts", False, None), # intent demo (explicitly labeled demo pair)
116
+ (3, 2, "similar", False, 0.85), # near-dup demo pair (hybrid ≈ BM25)
117
+ (0, 12, "same_as", False, None), # EN/DE alias demo
118
+ (9, 12, "extends", False, None), # hallucination motivates the DE twin too
119
+ # 2 pending edges → the HITL review flow has something to review
120
+ (10, 4, "extends", True, 0.62),
121
+ (11, 3, "extends", True, 0.58),
122
+ ]
123
+
124
+
125
+ def _now() -> str:
126
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
127
+
128
+
129
+ def build_demo_brain(path: str, commit: bool = True) -> dict:
130
+ """Create a fresh brain at `path` and fill it with the demo graph.
131
+
132
+ Returns a small stats dict for the CLI to print. Aborts if the path
133
+ already contains a brain (never overwrites user data).
134
+ """
135
+ import os
136
+ if os.path.exists(path) and os.listdir(path):
137
+ raise FileExistsError(f"{path} already exists and is not empty.")
138
+ brain = Brain(path, mode="git")
139
+ brain.init(commit=False) # structure + git init, no commit yet
140
+
141
+ nodes = [Node(text=t, source="demo-seed", tags=tags, ntype=nt, status="active")
142
+ for t, nt, tags in _NODES]
143
+ for n in nodes:
144
+ brain.write_node(n)
145
+
146
+ for s, t, kind, pending, conf in _EDGES:
147
+ brain.add_edge(Edge(source=nodes[s].id, target=nodes[t].id,
148
+ kind=kind, pending=pending, confidence=conf,
149
+ origin="manual" if conf is None else "suggester"))
150
+
151
+ # Embedding cache for the demo nodes (so `ig search` works immediately).
152
+ # Route through get_embedder() so a light install (no [st] extra) degrades
153
+ # to the HashEmbedder instead of crashing the demo seed.
154
+ from .embedder import get_embedder
155
+ emb = get_embedder()
156
+ vectors = emb.embed_batch([n.text for n in nodes])
157
+ vectors = {n.id: v for n, v in zip(nodes, vectors)}
158
+ brain.write_vectors(vectors)
159
+
160
+ brain.rebuild_index()
161
+ if commit:
162
+ brain.commit_and_push("demo-seed: 13 nodes, 19 edges (example graph)",
163
+ push=False)
164
+ return {"nodes": len(nodes), "edges": len(_EDGES),
165
+ "pending": sum(1 for e in _EDGES if e[3]), "path": str(brain.path)}