graph-engine-memory 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graph_engine/__init__.py +1 -0
- graph_engine/__main__.py +1275 -0
- graph_engine/access.py +202 -0
- graph_engine/agent_memory.py +103 -0
- graph_engine/brain.py +731 -0
- graph_engine/brain_engine.py +559 -0
- graph_engine/communities.py +503 -0
- graph_engine/consolidation.py +191 -0
- graph_engine/context.py +203 -0
- graph_engine/decay.py +65 -0
- graph_engine/demo.py +165 -0
- graph_engine/dream.py +408 -0
- graph_engine/embedder.py +84 -0
- graph_engine/engine.py +58 -0
- graph_engine/entities.py +376 -0
- graph_engine/evals.py +1206 -0
- graph_engine/feedback.py +155 -0
- graph_engine/gaps.py +209 -0
- graph_engine/graph.py +133 -0
- graph_engine/hygiene.py +213 -0
- graph_engine/inference.py +251 -0
- graph_engine/intent.py +207 -0
- graph_engine/mcp/__init__.py +1 -0
- graph_engine/mcp/agent/instructions.md +34 -0
- graph_engine/mcp/format.py +66 -0
- graph_engine/mcp/server.py +407 -0
- graph_engine/merge.py +220 -0
- graph_engine/model.py +56 -0
- graph_engine/namespaces.py +74 -0
- graph_engine/recall.py +161 -0
- graph_engine/report.py +401 -0
- graph_engine/reranker.py +86 -0
- graph_engine/retrieval.py +296 -0
- graph_engine/review.py +95 -0
- graph_engine/runtime.py +83 -0
- graph_engine/server.py +239 -0
- graph_engine/similarity.py +42 -0
- graph_engine/store.py +63 -0
- graph_engine/suggester.py +63 -0
- graph_engine/temporal.py +216 -0
- graph_engine/tiers.py +135 -0
- graph_engine/web/app.js +578 -0
- graph_engine/web/index.html +169 -0
- graph_engine/web/report.html +39 -0
- graph_engine/web/report.js +50 -0
- graph_engine/web/review.html +104 -0
- graph_engine/web/review.js +211 -0
- graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
- graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
- graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
- graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
- graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
- graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
graph_engine/report.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Generated state-of-the-brain digest (BRAIN_REPORT).
|
|
2
|
+
|
|
3
|
+
One page answering: what changed since last time, what needs a human
|
|
4
|
+
decision, and where is the graph structurally thin. Pure read-only over
|
|
5
|
+
nodes/*.md frontmatter, edges.jsonl and the precomputed vectors.jsonl —
|
|
6
|
+
nothing here writes to the brain, embeds, or calls `ig search` (that
|
|
7
|
+
would re-embed; the report must stay cheap and side-effect free).
|
|
8
|
+
|
|
9
|
+
Deliberately NOT regenerated on ingest: INDEX.md already rides the
|
|
10
|
+
ingest RMW chain, and the coverage pass alone costs ~5.8 s on a live
|
|
11
|
+
brain. Staleness is stated explicitly in the header (head_sha +
|
|
12
|
+
commits_behind) instead of hidden.
|
|
13
|
+
|
|
14
|
+
Sections deliberately cut (measured on the live brain): a status
|
|
15
|
+
distribution section (a constant: everything probation), a pending
|
|
16
|
+
inbox (always empty — the cycle auto-accepts), and a coverage section
|
|
17
|
+
(saturated taxonomy, +5.8 s — kept behind the opt-in `--coverage`).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
from collections import Counter
|
|
25
|
+
from datetime import datetime, timedelta, timezone
|
|
26
|
+
|
|
27
|
+
from .brain import Brain, _atomic_write
|
|
28
|
+
from .hygiene import connectivity, near_dup_pairs, status_counts
|
|
29
|
+
|
|
30
|
+
# Confidence bands (suggester.py): <0.45 no edge, 0.45-0.75 extends,
|
|
31
|
+
# 0.75-0.95 similar, >=0.95 auto-accept. The report mirrors these bands.
|
|
32
|
+
BANDS = ((0.45, "none"), (0.75, "low"), (0.95, "mid"), (float("inf"), "auto"))
|
|
33
|
+
INTENT_KINDS = ("contradicts", "supersedes")
|
|
34
|
+
DEFAULT_SINCE_HOURS = 24
|
|
35
|
+
DEFAULT_TOP = 10
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Escaping helpers
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def _md_cell(text: str, limit: int = 60) -> str:
|
|
43
|
+
"""Table cell: truncate FIRST, then escape pipes (Audit #55 order)."""
|
|
44
|
+
text = " ".join((text or "").split())
|
|
45
|
+
text = text[:limit]
|
|
46
|
+
return text.replace("|", "\\|")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _short(text: str, limit: int = 72) -> str:
|
|
50
|
+
text = " ".join((text or "").split())
|
|
51
|
+
return text[:limit] + ("…" if len(text) > limit else "")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Git provenance (read-only)
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def _git(brain: Brain, *args: str) -> str | None:
|
|
59
|
+
try:
|
|
60
|
+
r = subprocess.run(["git", "-C", brain.path, *args],
|
|
61
|
+
capture_output=True, text=True, timeout=10)
|
|
62
|
+
if r.returncode != 0:
|
|
63
|
+
return None
|
|
64
|
+
return r.stdout.strip()
|
|
65
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _provenance(brain: Brain) -> dict:
|
|
70
|
+
if not brain.can_access("admin"):
|
|
71
|
+
return {"head_sha": None, "dirty_files": 0}
|
|
72
|
+
sha = _git(brain, "rev-parse", "--short", "HEAD")
|
|
73
|
+
behind = 0
|
|
74
|
+
if sha:
|
|
75
|
+
n = _git(brain, "rev-list", "--count", f"{sha}..HEAD")
|
|
76
|
+
# rev-list sha..HEAD is 0 by construction; the meaningful number is
|
|
77
|
+
# how far the REMOTE has moved — but a read-only report must not
|
|
78
|
+
# fetch. Report the local working-tree dirtiness instead: the
|
|
79
|
+
# number of uncommitted changes signals "INDEX.md moved, report
|
|
80
|
+
# may be stale" without any network call.
|
|
81
|
+
dirty = _git(brain, "status", "--porcelain")
|
|
82
|
+
behind = len([l for l in (dirty or "").splitlines() if l.strip()])
|
|
83
|
+
return {"head_sha": sha, "dirty_files": behind}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Data assembly
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def _parse_since(opts: dict) -> datetime:
|
|
91
|
+
since = opts.get("since")
|
|
92
|
+
if since is None:
|
|
93
|
+
return datetime.now(timezone.utc) - timedelta(hours=DEFAULT_SINCE_HOURS)
|
|
94
|
+
if isinstance(since, (int, float)):
|
|
95
|
+
return datetime.now(timezone.utc) - timedelta(hours=float(since))
|
|
96
|
+
text = str(since).strip()
|
|
97
|
+
try:
|
|
98
|
+
return datetime.now(timezone.utc) - timedelta(hours=float(text))
|
|
99
|
+
except ValueError:
|
|
100
|
+
pass
|
|
101
|
+
iso = text.replace("Z", "+00:00")
|
|
102
|
+
try:
|
|
103
|
+
dt = datetime.fromisoformat(iso)
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"--since expects hours (e.g. 24) or ISO datetime, got: {text!r}"
|
|
107
|
+
) from exc
|
|
108
|
+
if dt.tzinfo is None:
|
|
109
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
110
|
+
return dt
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _band(conf: float | None) -> str:
|
|
114
|
+
if conf is None:
|
|
115
|
+
return "none"
|
|
116
|
+
for upper, name in BANDS:
|
|
117
|
+
if conf < upper:
|
|
118
|
+
return name
|
|
119
|
+
return "auto"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _load_structural(brain: Brain) -> list[dict]:
|
|
123
|
+
"""Structural-gap provider seam (report #7 §3.6).
|
|
124
|
+
|
|
125
|
+
The report renders structural findings, it never computes them:
|
|
126
|
+
provider is `graph_engine.communities.structural_gaps(brain)` when
|
|
127
|
+
importable, else a JSON file at $IG_STRUCTURAL_PATH. Returns []
|
|
128
|
+
when absent — the section is then omitted entirely, never empty.
|
|
129
|
+
Contract: list of {"kind": "community"|"god"|"hole", "label": str,
|
|
130
|
+
"node_ids": list[str], "score": float, "hint": str}.
|
|
131
|
+
"""
|
|
132
|
+
rows: list = []
|
|
133
|
+
try:
|
|
134
|
+
from .communities import structural_gaps # type: ignore
|
|
135
|
+
got = structural_gaps(brain)
|
|
136
|
+
if isinstance(got, list) and got:
|
|
137
|
+
return got
|
|
138
|
+
except ImportError:
|
|
139
|
+
pass
|
|
140
|
+
# Importable provider absent or empty (e.g. no community qualifies at
|
|
141
|
+
# min_size on a small brain) → fall back to the JSON file seam.
|
|
142
|
+
if hasattr(brain, "identity"):
|
|
143
|
+
return [] # externally precomputed findings have no graph/ACL provenance
|
|
144
|
+
path = os.environ.get("IG_STRUCTURAL_PATH", "")
|
|
145
|
+
if path and os.path.exists(path):
|
|
146
|
+
try:
|
|
147
|
+
got = json.loads(open(path, encoding="utf-8").read())
|
|
148
|
+
if isinstance(got, list):
|
|
149
|
+
rows = got
|
|
150
|
+
except (OSError, ValueError):
|
|
151
|
+
pass
|
|
152
|
+
return rows
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def report_data(brain: Brain, **opts) -> dict:
|
|
156
|
+
"""Machine-readable report payload (--json and GET /api/report)."""
|
|
157
|
+
nodes = [n for n in brain.read_nodes() if n.status != "tombstone"]
|
|
158
|
+
edges = [e for e in brain.read_edges() if e.is_current]
|
|
159
|
+
id2node = {n.id: n for n in nodes}
|
|
160
|
+
since = _parse_since(opts)
|
|
161
|
+
top = max(int(opts.get("top") or DEFAULT_TOP), 0)
|
|
162
|
+
|
|
163
|
+
# --- deltas since the window start
|
|
164
|
+
new_nodes = [n for n in nodes if _ts(n.created) >= since]
|
|
165
|
+
new_edges = [e for e in edges if _ts(e.valid_from) >= since]
|
|
166
|
+
new_intent = [e for e in new_edges if e.kind in INTENT_KINDS]
|
|
167
|
+
new_tombstones = [n for n in brain.read_nodes()
|
|
168
|
+
if n.status == "tombstone" and _ts(n.created) >= since]
|
|
169
|
+
|
|
170
|
+
# --- edge kinds + confidence bands
|
|
171
|
+
kinds = Counter(e.kind for e in edges)
|
|
172
|
+
bands = Counter(_band(e.confidence) for e in edges)
|
|
173
|
+
only_extends = 0
|
|
174
|
+
node_kinds: dict[str, set[str]] = {}
|
|
175
|
+
for e in edges:
|
|
176
|
+
for nid in (e.source, e.target):
|
|
177
|
+
node_kinds.setdefault(nid, set()).add(e.kind)
|
|
178
|
+
for nid, ks in node_kinds.items():
|
|
179
|
+
if ks == {"extends"}:
|
|
180
|
+
only_extends += 1
|
|
181
|
+
|
|
182
|
+
# --- intent review queue: grouped by source, fan-out desc
|
|
183
|
+
by_source: Counter = Counter()
|
|
184
|
+
pending_by_source: Counter = Counter()
|
|
185
|
+
kinds_by_source: dict[str, set[str]] = {}
|
|
186
|
+
for e in edges:
|
|
187
|
+
if e.kind in INTENT_KINDS:
|
|
188
|
+
by_source[e.source] += 1
|
|
189
|
+
kinds_by_source.setdefault(e.source, set()).add(e.kind)
|
|
190
|
+
if e.pending:
|
|
191
|
+
pending_by_source[e.source] += 1
|
|
192
|
+
intent_queue = []
|
|
193
|
+
for sid, fan in by_source.most_common(top):
|
|
194
|
+
node = id2node.get(sid)
|
|
195
|
+
ks = kinds_by_source.get(sid, set())
|
|
196
|
+
intent_queue.append({
|
|
197
|
+
"source": sid,
|
|
198
|
+
"fan_out": fan,
|
|
199
|
+
"pending": pending_by_source.get(sid, 0),
|
|
200
|
+
"kind": "mixed" if len(ks) > 1 else next(iter(ks)),
|
|
201
|
+
"title": _short(node.text) if node else "?",
|
|
202
|
+
"warn": fan >= 3,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
# --- hubs + degree percentiles (same live-edge rule as hygiene)
|
|
206
|
+
deg: Counter = Counter()
|
|
207
|
+
for e in edges:
|
|
208
|
+
deg[e.source] += 1
|
|
209
|
+
deg[e.target] += 1
|
|
210
|
+
hubs = []
|
|
211
|
+
for nid, d in sorted(deg.items(), key=lambda kv: (-kv[1], kv[0]))[:top]:
|
|
212
|
+
node = id2node.get(nid)
|
|
213
|
+
hubs.append({"id": nid, "degree": d,
|
|
214
|
+
"title": _short(node.text) if node else "?"})
|
|
215
|
+
degs = sorted(deg.get(n.id, 0) for n in nodes)
|
|
216
|
+
percentiles = _pct(degs)
|
|
217
|
+
|
|
218
|
+
# --- structural gaps (provider seam; omitted when absent)
|
|
219
|
+
# 'god' rows duplicate the Hubs section — render hole/community only.
|
|
220
|
+
structural = [r for r in _load_structural(brain)
|
|
221
|
+
if r.get("kind") in ("hole", "community")][:top] if top else []
|
|
222
|
+
|
|
223
|
+
# --- hygiene pointer (REUSED, not reimplemented)
|
|
224
|
+
conn = connectivity(brain)
|
|
225
|
+
nd = near_dup_pairs(brain, max_pairs=1)
|
|
226
|
+
near_dup_total = len(near_dup_pairs(brain, max_pairs=10_000)) \
|
|
227
|
+
if opts.get("full_near_dup") else None
|
|
228
|
+
statuses = status_counts(brain)
|
|
229
|
+
|
|
230
|
+
# --- research next: derived from the queue and structural rows
|
|
231
|
+
research = []
|
|
232
|
+
for q in intent_queue[:3]:
|
|
233
|
+
research.append(f"resolve {q['fan_out']}x {q['kind']} from "
|
|
234
|
+
f"{q['source']} — {q['title']}")
|
|
235
|
+
for row in structural[:2]:
|
|
236
|
+
research.append(row.get("hint") or f"bridge {row.get('label', 'gap')}")
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
"generated_at": _now_iso(),
|
|
240
|
+
"brain": str(brain.path) if brain.can_access("admin") else brain.path.name,
|
|
241
|
+
"head_sha": _provenance(brain)["head_sha"],
|
|
242
|
+
"dirty_files": _provenance(brain)["dirty_files"],
|
|
243
|
+
"nodes": len(nodes),
|
|
244
|
+
"edges": len(edges),
|
|
245
|
+
"since": since.isoformat(),
|
|
246
|
+
"deltas": {
|
|
247
|
+
"nodes": len(new_nodes),
|
|
248
|
+
"edges": len(new_edges),
|
|
249
|
+
"intent_edges": len(new_intent),
|
|
250
|
+
"tombstones": len(new_tombstones),
|
|
251
|
+
},
|
|
252
|
+
"kinds": dict(kinds),
|
|
253
|
+
"confidence_bands": dict(bands),
|
|
254
|
+
"extends_only_nodes": only_extends,
|
|
255
|
+
"intent_queue": intent_queue,
|
|
256
|
+
"hubs": hubs,
|
|
257
|
+
"degree_percentiles": percentiles,
|
|
258
|
+
"structural": structural,
|
|
259
|
+
"hygiene": {
|
|
260
|
+
"orphans": len(conn.orphans),
|
|
261
|
+
"islands": len(conn.islands),
|
|
262
|
+
"weak": len(conn.weak),
|
|
263
|
+
"near_dup_pairs": near_dup_total,
|
|
264
|
+
"statuses": dict(statuses),
|
|
265
|
+
},
|
|
266
|
+
"research_next": research[:5],
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _now_iso() -> str:
|
|
271
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _ts(value: str | None) -> datetime:
|
|
275
|
+
if not value:
|
|
276
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
277
|
+
try:
|
|
278
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
279
|
+
except ValueError:
|
|
280
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _pct(sorted_deg: list[int]) -> dict:
|
|
284
|
+
if not sorted_deg:
|
|
285
|
+
return {"p50": 0, "p90": 0, "p99": 0}
|
|
286
|
+
def at(q: float) -> int:
|
|
287
|
+
idx = min(int(q * len(sorted_deg)), len(sorted_deg) - 1)
|
|
288
|
+
return sorted_deg[idx]
|
|
289
|
+
return {"p50": at(0.50), "p90": at(0.90), "p99": at(0.99)}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# Rendering
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def render_report(brain: Brain, **opts) -> str:
|
|
297
|
+
data = report_data(brain, **opts)
|
|
298
|
+
if opts.get("coverage"):
|
|
299
|
+
from .gaps import analyze_coverage, find_gaps
|
|
300
|
+
cov = analyze_coverage(brain)
|
|
301
|
+
gaps = find_gaps(cov, int(opts.get("min_coverage") or 10))
|
|
302
|
+
data["coverage"] = {"areas": {a.name: a.count for a in cov.areas},
|
|
303
|
+
"unclassified": cov.unclassified,
|
|
304
|
+
"gaps": gaps}
|
|
305
|
+
lines: list[str] = []
|
|
306
|
+
lines.append("# BRAIN_REPORT")
|
|
307
|
+
lines.append("")
|
|
308
|
+
lines.append(f"generated: {data['generated_at']} · head {data['head_sha'] or 'n/a'}"
|
|
309
|
+
f" · dirty files: {data['dirty_files']}"
|
|
310
|
+
f" (report may lag INDEX.md — regenerate with `ig report --write`)")
|
|
311
|
+
lines.append(f"brain: {data['brain']} · {data['nodes']} nodes / {data['edges']} live edges")
|
|
312
|
+
lines.append("")
|
|
313
|
+
d = data["deltas"]
|
|
314
|
+
lines.append(f"## Since {data['since'][:19].replace('T', ' ')}")
|
|
315
|
+
lines.append(f"nodes +{d['nodes']} · edges +{d['edges']} · "
|
|
316
|
+
f"intent edges +{d['intent_edges']} · tombstones +{d['tombstones']}")
|
|
317
|
+
lines.append("")
|
|
318
|
+
lines.append("## Edge mix")
|
|
319
|
+
kinds = " · ".join(f"{k} {v}" for k, v in
|
|
320
|
+
sorted(data["kinds"].items(), key=lambda kv: -kv[1]))
|
|
321
|
+
bands = " · ".join(f"{k} {v}" for k, v in
|
|
322
|
+
sorted(data["confidence_bands"].items()))
|
|
323
|
+
lines.append(f"kinds: {kinds}")
|
|
324
|
+
lines.append(f"confidence: {bands}")
|
|
325
|
+
share = (100 * data["extends_only_nodes"] / data["nodes"]) if data["nodes"] else 0
|
|
326
|
+
lines.append(f"{data['extends_only_nodes']}/{data['nodes']} nodes "
|
|
327
|
+
f"({share:.0f}%) hang on `extends` ONLY — the graph is one "
|
|
328
|
+
"weak edge kind deep")
|
|
329
|
+
lines.append("")
|
|
330
|
+
lines.append("## Intent review queue")
|
|
331
|
+
if data["intent_queue"]:
|
|
332
|
+
lines.append("fan-out by source (contradicts/supersedes, confidence=None — "
|
|
333
|
+
"fan-out is the false-positive signal; `pending` = held back "
|
|
334
|
+
"for `ig pending` by the intent cap):")
|
|
335
|
+
for q in data["intent_queue"]:
|
|
336
|
+
warn = " ⚠ HIGH FAN-OUT" if q["warn"] else ""
|
|
337
|
+
held = f" · {q['pending']} pending" if q.get("pending") else ""
|
|
338
|
+
lines.append(f"{q['fan_out']}x [{q['kind']}] {q['source']} "
|
|
339
|
+
f"\"{q['title']}\"{held}{warn}")
|
|
340
|
+
else:
|
|
341
|
+
lines.append("no intent edges — nothing to review")
|
|
342
|
+
lines.append("")
|
|
343
|
+
lines.append("## Hubs")
|
|
344
|
+
p = data["degree_percentiles"]
|
|
345
|
+
lines.append(f"degree p50 {p['p50']} / p90 {p['p90']} / p99 {p['p99']}")
|
|
346
|
+
for h in data["hubs"][:5]:
|
|
347
|
+
lines.append(f"{h['degree']:>4} {h['id']} {h['title']}")
|
|
348
|
+
if data.get("structural"):
|
|
349
|
+
lines.append("")
|
|
350
|
+
lines.append("## Structural gaps")
|
|
351
|
+
for row in data["structural"]:
|
|
352
|
+
lines.append(f"[{row.get('kind', 'hole')}] {row.get('label', '?')} "
|
|
353
|
+
f"score {row.get('score', 0)} — {row.get('hint', '')}")
|
|
354
|
+
lines.append("")
|
|
355
|
+
lines.append("## Hygiene")
|
|
356
|
+
h = data["hygiene"]
|
|
357
|
+
statuses = h["statuses"]
|
|
358
|
+
prob = statuses.get("probation", 0)
|
|
359
|
+
act = statuses.get("active", 0)
|
|
360
|
+
stale = statuses.get("stale", 0)
|
|
361
|
+
nd = h.get("near_dup_pairs")
|
|
362
|
+
nd_txt = f", {nd} near-dup pairs" if nd is not None else ""
|
|
363
|
+
lines.append(f"orphans {h['orphans']} / islands {h['islands']} / "
|
|
364
|
+
f"weak {h['weak']}{nd_txt} → ig status / ig near-dup")
|
|
365
|
+
lines.append(f"{prob} probation / {act} active / {stale} stale — the status "
|
|
366
|
+
"lifecycle is driven by `ig dream --lifecycle`: used + connected "
|
|
367
|
+
"promotes, unused + weakly connected + old demotes to stale "
|
|
368
|
+
"(a demotion, never a deletion)")
|
|
369
|
+
if data.get("coverage"):
|
|
370
|
+
c = data["coverage"]
|
|
371
|
+
lines.append("")
|
|
372
|
+
lines.append("## Coverage (opt-in)")
|
|
373
|
+
top_areas = sorted(c["areas"].items(), key=lambda kv: -kv[1])[:5]
|
|
374
|
+
lines.append(" · ".join(f"{k} {v}" for k, v in top_areas)
|
|
375
|
+
+ f" · {c['unclassified']} unclassified"
|
|
376
|
+
+ (f" · gaps: {', '.join(c['gaps'])}" if c["gaps"]
|
|
377
|
+
else " · no gaps"))
|
|
378
|
+
lines.append("")
|
|
379
|
+
lines.append("## Research next")
|
|
380
|
+
if data["research_next"]:
|
|
381
|
+
for i, r in enumerate(data["research_next"], 1):
|
|
382
|
+
lines.append(f"{i}. {r}")
|
|
383
|
+
else:
|
|
384
|
+
lines.append("nothing queued — ingest new material")
|
|
385
|
+
return "\n".join(lines)
|
|
386
|
+
|
|
387
|
+
def write_report(brain: Brain, **opts) -> str:
|
|
388
|
+
"""Write BRAIN_REPORT.md at the brain root (tracked, renders on GitHub).
|
|
389
|
+
|
|
390
|
+
Returns the rendered markdown. The caller decides whether to commit —
|
|
391
|
+
in a cron cycle the write rides the cycle's commit (one commit per
|
|
392
|
+
generation, report #7 §5); from the CLI pass commit=True.
|
|
393
|
+
"""
|
|
394
|
+
brain.authorize("admin")
|
|
395
|
+
md = render_report(brain, **opts)
|
|
396
|
+
if not md.strip():
|
|
397
|
+
# A blank generated artifact destroys the workflow value (the
|
|
398
|
+
# Graphify GRAPH_REPORT.md lesson) — fail loudly, never write empty.
|
|
399
|
+
raise RuntimeError("render_report produced an empty report; refusing to write BRAIN_REPORT.md")
|
|
400
|
+
_atomic_write(brain.path / "BRAIN_REPORT.md", md + "\n")
|
|
401
|
+
return md
|
graph_engine/reranker.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Cross-encoder reranking (roadmap V2#1) — optional second retrieval pass.
|
|
2
|
+
|
|
3
|
+
Hybrid (dense+BM25, RRF) delivers the top-K candidates. A cross-encoder
|
|
4
|
+
scores each (query, candidate) pair JOINTLY — instead of cosine on separate
|
|
5
|
+
embeddings — and re-sorts the top-K down to the top-N. Per the roadmap this
|
|
6
|
+
is the single largest measured add-on (minus one third of residual failures).
|
|
7
|
+
|
|
8
|
+
No new mandatory dependency: the default is `None`/identity, so behavior is
|
|
9
|
+
unchanged. A real model is loaded only when requested (`IDEAGRAPH_RERANKER=st`
|
|
10
|
+
for a sentence-transformers CrossEncoder, or a model name/path as the value).
|
|
11
|
+
`ReverseReranker` is a deterministic test stub proving that the rerank pass
|
|
12
|
+
determines the final ranking.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
Candidate = tuple[str, str, float]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Reranker:
|
|
23
|
+
"""Protocol: re-sorts (query, candidates) down to the top-k."""
|
|
24
|
+
|
|
25
|
+
def rerank(self, query: str, candidates: list[Candidate], k: int) -> list[Candidate]:
|
|
26
|
+
# candidates: list[(node_id, text, rrf_score)]
|
|
27
|
+
# returns: list[(node_id, text, rrf_score)] — top-k in rerank order
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
def rerank_with_scores(self, query: str, candidates: list[Candidate],
|
|
31
|
+
k: int) -> tuple[list[Candidate], dict[str, float]]:
|
|
32
|
+
"""Return ordered hits and any model predictions, separately from RRF.
|
|
33
|
+
|
|
34
|
+
Ordering-only rerankers have no model scores to expose.
|
|
35
|
+
"""
|
|
36
|
+
return self.rerank(query, candidates, k), {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ReverseReranker(Reranker):
|
|
40
|
+
"""Deterministic test stub: reverses the candidate order.
|
|
41
|
+
|
|
42
|
+
Purpose: prove that the rerank pass determines the final ranking
|
|
43
|
+
(pipeline integration) — not that the model is qualitatively better.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def rerank(self, query: str, candidates: list[Candidate], k: int) -> list[Candidate]:
|
|
47
|
+
return list(reversed(candidates))[:k]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CrossEncoderReranker(Reranker):
|
|
51
|
+
"""Real cross-encoder model (sentence-transformers), loaded on demand."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"):
|
|
54
|
+
try:
|
|
55
|
+
from sentence_transformers import CrossEncoder
|
|
56
|
+
except ImportError as exc: # pragma: no cover
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
"IDEAGRAPH_RERANKER=st requires 'sentence-transformers' "
|
|
59
|
+
"(pip install sentence-transformers)."
|
|
60
|
+
) from exc
|
|
61
|
+
self.model = CrossEncoder(model_name)
|
|
62
|
+
|
|
63
|
+
def rerank(self, query: str, candidates: list[Candidate], k: int) -> list[Candidate]:
|
|
64
|
+
# Preserve the public search contract: scores in hits remain RRF.
|
|
65
|
+
return self.rerank_with_scores(query, candidates, k)[0]
|
|
66
|
+
|
|
67
|
+
def rerank_with_scores(self, query: str, candidates: list[Candidate],
|
|
68
|
+
k: int) -> tuple[list[Candidate], dict[str, float]]:
|
|
69
|
+
if not candidates:
|
|
70
|
+
return [], {}
|
|
71
|
+
pairs = [(query, text) for _, text, _ in candidates]
|
|
72
|
+
scores = self.model.predict(pairs, show_progress_bar=False)
|
|
73
|
+
scored = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
|
|
74
|
+
# Include predictions for candidates excluded by the final top-k too.
|
|
75
|
+
return ([cand for cand, _ in scored][:k],
|
|
76
|
+
{cand[0]: float(score) for cand, score in scored})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_reranker():
|
|
80
|
+
"""Factory from IDEAGRAPH_RERANKER: 'none' (default) | 'st' | model name/path."""
|
|
81
|
+
mode = os.environ.get("IDEAGRAPH_RERANKER", "none").strip().lower()
|
|
82
|
+
if not mode or mode == "none":
|
|
83
|
+
return None
|
|
84
|
+
if mode == "st":
|
|
85
|
+
return CrossEncoderReranker()
|
|
86
|
+
return CrossEncoderReranker(model_name=mode)
|