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,296 @@
1
+ """Hybrid retrieval: dense (cosine) + BM25, fused via Reciprocal Rank Fusion.
2
+
3
+ Roadmap V2#1: "first hybrid dense+BM25 with tuned fusion (highest ROI)".
4
+ BM25 is pure text statistics — no dependency, no model. The RRF fusion
5
+ combines both rankings robustly (independent of their scale) before a
6
+ cross-encoder reranking of the top-K is added later.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from dataclasses import dataclass, field
13
+
14
+ import numpy as np
15
+
16
+ from .brain_engine import BrainEngine
17
+ from .feedback import LearningProfile, learning_profile
18
+ from .tiers import order_by_tier
19
+
20
+
21
+ def tokenize(text: str) -> list[str]:
22
+ return text.lower().split()
23
+
24
+
25
+ class BM25:
26
+ """BM25 scorer. The index (df, doc_len, per-doc tf) is built ONCE in the
27
+ constructor — `scores()` only looks it up (Audit #10: the old version
28
+ re-tokenized the entire corpus twice per query, 0.225 s/query @2k docs)."""
29
+
30
+ def __init__(self, corpus: list[str], k1: float = 1.2, b: float = 0.75):
31
+ self.k1 = k1
32
+ self.b = b
33
+ self.N = len(corpus)
34
+ self.doc_len: list[int] = []
35
+ self.avgdl = sum(self.doc_len) / self.N if self.N else 0.0
36
+ self.df: dict[str, int] = {}
37
+ self._tf: list[dict[str, int]] = []
38
+ for doc in corpus:
39
+ tf: dict[str, int] = {}
40
+ for t in tokenize(doc):
41
+ tf[t] = tf.get(t, 0) + 1
42
+ self._tf.append(tf)
43
+ self.doc_len.append(sum(tf.values()))
44
+ for term in tf:
45
+ self.df[term] = self.df.get(term, 0) + 1
46
+ self.avgdl = sum(self.doc_len) / self.N if self.N else 0.0
47
+
48
+ def idf(self, term: str) -> float:
49
+ df = self.df.get(term, 0)
50
+ return math.log(1.0 + (self.N - df + 0.5) / (df + 0.5))
51
+
52
+ def scores(self, query_tokens: list[str]) -> list[float]:
53
+ """BM25 score per document (same order as corpus)."""
54
+ if self.N == 0:
55
+ return []
56
+ out: list[float] = []
57
+ for tf, dl in zip(self._tf, self.doc_len):
58
+ s = 0.0
59
+ for t in query_tokens:
60
+ f = tf.get(t, 0)
61
+ if f == 0:
62
+ continue
63
+ denom = f + self.k1 * (1.0 - self.b + self.b * dl / self.avgdl)
64
+ s += self.idf(t) * (f * (self.k1 + 1.0)) / denom
65
+ out.append(s)
66
+ return out
67
+
68
+
69
+ def rrf_fuse(ranked_lists: list[list[tuple[str, float]]], k: int = 60,
70
+ weights: list[float] | None = None) -> list[tuple[str, float]]:
71
+ """Reciprocal Rank Fusion: merges multiple (id, score) rankings into one.
72
+
73
+ Each ranking is sorted by score descending; each rank contributes
74
+ 1/(k + rank). k=60 is the usual RRF standard.
75
+ """
76
+ fused: dict[str, float] = {}
77
+ weights = weights if weights is not None else [1.0] * len(ranked_lists)
78
+ if len(weights) != len(ranked_lists) or any(not math.isfinite(w) or w < 0 for w in weights):
79
+ raise ValueError("one finite non-negative weight is required per ranking")
80
+ for rl, weight in zip(ranked_lists, weights):
81
+ ordered = sorted(rl, key=lambda x: x[1], reverse=True)
82
+ for rank, (nid, _) in enumerate(ordered):
83
+ fused[nid] = fused.get(nid, 0.0) + weight / (k + rank + 1)
84
+ return sorted(fused.items(), key=lambda x: x[1], reverse=True)
85
+
86
+
87
+ @dataclass
88
+ class _RetrievalTrace:
89
+ node_ids: list[str]
90
+ text_by_id: dict[str, str]
91
+ candidates: list[tuple[str, float]] = field(default_factory=list)
92
+ dense_scores: dict[str, float] = field(default_factory=dict)
93
+ bm25_scores: dict[str, float] = field(default_factory=dict)
94
+ dense_ranks: dict[str, int] = field(default_factory=dict)
95
+ bm25_ranks: dict[str, int] = field(default_factory=dict)
96
+ fused: list[tuple[str, float]] = field(default_factory=list)
97
+ reranker_scores: dict[str, float] = field(default_factory=dict)
98
+ tiers: dict[str, str] = field(default_factory=dict)
99
+ learning: LearningProfile | None = None
100
+ feedback_adjustments: dict[str, float] = field(default_factory=dict)
101
+
102
+
103
+ def _retrieve_trace(engine: BrainEngine, query: str, rerank_k: int,
104
+ persist: bool, *, learn: bool = True) -> _RetrievalTrace:
105
+ """One scoring path for normal retrieval and its explanation."""
106
+ nodes = engine.brain.read_nodes()
107
+ # Audit #7: tombstones are "forgotten" — they must not come back as
108
+ # search answers (consolidate/_find_duplicate already exclude them).
109
+ nodes = [n for n in nodes if n.status != "tombstone"]
110
+ trace = _RetrievalTrace([n.id for n in nodes], {n.id: n.text for n in nodes})
111
+ trace.tiers = {n.id: n.storage_tier for n in nodes}
112
+ trace.learning = learning_profile(engine.brain, query, nodes) if learn else LearningProfile(query)
113
+ query = trace.learning.expanded_query
114
+ if not nodes:
115
+ return trace
116
+ node_ids = [n.id for n in nodes]
117
+ qvec = engine.embedder.embed(query)
118
+
119
+ vecs = engine.brain.vectors_for(set(node_ids), lambda t: engine.embedder.embed(t),
120
+ persist=persist)
121
+ # Audit #8 (follow-up fix): vectors with a foreign dimension are not
122
+ # comparable with the query (different embedder in the same brain, e.g.
123
+ # demo seed with precomputed ST vectors + HashEmbedder engine). Instead of
124
+ # silently truncating (old cosine bug) they are skipped — the dense stage
125
+ # degrades, BM25 stays fully effective.
126
+ # Audit #60: the dense stage runs as a numpy matmul over L2-normalized
127
+ # vectors instead of a pure-Python cosine loop (0.150 s/query @2k×384-dim →
128
+ # sub-2 ms). Foreign-dimension/empty vectors remain excluded.
129
+ dense_ids = [nid for nid in node_ids
130
+ if nid in vecs and vecs[nid] and len(vecs[nid]) == len(qvec)]
131
+ if dense_ids:
132
+ M = np.array([vecs[nid] for nid in dense_ids], dtype=np.float64)
133
+ M = M / (np.linalg.norm(M, axis=1, keepdims=True) + 1e-12)
134
+ q = np.asarray(qvec, dtype=np.float64)
135
+ q = q / (np.linalg.norm(q) + 1e-12)
136
+ sims = M @ q
137
+ dense = [(nid, float(s)) for nid, s in zip(dense_ids, sims)]
138
+ else:
139
+ dense = []
140
+
141
+ bm = BM25([n.text for n in nodes])
142
+ bm_scores = bm.scores(tokenize(query))
143
+ bm_rank = [(nid, s) for nid, s in zip(node_ids, bm_scores) if s > 0.0]
144
+ trace.dense_scores = dict(dense)
145
+ trace.bm25_scores = dict(zip(node_ids, bm_scores))
146
+
147
+ # Audit #37: a nonsense query otherwise returned 5 "results" with
148
+ # RRF scores ≈ 0.033 — both ranks meaningless, but presented as a
149
+ # ranking. Nodes without any overlap (dense 0.0 AND BM25 0.0)
150
+ # are dropped before the fusion.
151
+ dense_ids = {nid for nid, s in dense if s > 0.0}
152
+ bm_ids = {nid for nid, s in bm_rank}
153
+ overlap = dense_ids | bm_ids
154
+ dense = [(nid, s) for nid, s in dense if nid in overlap]
155
+ bm_rank = [(nid, s) for nid, s in bm_rank if nid in overlap]
156
+ if not dense and not bm_rank:
157
+ return trace
158
+
159
+ trace.dense_ranks = {nid: rank for rank, (nid, _) in enumerate(
160
+ sorted(dense, key=lambda hit: hit[1], reverse=True), start=1)}
161
+ trace.bm25_ranks = {nid: rank for rank, (nid, _) in enumerate(
162
+ sorted(bm_rank, key=lambda hit: hit[1], reverse=True), start=1)}
163
+ trace.fused = rrf_fuse([dense, bm_rank], k=60,
164
+ weights=[trace.learning.dense_weight, trace.learning.bm25_weight])
165
+ trace.feedback_adjustments = {
166
+ nid: max(-score, trace.learning.adjustments.get(nid, 0.0))
167
+ for nid, score in trace.fused}
168
+ trace.fused = sorted([(nid, score + trace.feedback_adjustments[nid])
169
+ for nid, score in trace.fused], key=lambda hit: hit[1], reverse=True)
170
+ trace.fused = order_by_tier(trace.fused, trace.tiers)
171
+ trace.candidates = trace.fused[:rerank_k]
172
+ return trace
173
+
174
+
175
+ def retrieve_candidates(engine: BrainEngine, query: str, rerank_k: int = 30,
176
+ persist: bool = True) -> tuple[list[str], dict[str, str], list[tuple[str, float]]]:
177
+ """Shared candidate retrieval for the retrieve()/rerank path."""
178
+ trace = _retrieve_trace(engine, query, rerank_k, persist)
179
+ return trace.node_ids, trace.text_by_id, trace.candidates
180
+
181
+
182
+ def explain(engine: BrainEngine, node_id: str, query: str, *, k: int = 5,
183
+ rerank_k: int = 30) -> dict:
184
+ """Explain a fresh query against the current brain, without writing anything.
185
+
186
+ Graph edges are contextual evidence only: hybrid retrieval does not use
187
+ graph traversal. This is a recomputation, not a historical search log.
188
+ """
189
+ if not query.strip():
190
+ raise ValueError("query must be non-empty")
191
+ if k < 1 or rerank_k < 1:
192
+ raise ValueError("k and rerank_k must be positive")
193
+ node = engine.brain.read_node(node_id)
194
+ if node is None:
195
+ raise ValueError(f"No node with id {node_id!r}.")
196
+ if node.status == "tombstone":
197
+ raise ValueError(f"Node {node_id!r} is tombstoned and excluded from retrieval.")
198
+
199
+ trace = _retrieve_trace(engine, query, rerank_k, persist=False)
200
+ reranked = getattr(engine, "reranker", None) is not None
201
+ hits = (_rerank(engine, query, k, trace.text_by_id, trace.candidates,
202
+ reranker_scores=trace.reranker_scores, tiers=trace.tiers)
203
+ if reranked and trace.candidates else trace.candidates[:k])
204
+ final_ranks = {nid: rank for rank, (nid, _) in enumerate(hits, start=1)}
205
+ fused_ranks = {nid: rank for rank, (nid, _) in enumerate(trace.fused, start=1)}
206
+
207
+ def channel(scores: dict[str, float], ranks: dict[str, int], weight: float) -> dict:
208
+ rank = ranks.get(node_id)
209
+ return {"score": scores.get(node_id), "rank": rank,
210
+ "rrf_contribution": weight / (60 + rank) if rank is not None else 0.0}
211
+
212
+ dense = channel(trace.dense_scores, trace.dense_ranks, trace.learning.dense_weight)
213
+ bm25 = channel(trace.bm25_scores, trace.bm25_ranks, trace.learning.bm25_weight)
214
+ candidate = any(nid == node_id for nid, _ in trace.candidates)
215
+ retrieved = node_id in final_ranks
216
+ retrieval_score = dict(hits).get(node_id)
217
+ has_model_scores = bool(trace.reranker_scores)
218
+ reranker_score = trace.reranker_scores.get(node_id)
219
+ reason = ("returned" if retrieved else "outside_top_k" if candidate else
220
+ "outside_candidate_limit" if node_id in fused_ranks else "no_overlap")
221
+ # Only accepted, live connections to other returned hits are evidence.
222
+ hit_ids = set(final_ranks) - {node_id}
223
+ edges = [edge for edge in engine.brain.read_edges()
224
+ if not edge.pending and not edge.rejected and edge.is_current
225
+ and ((edge.source == node_id and edge.target in hit_ids)
226
+ or (edge.target == node_id and edge.source in hit_ids))]
227
+ return {
228
+ "query": query, "node_id": node_id, "retrieved": retrieved,
229
+ "storage_tier": trace.tiers.get(node_id),
230
+ "tier_priority": ["main", "recall", "archival"],
231
+ "learning": {"expanded_query": trace.learning.expanded_query,
232
+ "expansion_terms": trace.learning.expansion_terms,
233
+ "dense_weight": trace.learning.dense_weight,
234
+ "bm25_weight": trace.learning.bm25_weight,
235
+ "feedback_adjustment": trace.feedback_adjustments.get(node_id, 0.0)},
236
+ "reason": reason, "rank": final_ranks.get(node_id),
237
+ "k": k, "rerank_k": rerank_k, "candidate": candidate,
238
+ "dense": dense, "bm25": bm25,
239
+ "rrf": {"k": 60, "rank": fused_ranks.get(node_id),
240
+ "score": dict(trace.fused).get(node_id, 0.0)},
241
+ "retrieval_score": retrieval_score,
242
+ "reranker_score": reranker_score,
243
+ "final_score": (reranker_score if has_model_scores else retrieval_score)
244
+ if retrieved else None,
245
+ "final_score_kind": "reranker" if has_model_scores else "rrf",
246
+ "matched_terms": sorted(set(tokenize(query)) & set(tokenize(node.text))),
247
+ "graph_used_for_ranking": False,
248
+ "graph_context": [edge.to_dict() for edge in sorted(edges, key=lambda e: e.id)],
249
+ }
250
+
251
+
252
+ def retrieve(engine: BrainEngine, query: str, k: int = 5, rerank_k: int = 30,
253
+ persist: bool = True, track: bool = False) -> list[tuple[str, float]]:
254
+ """Hybrid retrieval over the brain. Returns top-k (node_id, rrf_score).
255
+
256
+ Dense: cosine of the query embedding against the cached node vectors.
257
+ BM25: lexical overlap against the node texts.
258
+ Fusion: RRF over the two rankings with optional learned weights and
259
+ explicit feedback adjustments. Matching tiers precede relevance: main,
260
+ recall, archival, including after reranking.
261
+ Rerank (V2#1, optional): hybrid yields top-`rerank_k` candidates; a
262
+ `reranker` set on the engine (cross-encoder or stub) re-sorts them to
263
+ top-`k`. Without a reranker (default) the behavior is identical.
264
+ track (recall tracking): append the returned ids to the local recall ledger
265
+ (`recall.record`). Off by default so read-only surfaces (MCP) stay
266
+ read-only; CLI/HTTP pass True. The ledger write is an append to a
267
+ gitignored file — it never dirties the brain repo.
268
+ """
269
+ trace = _retrieve_trace(engine, query, rerank_k, persist)
270
+ text_by_id, candidates = trace.text_by_id, trace.candidates
271
+ if not candidates:
272
+ return []
273
+
274
+ reranker = getattr(engine, "reranker", None)
275
+ result = candidates[:k] if reranker is None else _rerank(engine, query, k,
276
+ text_by_id, candidates, tiers=trace.tiers)
277
+ if track:
278
+ from .recall import record, tracking_enabled
279
+ if tracking_enabled():
280
+ record(engine.brain, query, [nid for nid, _ in result])
281
+ return result
282
+
283
+
284
+ def _rerank(engine: BrainEngine, query: str, k: int, text_by_id: dict[str, str],
285
+ candidates: list[tuple[str, float]], *,
286
+ reranker_scores: dict[str, float] | None = None,
287
+ tiers: dict[str, str] | None = None) -> list[tuple[str, float]]:
288
+ with_text = [(nid, text_by_id[nid], score) for nid, score in candidates]
289
+ with_scores = getattr(engine.reranker, "rerank_with_scores", None)
290
+ if with_scores is None:
291
+ reranked = engine.reranker.rerank(query, with_text, len(with_text))
292
+ else:
293
+ reranked, scores = with_scores(query, with_text, len(with_text))
294
+ if reranker_scores is not None:
295
+ reranker_scores.update(scores)
296
+ return order_by_tier([(nid, score) for nid, _, score in reranked], tiers or {})[:k]
graph_engine/review.py ADDED
@@ -0,0 +1,95 @@
1
+ """Pending-edge review policy: accept suggestions, but cap intent fan-out.
2
+
3
+ `ig pending` lists the suggestions the suggester left for review. Accepting all
4
+ of them is right for similarity edges (they carry a cosine confidence) and wrong
5
+ for intent edges:
6
+
7
+ - intent edges (`contradicts`/`supersedes`/`continues`) carry
8
+ `confidence=None`, so the 0.95 auto-accept band can never judge them;
9
+ - their only producer is the marker heuristic in `intent.py`, which
10
+ mass-fires in a homogeneous brain — measured live: 168 intent edges ever
11
+ created, 66 of them invalidated again (39 % false), one source holding 10
12
+ auto-accepted `contradicts`, and 26 false edges added in a single cycle;
13
+ - they are auto-accepted at birth, so `ig pending` never showed them and the
14
+ false positives stayed invisible until someone read the graph.
15
+
16
+ The dam: per source node at most `INTENT_AUTO_ACCEPT_MAX` intent edges may be
17
+ auto-accepted. Everything beyond that stays pending and waits for a human. It is
18
+ a bound, not a repair — the repair class is semantic (a marker heuristic cannot
19
+ read descriptive negation); the measurable contract lives in the ROADMAP_CASE
20
+ `roadmap-intent-fanout-cap` and is enforced in `brain_engine.ingest` at birth
21
+ plus here on the review path.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from collections import Counter
28
+
29
+ from .brain import Brain, Edge
30
+ from .intent import INTENT_KINDS
31
+
32
+ # How many intent edges one source node may auto-accept. Overridable per call
33
+ # (eval cases) and per deployment (env), never silently.
34
+ INTENT_AUTO_ACCEPT_MAX = 2
35
+ INTENT_AUTO_ACCEPT_MAX_ENV = "IG_INTENT_AUTO_ACCEPT_MAX"
36
+
37
+
38
+ def intent_auto_accept_max(env: dict[str, str] | None = None) -> int:
39
+ """Resolve the cap: per-call env > process env > `INTENT_AUTO_ACCEPT_MAX`."""
40
+ raw = (env or {}).get(INTENT_AUTO_ACCEPT_MAX_ENV,
41
+ os.environ.get(INTENT_AUTO_ACCEPT_MAX_ENV))
42
+ if raw is None or raw == "":
43
+ return INTENT_AUTO_ACCEPT_MAX
44
+ try:
45
+ value = int(raw)
46
+ except (TypeError, ValueError):
47
+ raise ValueError(
48
+ f"{INTENT_AUTO_ACCEPT_MAX_ENV} must be an integer, got: {raw!r}")
49
+ if value < 0:
50
+ raise ValueError(
51
+ f"{INTENT_AUTO_ACCEPT_MAX_ENV} must be >= 0, got: {value}")
52
+ return value
53
+
54
+
55
+ def is_live_intent(edge: Edge) -> bool:
56
+ """A currently valid, accepted intent edge (the thing the cap bounds)."""
57
+ return (edge.kind in INTENT_KINDS and not edge.pending
58
+ and not edge.rejected and edge.is_current)
59
+
60
+
61
+ def accept_pending(brain: Brain, *, max_intent_per_source: int | None = None,
62
+ dry_run: bool = False, env: dict[str, str] | None = None,
63
+ commit: bool = True) -> dict:
64
+ """Accept pending edges; hold intent edges beyond the per-source cap.
65
+
66
+ Non-intent pending edges are accepted (their confidence is the signal).
67
+ Intent pending edges are accepted only while the source's live intent count
68
+ stays under the cap; the rest are HELD (still pending) for `ig pending`.
69
+
70
+ Returns {"accepted": [ids], "held": [ids], "cap": int, "dry_run": bool}.
71
+ Deterministic: pending edges are processed in sorted-id order, so two runs
72
+ over the same state decide identically.
73
+ """
74
+ cap = (max_intent_per_source if max_intent_per_source is not None
75
+ else intent_auto_accept_max(env))
76
+ edges = brain.read_edges(include_rejected=True)
77
+ live_intent = Counter(e.source for e in edges if is_live_intent(e))
78
+ accepted: list[str] = []
79
+ held: list[str] = []
80
+ for edge in sorted((e for e in edges if e.pending and not e.rejected and not e.is_invalidated),
81
+ key=lambda e: e.id):
82
+ if edge.kind in INTENT_KINDS:
83
+ if live_intent[edge.source] >= cap:
84
+ held.append(edge.id)
85
+ continue
86
+ live_intent[edge.source] += 1
87
+ edge.pending = False
88
+ accepted.append(edge.id)
89
+ if accepted and not dry_run:
90
+ brain.write_edges(edges)
91
+ if commit:
92
+ brain.commit_and_push(
93
+ f"edge review: {len(accepted)} accepted, "
94
+ f"{len(held)} intent held for review")
95
+ return {"accepted": accepted, "held": held, "cap": cap, "dry_run": dry_run}
@@ -0,0 +1,83 @@
1
+ """Shared brain/engine construction for the CLI, the FastAPI server and (future)
2
+ process-shaped surfaces such as the MCP server.
3
+
4
+ Before this module the CLI (`__main__.py`) and the server (`server.py`) each
5
+ rolled their own Brain/BrainEngine construction — same env contract, two
6
+ copies that could drift (the server adds a process-wide engine cache, the
7
+ CLI does not). One factory, one source of truth.
8
+
9
+ Env contract (identical for every consumer):
10
+ IG_BRAIN_PATH — brain location (default ~/graph-engine-brain, ~ expanded,
11
+ also for explicitly set values — Audit #33)
12
+ IG_BRAIN_REMOTE — clone URL for FIRST setup only (no personal default)
13
+ IG_BRAIN_MODE — "git" (real repo) or "local" (FS only, tests)
14
+ IDEAGRAPH_EMBEDDER — "st" (sentence-transformers) or "hash"
15
+ IDEAGRAPH_EMBEDDER_MODEL — model override (Audit #60)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+
22
+ from .brain import Brain
23
+ from . import access, namespaces
24
+ from .brain_engine import BrainEngine
25
+ from .embedder import get_embedder
26
+
27
+ # Audit #16: building a fresh engine per request re-instantiates the
28
+ # sentence-transformers model per request (seconds of CPU, memory churn).
29
+ # A process-wide cache shares the model + vector cache; write consistency
30
+ # is provided by the Brain instance lock (all RMW mutations run under
31
+ # brain._lock / BRAIN_LOCK). The cache is keyed on (brain path, embedder):
32
+ # a changed env (tests, multi-brain setups) correctly gets a fresh engine
33
+ # instead of the foreign instance.
34
+ _ENGINES: dict[tuple[str, str], BrainEngine] = {}
35
+
36
+
37
+ def brain_path() -> str:
38
+ """Resolved brain path from the env (~ expanded, Audit #33)."""
39
+ return _brain_path(namespaces.selected())
40
+
41
+
42
+ def _brain_path(graph_name: str) -> str:
43
+ """Use the same selection snapshot for the path and its permissions."""
44
+ if namespaces.home() is not None:
45
+ return str(namespaces.graph_path(graph_name))
46
+ return os.path.expanduser(
47
+ os.environ.get("IG_BRAIN_PATH", os.path.expanduser("~/graph-engine-brain")))
48
+
49
+
50
+ def make_brain() -> Brain:
51
+ """A Brain from the env contract. No personal defaults."""
52
+ graph_name = namespaces.selected()
53
+ cls = access.SecuredBrain if access.policy_path() else Brain
54
+ kwargs = {"graph_name": graph_name, "identity": access.principal()} if access.policy_path() else {}
55
+ return cls(
56
+ **kwargs,
57
+ path=_brain_path(graph_name),
58
+ remote=(os.environ.get("IG_BRAIN_REMOTE", "") or None) if namespaces.home() is None else None,
59
+ mode=os.environ.get("IG_BRAIN_MODE", "git"),
60
+ )
61
+
62
+
63
+ def make_engine() -> BrainEngine:
64
+ """A BrainEngine from the env contract, process-wide cached."""
65
+ brain = make_brain()
66
+ if access.policy_path():
67
+ # Never cache identities/policies across requests or policy revocation.
68
+ return BrainEngine(brain, get_embedder(
69
+ os.environ.get("IDEAGRAPH_EMBEDDER", "st"),
70
+ os.environ.get("IDEAGRAPH_EMBEDDER_MODEL")))
71
+ embedder_name = os.environ.get("IDEAGRAPH_EMBEDDER", "st")
72
+ key = (str(brain.path), embedder_name)
73
+ eng = _ENGINES.get(key)
74
+ if eng is None:
75
+ model = os.environ.get("IDEAGRAPH_EMBEDDER_MODEL") # audit #60
76
+ eng = BrainEngine(brain, get_embedder(embedder_name, model))
77
+ _ENGINES[key] = eng
78
+ return eng
79
+
80
+
81
+ def reset_engine_cache() -> None:
82
+ """Drop the process-wide engine cache (tests, env changes)."""
83
+ _ENGINES.clear()