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,155 @@
1
+ """Deterministic retrieval learning from explicit user relevance judgments.
2
+
3
+ The tracked feedback.json file keeps the latest judgment per normalized
4
+ (query, node), avoiding repeated votes and allowing corrections. Channel rank
5
+ observations train bounded RRF weights; successful queries expand similar
6
+ queries with a small set of terms from currently visible, live target nodes.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import re
13
+ from collections import Counter
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING
16
+
17
+ from .brain import Brain, Node, _atomic_write, _now_iso
18
+
19
+ if TYPE_CHECKING:
20
+ from .brain_engine import BrainEngine
21
+
22
+ FEEDBACK_FILE = "feedback.json"
23
+ MAX_EXPANSION_TERMS = 5
24
+ STOP_WORDS = frozenset("a an and are as at be by for from in is it of on or that the this to was with".split())
25
+
26
+
27
+ def normalize(query: str) -> str:
28
+ return " ".join(query.lower().split())
29
+
30
+
31
+ def _terms(text: str) -> list[str]:
32
+ return [word for word in re.findall(r"\w+", text.lower())
33
+ if len(word) > 2 and word not in STOP_WORDS]
34
+
35
+
36
+ def _read_feedback(brain: Brain) -> list[dict]:
37
+ """Read valid rows; trusted RMW preserves judgments outside caller scope."""
38
+ path = brain.path / FEEDBACK_FILE
39
+ if not path.exists():
40
+ return []
41
+ try:
42
+ data = json.loads(path.read_text(encoding="utf-8"))
43
+ if not isinstance(data, dict) or data.get("version") != 1 or not isinstance(data.get("judgments"), list):
44
+ raise ValueError("unsupported feedback format")
45
+ except (json.JSONDecodeError, ValueError) as exc:
46
+ raise ValueError(f"Cannot read {FEEDBACK_FILE}: {exc}") from exc
47
+ rows = []
48
+ for row in data["judgments"]:
49
+ if (not isinstance(row, dict) or not isinstance(row.get("query"), str)
50
+ or not normalize(row["query"]) or not isinstance(row.get("node_id"), str)
51
+ or row.get("label") not in ("relevant", "irrelevant")):
52
+ continue
53
+ rows.append(row)
54
+ return rows
55
+
56
+
57
+ def read_feedback(brain: Brain) -> list[dict]:
58
+ """Read judgments only for currently visible, live nodes."""
59
+ getattr(brain, "authorize", lambda **_: None)(action="read")
60
+ visible = {node.id for node in brain.read_nodes() if node.status != "tombstone"}
61
+ return [row for row in _read_feedback(brain) if row["node_id"] in visible]
62
+
63
+
64
+ @dataclass
65
+ class LearningProfile:
66
+ query: str
67
+ dense_weight: float = 1.0
68
+ bm25_weight: float = 1.0
69
+ expansion_terms: list[str] = field(default_factory=list)
70
+ adjustments: dict[str, float] = field(default_factory=dict)
71
+
72
+ @property
73
+ def expanded_query(self) -> str:
74
+ return " ".join([self.query, *self.expansion_terms])
75
+
76
+
77
+ def learning_profile(brain: Brain, query: str, nodes: list[Node]) -> LearningProfile:
78
+ profile = LearningProfile(query)
79
+ visible = {node.id: node for node in nodes if node.status != "tombstone"}
80
+ # Latest row wins even in hand-edited files; a judgment cannot multiply votes.
81
+ getattr(brain, "authorize", lambda **_: None)(action="read")
82
+ judgments = {(normalize(row["query"]), row["node_id"]): row
83
+ for row in _read_feedback(brain) if row["node_id"] in visible}
84
+ if not judgments:
85
+ return profile
86
+ dense_utility = bm25_utility = 0.0
87
+ expansions: Counter[str] = Counter()
88
+ query_terms = set(_terms(query))
89
+ for (prior_query, nid), row in judgments.items():
90
+ sign = 1 if row["label"] == "relevant" else -1
91
+ def utility(channel: str) -> float:
92
+ rank = row.get(channel + "_rank")
93
+ return 1.0 / rank if isinstance(rank, int) and not isinstance(rank, bool) and rank > 0 else 0.0
94
+ dense_utility += sign * utility("dense")
95
+ bm25_utility += sign * utility("bm25")
96
+ if prior_query == normalize(query):
97
+ profile.adjustments[nid] = sign / 60.0
98
+ prior_terms = set(_terms(prior_query))
99
+ similarity = len(query_terms & prior_terms) / max(1, len(query_terms | prior_terms))
100
+ if sign > 0 and similarity >= 0.5:
101
+ expansions.update(set(_terms(visible[nid].text)) - query_terms)
102
+ # Bounded update with a neutral prior: many duplicate votes cannot overpower
103
+ # the other channel, and no-feedback behavior is exactly the existing RRF.
104
+ delta = max(-0.5, min(0.5, (dense_utility - bm25_utility) / (2 + len(judgments))))
105
+ profile.dense_weight = 1.0 + delta
106
+ profile.bm25_weight = 1.0 - delta
107
+ profile.expansion_terms = sorted(expansions, key=lambda t: (-expansions[t], t))[:MAX_EXPANSION_TERMS]
108
+ return profile
109
+
110
+
111
+ def record_feedback(engine: BrainEngine, query: str, node_id: str, label: str, *,
112
+ commit: bool = True) -> dict:
113
+ if not isinstance(query, str) or not query.strip():
114
+ raise ValueError("query must be non-empty")
115
+ if label not in ("relevant", "irrelevant"):
116
+ raise ValueError("label must be relevant or irrelevant")
117
+ from .brain_engine import BRAIN_LOCK
118
+ from .retrieval import _retrieve_trace
119
+ brain = engine.brain
120
+ with BRAIN_LOCK:
121
+ getattr(brain, "authorize", lambda **_: None)(action="write", node_id=node_id)
122
+ brain.ensure_ready()
123
+ brain.pull()
124
+ node = brain.read_node(node_id)
125
+ if node is None or node.status == "tombstone":
126
+ raise ValueError(f"No live node with id {node_id!r}.")
127
+ # Measure the baseline channels, not our own previous learned boost.
128
+ trace = _retrieve_trace(engine, query, 30, persist=False, learn=False)
129
+ rows = _read_feedback(brain)
130
+ key = (normalize(query), node_id)
131
+ rows = [row for row in rows if (normalize(row["query"]), row["node_id"]) != key]
132
+ row = {"query": key[0], "node_id": node_id, "label": label, "at": _now_iso(),
133
+ "dense_rank": trace.dense_ranks.get(node_id),
134
+ "bm25_rank": trace.bm25_ranks.get(node_id)}
135
+ rows.append(row)
136
+ _atomic_write(brain.path / FEEDBACK_FILE,
137
+ json.dumps({"version": 1, "judgments": rows}, ensure_ascii=False, indent=2) + "\n")
138
+ if commit:
139
+ brain.commit_and_push(f"feedback: {label} for {node_id}")
140
+ return row
141
+
142
+
143
+ def cmd_feedback(engine: BrainEngine, args: list[str]) -> None:
144
+ parser = argparse.ArgumentParser(prog="ig feedback")
145
+ parser.add_argument("query")
146
+ parser.add_argument("node_id")
147
+ parser.add_argument("label", choices=("relevant", "irrelevant"))
148
+ parser.add_argument("--json", action="store_true")
149
+ opts = parser.parse_args(args)
150
+ try:
151
+ result = record_feedback(engine, opts.query, opts.node_id, opts.label)
152
+ except ValueError as exc:
153
+ parser.error(str(exc))
154
+ print(json.dumps(result, indent=2) if opts.json else
155
+ f"Recorded {opts.label} feedback for {opts.node_id}.")
graph_engine/gaps.py ADDED
@@ -0,0 +1,209 @@
1
+ """Coverage and gap analysis over the brain.
2
+
3
+ Classifies each node against a topic taxonomy (area → keywords),
4
+ counts coverage per area and identifies underrepresented areas (gaps).
5
+ This steers targeted research instead of broad search.
6
+
7
+ The taxonomy is generic and swappable via a JSON file
8
+ (format: {"area": ["keyword", ...]}); DEFAULT_TAXONOMY is a sensible
9
+ LLM/agent default. Nothing on the brain is modified (read-only).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from collections import Counter, defaultdict
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ from .brain import Brain
20
+
21
+ # Default taxonomy (LLM/agent research) — generic, no personal data.
22
+ DEFAULT_TAXONOMY: dict[str, list[str]] = {
23
+ "Agent-Harness & Orchestrierung": [
24
+ "harness", "orchestrierung", "agent-runtime", "supervisor", "handoff",
25
+ "loop-kontrolle", "startup", "kontext-injektion", "resilienz",
26
+ "circuit-breaker", "durable", "plan-and-execute", "agent-loop",
27
+ "workflow", "subagent", "fanout", "delegation",
28
+ ],
29
+ "Coding-Agents & Code": [
30
+ "code", "programmier", "coding", "software", "repo", "debug",
31
+ "test-generierung", "code-review", "refactor", "swe-bench", "human-eval",
32
+ "legacy", "api-generierung", "sql",
33
+ ],
34
+ "Multi-Agent-Systeme": [
35
+ "multi-agent", "verhandlung", "auktion", "konsens", "voting",
36
+ "kooperation", "konkurrenz", "spieltheorie", "mechanism-design",
37
+ "incentive", "marktplatz", "interoperabilitaet", "blackboard",
38
+ "agenten-routing", "schwarm", "peer-to-peer",
39
+ ],
40
+ "LLM-Architektur & Interna": [
41
+ "attention", "transformer", "token", "embedding", "kv-cache", "position",
42
+ "rope", "mamba", "ssm", "mixture", "moe", "architektur", "logits",
43
+ "softmax", "layernorm", "tokenisierung", "bpe", "neuron",
44
+ ],
45
+ "Training & Alignment": [
46
+ "rlhf", "dpo", "grpo", "ppo", "alignment", "fine-tuning", "finetuning",
47
+ "lora", "peft", "qlora", "sft", "reward", "preference", "synthetic",
48
+ "curriculum", "gradient", "loss", "optimizer", "adamw", "training",
49
+ ],
50
+ "Evals & Benchmarks": [
51
+ "eval", "benchmark", "metrik", "leaderboard", "mmlu", "gsm8k", "judge",
52
+ "kontamination", "golden", "oracle", "pass@k", "halluzination-messung",
53
+ "test", "verifikation",
54
+ ],
55
+ "Retrieval & RAG": [
56
+ "retrieval", "rag", "rerank", "bm25", "dense", "vector",
57
+ "embedding-search", "chunk", "index", "graph-rag", "hybrid",
58
+ "semantische-suche", "cross-encoder", "late-chunking",
59
+ ],
60
+ "Memory & Kontext": [
61
+ "memory", "gedaechtnis", "kontext", "working-memory", "episodic",
62
+ "context-engineering", "token-budget", "context-window", "scratchpad",
63
+ "langzeit", "hierarchisches", "summarization", "kontext-kompression",
64
+ ],
65
+ "Serving & Inferenz": [
66
+ "serving", "inferenz", "vllm", "batching", "prefill", "decode",
67
+ "throughput", "latenz", "quantisierung", "gpu", "inference",
68
+ "speculative", "disaggregated", "ttft", "pagedattention",
69
+ ],
70
+ "Sicherheit & Governance": [
71
+ "sicherheit", "security", "jailbreak", "injection", "sandbox",
72
+ "governance", "privacy", "datenschutz", "ethik", "regulierung", "audit",
73
+ "compliance", "exfiltration", "autorisierung", "trust", "verantwortung",
74
+ ],
75
+ "Multimodal": [
76
+ "multimodal", "vision", "bild", "audio", "speech", "tts", "video",
77
+ "clip", "image", "sprach-synthese", "geste", "cross-modal",
78
+ "text-to-image",
79
+ ],
80
+ "Konversations-KI": [
81
+ "konversation", "dialog", "chatbot", "persona", "intent", "slot",
82
+ "multi-turn", "chat", "gespraech", "engagement", "turn-qualitaet",
83
+ "empathie",
84
+ ],
85
+ "Interpretability & Mechanistik": [
86
+ "interpretab", "mechanistic", "sae", "sparse autoencoder", "activation",
87
+ "circuit", "logit-lens", "probing", "attribution", "causal", "ablation",
88
+ "steering", "transparenz", "erklaer",
89
+ ],
90
+ "Anwendungen": [
91
+ "industr", "fertigung", "medizin", "biologie", "chemie", "physik",
92
+ "bildung", "tutor", "lern", "content", "marketing", "journalismus",
93
+ "recht", "finanz", "gesundheit", "wissenschaft", "astronomie", "ocean",
94
+ "klima",
95
+ ],
96
+ "Oekonomie & Gesellschaft": [
97
+ "oekonomie", "kosten", "open-source", "closed", "arbeitsmarkt", "umwelt",
98
+ "gesellschaft", "wirtschaft", "markt", "lizenz", "urheber",
99
+ ],
100
+ }
101
+
102
+
103
+ def normalize(text: str) -> str:
104
+ """Lowercase + umlaut normalization (ä→ae etc.)."""
105
+ s = text.lower()
106
+ for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
107
+ s = s.replace(a, b)
108
+ return s
109
+
110
+
111
+ def _node_text(node: Any) -> str:
112
+ """Node body without frontmatter.
113
+
114
+ Audit #25: the blind parts[2] lost everything after an internal
115
+ `---` horizontal rule in the body. Frontmatter end is the FIRST
116
+ `---` line alone — everything after that is the complete body.
117
+ """
118
+ t = getattr(node, "text", "") or ""
119
+ m = re.match(r"^---\n.*?\n---\n\n?(.*)$", t, re.DOTALL)
120
+ if m:
121
+ return m.group(1).strip()
122
+ return t.strip()
123
+
124
+
125
+ @dataclass
126
+ class AreaStat:
127
+ name: str
128
+ count: int = 0
129
+ examples: list[dict] = field(default_factory=list)
130
+
131
+
132
+ @dataclass
133
+ class CoverageResult:
134
+ total: int
135
+ areas: list[AreaStat] # sorted by count descending
136
+ unclassified: int
137
+ taxonomy: dict[str, list[str]]
138
+
139
+
140
+ def analyze_coverage(brain: Brain, taxonomy: dict[str, list[str]] | None = None) -> CoverageResult:
141
+ """Classifies all nodes against the taxonomy and counts coverage per area.
142
+
143
+ A node can belong to multiple areas (keyword overlap).
144
+ Read-only — does not modify the brain.
145
+ """
146
+ tax = taxonomy or DEFAULT_TAXONOMY
147
+ count: Counter = Counter()
148
+ examples: dict[str, list] = defaultdict(list)
149
+ nodes = brain.read_nodes()
150
+ # Tombstones are append-only history, not live knowledge: they must not
151
+ # count toward coverage (they would inflate UNCLASSIFIED and every area).
152
+ nodes = [n for n in nodes if n.status != "tombstone"]
153
+ unclassified = 0
154
+ # Audit #60: substring matching let "test" match "latest" — keywords now
155
+ # match with word boundaries (umlauts are ASCII-shaped by normalize()).
156
+ kw_res = {area: [re.compile(rf"\b{re.escape(k)}\b") for k in kws]
157
+ for area, kws in tax.items()}
158
+ for node in nodes:
159
+ b = normalize(_node_text(node))
160
+ matched = [area for area, patterns in kw_res.items()
161
+ if any(p.search(b) for p in patterns)]
162
+ if not matched:
163
+ unclassified += 1
164
+ for m in matched:
165
+ count[m] += 1
166
+ if len(examples[m]) < 5:
167
+ examples[m].append({"id": node.id, "text": _node_text(node)[:72]})
168
+ areas = [AreaStat(name=a, count=count[a], examples=examples[a]) for a in tax]
169
+ areas.sort(key=lambda s: s.count, reverse=True)
170
+ return CoverageResult(total=len(nodes), areas=areas, unclassified=unclassified, taxonomy=tax)
171
+
172
+
173
+ def find_gaps(coverage: CoverageResult, threshold: int) -> list[AreaStat]:
174
+ """Areas below the threshold, ascending by coverage (thinnest first)."""
175
+ return [a for a in sorted(coverage.areas, key=lambda s: s.count) if a.count < threshold]
176
+
177
+
178
+ def render(coverage: CoverageResult, threshold: int) -> str:
179
+ """Textual coverage/gap report."""
180
+ lines = [
181
+ f"Coverage ({coverage.total} nodes, {len(coverage.areas)} areas):",
182
+ f"{'Nodes':>5} {'Area':<40} Coverage",
183
+ "----- " + "-" * 40 + " ---------",
184
+ ]
185
+ maxc = max((a.count for a in coverage.areas), default=1)
186
+ # Audit #24: a brain without hits (all counts 0) must not crash with
187
+ # ZeroDivisionError — maxc at least 1.
188
+ maxc = max(maxc, 1)
189
+ for a in coverage.areas:
190
+ bar = "#" * int(a.count / maxc * 30)
191
+ gap = " ← GAP" if a.count < threshold else ""
192
+ lines.append(f"{a.count:>5} {a.name:<40} {bar}{gap}")
193
+ lines.append(f"{coverage.unclassified:>5} {'UNCLASSIFIED':<40}")
194
+ gaps = find_gaps(coverage, threshold)
195
+ if gaps:
196
+ lines.append("")
197
+ lines.append(f"GAPS (< {threshold} nodes) — next research targets (thinnest first):")
198
+ for a in gaps:
199
+ lines.append(f" {a.count:>3} {a.name}")
200
+ return "\n".join(lines)
201
+
202
+
203
+ def load_taxonomy(path: str) -> dict[str, list[str]]:
204
+ """Loads a taxonomy from a JSON file ({"area": ["kw", ...]})."""
205
+ with open(path, encoding="utf-8") as f:
206
+ data = json.load(f)
207
+ if not isinstance(data, dict) or not all(isinstance(v, list) for v in data.values()):
208
+ raise ValueError("Taxonomy must be a JSON object: {\"area\": [\"kw\", ...]}")
209
+ return data
graph_engine/graph.py ADDED
@@ -0,0 +1,133 @@
1
+ """Graph traversal over live edges (report #1, MCP phase 1).
2
+
3
+ SDK-free and CLI-reusable: the MCP `neighbors` tool and any future CLI
4
+ command share these functions. The undirected view is deliberate — "what
5
+ is this node connected to" does not care which endpoint was newer at
6
+ ingest time (edges always point newest -> older).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections import deque
11
+ from dataclasses import dataclass
12
+
13
+ from .brain import Brain, Edge, Node
14
+
15
+
16
+ @dataclass
17
+ class Neighbor:
18
+ id: str
19
+ snippet: str
20
+ kind: str
21
+ direction: str # "out" (this node is the source) | "in"
22
+ pending: bool
23
+ confidence: float | None
24
+ hops: int
25
+ predicate: str | None = None
26
+
27
+
28
+ def live_edges(brain: Brain, include_pending: bool = False) -> list[Edge]:
29
+ """Edges that count as real relations: currently valid and not invalidated,
30
+ not rejected, endpoints not tombstoned (hygiene.py precedent — tombstones
31
+ are edge-less BY DESIGN), and optionally not pending."""
32
+ node_status = {n.id: n.status for n in brain.read_nodes()}
33
+ out = []
34
+ for e in brain.read_edges():
35
+ if not e.is_current or e.rejected:
36
+ continue
37
+ if e.source in node_status and node_status[e.source] == "tombstone":
38
+ continue
39
+ if e.target in node_status and node_status[e.target] == "tombstone":
40
+ continue
41
+ if e.pending and not include_pending:
42
+ continue
43
+ out.append(e)
44
+ return out
45
+
46
+
47
+ def neighbors(brain: Brain, node_id: str, hops: int = 1,
48
+ kinds: list[str] | None = None,
49
+ include_pending: bool = False,
50
+ limit: int | None = None) -> list[Neighbor]:
51
+ """BFS over the UNDIRECTED live-edge set, deterministic order.
52
+
53
+ Ordering: by (hops, kind, id) so tests are stable regardless of dict
54
+ iteration order. `limit` caps the RESULT (after sorting), not the
55
+ per-hop expansion — a cap during expansion would make the result
56
+ order-dependent in a subtle way.
57
+ """
58
+ edges = live_edges(brain, include_pending=include_pending)
59
+ if kinds is not None:
60
+ allowed = set(kinds)
61
+ edges = [e for e in edges if e.kind in allowed]
62
+ adj: dict[str, list[tuple[str, Edge, str]]] = {}
63
+ for e in edges:
64
+ adj.setdefault(e.source, []).append((e.target, e, "out"))
65
+ adj.setdefault(e.target, []).append((e.source, e, "in"))
66
+
67
+ id2node = {n.id: n for n in brain.read_nodes()}
68
+ seen: dict[str, int] = {node_id: 0}
69
+ results: list[Neighbor] = []
70
+ queue = deque([node_id])
71
+ while queue:
72
+ cur = queue.popleft()
73
+ depth = seen[cur]
74
+ if depth >= hops:
75
+ continue
76
+ for other, edge, direction in sorted(adj.get(cur, []),
77
+ key=lambda t: (t[1].kind, t[0])):
78
+ if other in seen:
79
+ continue
80
+ seen[other] = depth + 1
81
+ node = id2node.get(other)
82
+ results.append(Neighbor(
83
+ id=other,
84
+ snippet=(node.text if node else "?"),
85
+ kind=edge.kind,
86
+ direction=direction if depth == 0 else
87
+ ("in" if direction == "out" else "out"),
88
+ pending=edge.pending,
89
+ confidence=edge.confidence,
90
+ hops=depth + 1,
91
+ predicate=edge.predicate,
92
+ ))
93
+ queue.append(other)
94
+ results.sort(key=lambda nb: (nb.hops, nb.kind, nb.id))
95
+ if limit is not None:
96
+ results = results[:limit]
97
+ return results
98
+
99
+
100
+ def shortest_path(brain: Brain, a: str, b: str, max_depth: int = 4,
101
+ include_pending: bool = False) -> list[str] | None:
102
+ """BFS shortest path a -> b over undirected live edges, depth-capped.
103
+ Returns the node id sequence INCLUDING both endpoints, or None."""
104
+ if a == b:
105
+ return [a]
106
+ edges = live_edges(brain, include_pending=include_pending)
107
+ adj: dict[str, list[str]] = {}
108
+ for e in edges:
109
+ adj.setdefault(e.source, []).append(e.target)
110
+ adj.setdefault(e.target, []).append(e.source)
111
+ prev: dict[str, str] = {a: a}
112
+ depth: dict[str, int] = {a: 0}
113
+ queue = deque([a])
114
+ while queue:
115
+ cur = queue.popleft()
116
+ if depth[cur] >= max_depth:
117
+ continue
118
+ for nxt in sorted(adj.get(cur, [])):
119
+ if nxt in prev:
120
+ continue
121
+ prev[nxt] = cur
122
+ depth[nxt] = depth[cur] + 1
123
+ if nxt == b:
124
+ return _walk(prev, b)
125
+ queue.append(nxt)
126
+ return None
127
+
128
+
129
+ def _walk(prev: dict[str, str], end: str) -> list[str]:
130
+ path = [end]
131
+ while prev[path[-1]] != path[-1]:
132
+ path.append(prev[path[-1]])
133
+ return list(reversed(path))