graph-engine-memory 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graph_engine/__init__.py +1 -0
- graph_engine/__main__.py +1275 -0
- graph_engine/access.py +202 -0
- graph_engine/agent_memory.py +103 -0
- graph_engine/brain.py +731 -0
- graph_engine/brain_engine.py +559 -0
- graph_engine/communities.py +503 -0
- graph_engine/consolidation.py +191 -0
- graph_engine/context.py +203 -0
- graph_engine/decay.py +65 -0
- graph_engine/demo.py +165 -0
- graph_engine/dream.py +408 -0
- graph_engine/embedder.py +84 -0
- graph_engine/engine.py +58 -0
- graph_engine/entities.py +376 -0
- graph_engine/evals.py +1206 -0
- graph_engine/feedback.py +155 -0
- graph_engine/gaps.py +209 -0
- graph_engine/graph.py +133 -0
- graph_engine/hygiene.py +213 -0
- graph_engine/inference.py +251 -0
- graph_engine/intent.py +207 -0
- graph_engine/mcp/__init__.py +1 -0
- graph_engine/mcp/agent/instructions.md +34 -0
- graph_engine/mcp/format.py +66 -0
- graph_engine/mcp/server.py +407 -0
- graph_engine/merge.py +220 -0
- graph_engine/model.py +56 -0
- graph_engine/namespaces.py +74 -0
- graph_engine/recall.py +161 -0
- graph_engine/report.py +401 -0
- graph_engine/reranker.py +86 -0
- graph_engine/retrieval.py +296 -0
- graph_engine/review.py +95 -0
- graph_engine/runtime.py +83 -0
- graph_engine/server.py +239 -0
- graph_engine/similarity.py +42 -0
- graph_engine/store.py +63 -0
- graph_engine/suggester.py +63 -0
- graph_engine/temporal.py +216 -0
- graph_engine/tiers.py +135 -0
- graph_engine/web/app.js +578 -0
- graph_engine/web/index.html +169 -0
- graph_engine/web/report.html +39 -0
- graph_engine/web/report.js +50 -0
- graph_engine/web/review.html +104 -0
- graph_engine/web/review.js +211 -0
- graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
- graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
- graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
- graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
- graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
- graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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}
|
graph_engine/runtime.py
ADDED
|
@@ -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()
|