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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. graph_engine/__init__.py +1 -0
  2. graph_engine/__main__.py +1275 -0
  3. graph_engine/access.py +202 -0
  4. graph_engine/agent_memory.py +103 -0
  5. graph_engine/brain.py +731 -0
  6. graph_engine/brain_engine.py +559 -0
  7. graph_engine/communities.py +503 -0
  8. graph_engine/consolidation.py +191 -0
  9. graph_engine/context.py +203 -0
  10. graph_engine/decay.py +65 -0
  11. graph_engine/demo.py +165 -0
  12. graph_engine/dream.py +408 -0
  13. graph_engine/embedder.py +84 -0
  14. graph_engine/engine.py +58 -0
  15. graph_engine/entities.py +376 -0
  16. graph_engine/evals.py +1206 -0
  17. graph_engine/feedback.py +155 -0
  18. graph_engine/gaps.py +209 -0
  19. graph_engine/graph.py +133 -0
  20. graph_engine/hygiene.py +213 -0
  21. graph_engine/inference.py +251 -0
  22. graph_engine/intent.py +207 -0
  23. graph_engine/mcp/__init__.py +1 -0
  24. graph_engine/mcp/agent/instructions.md +34 -0
  25. graph_engine/mcp/format.py +66 -0
  26. graph_engine/mcp/server.py +407 -0
  27. graph_engine/merge.py +220 -0
  28. graph_engine/model.py +56 -0
  29. graph_engine/namespaces.py +74 -0
  30. graph_engine/recall.py +161 -0
  31. graph_engine/report.py +401 -0
  32. graph_engine/reranker.py +86 -0
  33. graph_engine/retrieval.py +296 -0
  34. graph_engine/review.py +95 -0
  35. graph_engine/runtime.py +83 -0
  36. graph_engine/server.py +239 -0
  37. graph_engine/similarity.py +42 -0
  38. graph_engine/store.py +63 -0
  39. graph_engine/suggester.py +63 -0
  40. graph_engine/temporal.py +216 -0
  41. graph_engine/tiers.py +135 -0
  42. graph_engine/web/app.js +578 -0
  43. graph_engine/web/index.html +169 -0
  44. graph_engine/web/report.html +39 -0
  45. graph_engine/web/report.js +50 -0
  46. graph_engine/web/review.html +104 -0
  47. graph_engine/web/review.js +211 -0
  48. graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
  49. graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
  50. graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
  51. graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
  52. graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
  53. graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
graph_engine/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
@@ -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)