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/feedback.py
ADDED
|
@@ -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))
|