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/hygiene.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Hygiene/status analysis (`ig status`, `ig near-dup`).
|
|
2
|
+
|
|
3
|
+
Read-only reports that support brain maintenance:
|
|
4
|
+
|
|
5
|
+
- `near_dup_pairs` — finds near-duplicate pairs in the cosine band below the
|
|
6
|
+
auto-dedup threshold (0.92). These pairs need a manual
|
|
7
|
+
`ig merge` decision (see `graph_engine.merge`).
|
|
8
|
+
- `connectivity` / `status_counts` — islands, weak nodes, orphans and
|
|
9
|
+
status distribution, so underpopulation and the hygiene backlog become visible.
|
|
10
|
+
|
|
11
|
+
Everything is read-only — nothing on the brain is modified.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .brain import Brain
|
|
22
|
+
|
|
23
|
+
# Auto-dedup threshold in brain_engine (cos >= 0.92 -> merge). Pairs below it,
|
|
24
|
+
# but close enough, are candidates for manual consolidation.
|
|
25
|
+
DEFAULT_DEDUP_THRESHOLD = 0.92
|
|
26
|
+
DEFAULT_NEAR_LO = 0.78
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class NearDup:
|
|
31
|
+
score: float
|
|
32
|
+
a: str
|
|
33
|
+
b: str
|
|
34
|
+
a_text: str
|
|
35
|
+
b_text: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_VEC_CACHE: dict[tuple[str, float, int], tuple[list[str], np.ndarray]] = {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_vectors(brain: Brain) -> tuple[list[str], np.ndarray]:
|
|
42
|
+
"""Reads vectors.jsonl; uses the dominant dimension (384 real vs 64 hash).
|
|
43
|
+
|
|
44
|
+
Audit #38: float32 rounds band-boundary cases wrong (0.9199999990 float64
|
|
45
|
+
-> 0.9200000167 float32 — the pair falls through BOTH mechanisms: no dup
|
|
46
|
+
in the engine, but not in the review band either). Hence float64.
|
|
47
|
+
Audit #60: results are cached keyed by (path, mtime, size) — repeated
|
|
48
|
+
report calls within one process skip the re-parse; any write to the file
|
|
49
|
+
invalidates the entry automatically."""
|
|
50
|
+
if hasattr(brain, "identity"):
|
|
51
|
+
vecs = brain.read_vectors()
|
|
52
|
+
if not vecs:
|
|
53
|
+
return [], np.zeros((0, 0), dtype=np.float64)
|
|
54
|
+
lens = Counter(len(v) for v in vecs.values())
|
|
55
|
+
dom = max(lens, key=lens.get)
|
|
56
|
+
ids = [nid for nid, v in vecs.items() if len(v) == dom]
|
|
57
|
+
values = np.array([vecs[nid] for nid in ids], dtype=np.float64)
|
|
58
|
+
return ids, values / (np.linalg.norm(values, axis=1, keepdims=True) + 1e-12)
|
|
59
|
+
vec_file = brain.path / "vectors.jsonl"
|
|
60
|
+
if not vec_file.exists():
|
|
61
|
+
return [], np.zeros((0, 0), dtype=np.float64)
|
|
62
|
+
try:
|
|
63
|
+
st = vec_file.stat()
|
|
64
|
+
key = (str(vec_file), st.st_mtime_ns, st.st_size)
|
|
65
|
+
except OSError:
|
|
66
|
+
key = None
|
|
67
|
+
if key is not None and key in _VEC_CACHE:
|
|
68
|
+
return _VEC_CACHE[key]
|
|
69
|
+
vecs: dict[str, list[float]] = {}
|
|
70
|
+
lens: Counter = Counter()
|
|
71
|
+
for l in vec_file.read_text(encoding="utf-8").splitlines():
|
|
72
|
+
if not l.strip():
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
o = json.loads(l)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
continue # Audit #17 family: a corrupt line does not kill the report
|
|
78
|
+
vecs[o["id"]] = o["vec"]
|
|
79
|
+
lens[len(o["vec"])] += 1
|
|
80
|
+
if not vecs:
|
|
81
|
+
return [], np.zeros((0, 0), dtype=np.float64)
|
|
82
|
+
dom = max(lens, key=lambda k: lens[k])
|
|
83
|
+
ids = [n for n, v in vecs.items() if len(v) == dom]
|
|
84
|
+
V = np.array([vecs[n] for n in ids], dtype=np.float64)
|
|
85
|
+
V = V / (np.linalg.norm(V, axis=1, keepdims=True) + 1e-12)
|
|
86
|
+
if key is not None:
|
|
87
|
+
_VEC_CACHE[key] = (ids, V)
|
|
88
|
+
return ids, V
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def near_dup_pairs(
|
|
92
|
+
brain: Brain,
|
|
93
|
+
lo: float = DEFAULT_NEAR_LO,
|
|
94
|
+
hi: float = DEFAULT_DEDUP_THRESHOLD,
|
|
95
|
+
max_pairs: int | None = None,
|
|
96
|
+
) -> list[NearDup]:
|
|
97
|
+
"""Finds near-duplicate pairs in the cosine band [lo, hi), descending by score.
|
|
98
|
+
|
|
99
|
+
Audit #38: the upper band end is inclusive-consistent with the engine — a
|
|
100
|
+
float64 cos of 0.9199999990 is NOT a dup in the engine (0.92 threshold),
|
|
101
|
+
so it must appear in the review band. An epsilon buffer at `hi` prevents
|
|
102
|
+
rounding from dropping such pairs out of both mechanisms.
|
|
103
|
+
Audit #39: the pair iteration runs vectorized (triu mask) instead of in
|
|
104
|
+
O(N²) Python loops (4M iterations @2k, ~50 s @20k)."""
|
|
105
|
+
ids, V = _load_vectors(brain)
|
|
106
|
+
if len(ids) < 2:
|
|
107
|
+
return []
|
|
108
|
+
# Tombstones are edge-less BY DESIGN (`ig merge` redirects the deletee's edges
|
|
109
|
+
# away), so a merged deletee keeps its stale vector in vectors.jsonl and would
|
|
110
|
+
# re-report the very pair it was merged for on EVERY run — the same phantom-node
|
|
111
|
+
# class as the connectivity()/gaps filter. Review only live nodes.
|
|
112
|
+
nodes = brain.read_nodes()
|
|
113
|
+
live = {n.id for n in nodes if n.status != "tombstone"}
|
|
114
|
+
keep = [i for i, nid in enumerate(ids) if nid in live]
|
|
115
|
+
if len(keep) < 2:
|
|
116
|
+
return []
|
|
117
|
+
ids = [ids[i] for i in keep]
|
|
118
|
+
V = V[keep]
|
|
119
|
+
S = V @ V.T
|
|
120
|
+
np.fill_diagonal(S, -1.0)
|
|
121
|
+
band = (S >= lo) & (S < hi + 1e-6)
|
|
122
|
+
band = np.triu(band, k=1)
|
|
123
|
+
ii, jj = np.nonzero(band)
|
|
124
|
+
texts = {n.id: n.text for n in nodes}
|
|
125
|
+
pairs = [NearDup(float(S[i][j]), ids[int(i)], ids[int(j)],
|
|
126
|
+
texts.get(ids[int(i)], ids[int(i)])[:72],
|
|
127
|
+
texts.get(ids[int(j)], ids[int(j)])[:72])
|
|
128
|
+
for i, j in zip(ii, jj)]
|
|
129
|
+
pairs.sort(key=lambda p: p.score, reverse=True)
|
|
130
|
+
if max_pairs is not None:
|
|
131
|
+
# Audit #60: `if max_pairs:` treated max_pairs=0 as "unbounded" —
|
|
132
|
+
# 0 means limit 0 (no pairs).
|
|
133
|
+
pairs = pairs[:max_pairs]
|
|
134
|
+
return pairs
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class Connectivity:
|
|
139
|
+
total: int
|
|
140
|
+
edges: int
|
|
141
|
+
islands: list[str] # degree <= 1
|
|
142
|
+
weak: list[str] # degree == 2
|
|
143
|
+
orphans: list[str] # degree == 0
|
|
144
|
+
max_degree: int
|
|
145
|
+
mean_degree: float
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def connectivity(brain: Brain) -> Connectivity:
|
|
149
|
+
nodes = brain.read_nodes()
|
|
150
|
+
# Tombstones are edge-less BY DESIGN (merge redirects their edges away and
|
|
151
|
+
# keeps the node as append-only history) — counting them made every merge
|
|
152
|
+
# leave a permanent phantom "orphan/island" in the status report, and the
|
|
153
|
+
# autonomous cycle then tried to re-link a dead node (found 2026-09-15).
|
|
154
|
+
nodes = [n for n in nodes if n.status != "tombstone"]
|
|
155
|
+
edges = brain.read_edges()
|
|
156
|
+
# Audit #40: invalidated or out-of-window edges no longer count toward
|
|
157
|
+
# connectivity — otherwise the status report would contradict the
|
|
158
|
+
# admit-rule logic (_has_relation correctly ignores them).
|
|
159
|
+
live_edges = [e for e in edges if e.is_current]
|
|
160
|
+
deg: Counter = Counter()
|
|
161
|
+
for e in live_edges:
|
|
162
|
+
deg[e.source] += 1
|
|
163
|
+
deg[e.target] += 1
|
|
164
|
+
ids = [n.id for n in nodes]
|
|
165
|
+
islands = [n for n in ids if deg[n] <= 1]
|
|
166
|
+
weak = [n for n in ids if deg[n] == 2]
|
|
167
|
+
orphans = [n for n in ids if deg[n] == 0]
|
|
168
|
+
vals = [deg[n] for n in ids] or [0]
|
|
169
|
+
return Connectivity(
|
|
170
|
+
total=len(ids),
|
|
171
|
+
edges=len(live_edges),
|
|
172
|
+
islands=islands,
|
|
173
|
+
weak=weak,
|
|
174
|
+
orphans=orphans,
|
|
175
|
+
max_degree=max(vals),
|
|
176
|
+
mean_degree=sum(vals) / len(vals),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def status_counts(brain: Brain) -> Counter:
|
|
181
|
+
c: Counter = Counter()
|
|
182
|
+
for n in brain.read_nodes():
|
|
183
|
+
c[n.status] += 1
|
|
184
|
+
return c
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def render_status(brain: Brain) -> str:
|
|
188
|
+
c = connectivity(brain)
|
|
189
|
+
st = status_counts(brain)
|
|
190
|
+
lines = [
|
|
191
|
+
f"Status ({c.total} nodes / {c.edges} edges):",
|
|
192
|
+
f" Degree: max={c.max_degree} mean={c.mean_degree:.1f}",
|
|
193
|
+
f" Orphans (0 edges): {len(c.orphans)}",
|
|
194
|
+
f" Islands (<=1 edge): {len(c.islands)}",
|
|
195
|
+
f" Weak (==2 edges): {len(c.weak)}",
|
|
196
|
+
f" Status: {dict(st)}",
|
|
197
|
+
]
|
|
198
|
+
if c.islands:
|
|
199
|
+
lines.append(" Island nodes: " + ", ".join(c.islands[:15]) + (" …" if len(c.islands) > 15 else ""))
|
|
200
|
+
if c.orphans:
|
|
201
|
+
lines.append(" Orphan nodes: " + ", ".join(c.orphans[:15]) + (" …" if len(c.orphans) > 15 else ""))
|
|
202
|
+
return "\n".join(lines)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def render_near_dup(pairs: list[NearDup]) -> str:
|
|
206
|
+
if not pairs:
|
|
207
|
+
return "No near-duplicates in the band."
|
|
208
|
+
lines = [f"{len(pairs)} near-duplicate pairs (review consolidation via `ig merge`):"]
|
|
209
|
+
for p in pairs:
|
|
210
|
+
lines.append(f"[{p.score:.3f}] {p.a} ↔ {p.b}")
|
|
211
|
+
lines.append(f" {p.a_text}")
|
|
212
|
+
lines.append(f" {p.b_text}")
|
|
213
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Read-only, evidence-backed relationship queries without an LLM.
|
|
2
|
+
|
|
3
|
+
Connectivity is not entailment. Only homogeneous is_a/part_of fact chains
|
|
4
|
+
and explicit same_as chains are transitive. Similarity, extends, arbitrary
|
|
5
|
+
predicates and contradictions never acquire transitive logical meaning.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import deque
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
import re
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from .brain import Brain, Edge, Node
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .brain_engine import BrainEngine
|
|
18
|
+
|
|
19
|
+
TRANSITIVE = frozenset({"is_a", "part_of", "same_as"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _snapshot(brain: Brain) -> tuple[dict[str, Node], list[Edge]]:
|
|
23
|
+
nodes = {n.id: n for n in brain.read_nodes() if n.status != "tombstone"}
|
|
24
|
+
now = datetime.now(timezone.utc)
|
|
25
|
+
# Check both endpoints and actual start dates, including legacy edges.
|
|
26
|
+
edges = sorted((e for e in brain.read_edges()
|
|
27
|
+
if e.source in nodes and e.target in nodes
|
|
28
|
+
and not e.pending and not e.rejected and e.is_current
|
|
29
|
+
and e.is_valid_at(now)), key=lambda e: e.id)
|
|
30
|
+
return nodes, edges
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve(nodes: dict[str, Node], reference: str) -> str:
|
|
34
|
+
reference = reference.strip().strip('"\'').strip()
|
|
35
|
+
if reference in nodes:
|
|
36
|
+
return reference
|
|
37
|
+
key = reference.casefold()
|
|
38
|
+
matches = sorted(n.id for n in nodes.values()
|
|
39
|
+
if key in {s.casefold() for s in
|
|
40
|
+
[n.text.strip(), n.entity_name or "", *n.aliases] if s})
|
|
41
|
+
if not matches:
|
|
42
|
+
raise ValueError(f"No live node matches {reference!r}; use a node ID or exact name.")
|
|
43
|
+
if len(matches) != 1:
|
|
44
|
+
raise ValueError(f"Ambiguous node {reference!r}; use an ID: {', '.join(matches)}")
|
|
45
|
+
return matches[0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _query_pair(query: str) -> tuple[str, str]:
|
|
49
|
+
query = query.strip()
|
|
50
|
+
if "->" in query:
|
|
51
|
+
parts = query.split("->")
|
|
52
|
+
if len(parts) == 2 and all(p.strip() for p in parts):
|
|
53
|
+
return parts[0].strip(), parts[1].strip()
|
|
54
|
+
patterns = [
|
|
55
|
+
r"(?:what is (?:the )?)?relationship between (.+?) and (.+?)\??",
|
|
56
|
+
r"how (?:does|is) (.+?) (?:relate(?:d)?|connected) to (.+?)\??",
|
|
57
|
+
]
|
|
58
|
+
for pattern in patterns:
|
|
59
|
+
match = re.fullmatch(pattern, query, flags=re.IGNORECASE)
|
|
60
|
+
if match:
|
|
61
|
+
return match[1].strip(), match[2].strip()
|
|
62
|
+
raise ValueError("Use 'A -> B', 'relationship between A and B', or "
|
|
63
|
+
"'How does A relate to B?' (exact names or node IDs).")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _relation(edge: Edge) -> str:
|
|
67
|
+
return edge.predicate or "fact" if edge.kind == "fact" else edge.kind
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _adjacency(edges: list[Edge], relation: str | None = None) -> dict[str, list[tuple[str, Edge]]]:
|
|
71
|
+
adj: dict[str, list[tuple[str, Edge]]] = {}
|
|
72
|
+
for edge in edges:
|
|
73
|
+
if relation is not None:
|
|
74
|
+
if _relation(edge) != relation:
|
|
75
|
+
continue
|
|
76
|
+
# A generic kind named part_of is not a structured fact.
|
|
77
|
+
if edge.kind != "fact" and edge.kind != "same_as":
|
|
78
|
+
continue
|
|
79
|
+
adj.setdefault(edge.source, []).append((edge.target, edge))
|
|
80
|
+
if relation is None or relation == "same_as":
|
|
81
|
+
adj.setdefault(edge.target, []).append((edge.source, edge))
|
|
82
|
+
for links in adj.values():
|
|
83
|
+
links.sort(key=lambda pair: (pair[0], pair[1].id))
|
|
84
|
+
return adj
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _walks(start: str, adj: dict[str, list[tuple[str, Edge]]], max_depth: int,
|
|
88
|
+
*, target: str | None = None, limit: int | None = None) -> list[dict]:
|
|
89
|
+
"""Deterministic BFS: one shortest evidence path per reachable node."""
|
|
90
|
+
queue = deque([(start, [start], [])])
|
|
91
|
+
seen = {start}
|
|
92
|
+
paths = []
|
|
93
|
+
while queue:
|
|
94
|
+
current, node_ids, steps = queue.popleft()
|
|
95
|
+
if len(steps) >= max_depth:
|
|
96
|
+
continue
|
|
97
|
+
for other, edge in adj.get(current, []):
|
|
98
|
+
if other in seen:
|
|
99
|
+
continue
|
|
100
|
+
seen.add(other)
|
|
101
|
+
new_steps = [*steps, {"edge_id": edge.id, "source": edge.source,
|
|
102
|
+
"target": edge.target, "relation": _relation(edge),
|
|
103
|
+
"direction": "out" if edge.source == current else "in"}]
|
|
104
|
+
new_ids = [*node_ids, other]
|
|
105
|
+
path = {"node_ids": new_ids, "steps": new_steps, "hops": len(new_steps)}
|
|
106
|
+
if target is None or other == target:
|
|
107
|
+
paths.append(path)
|
|
108
|
+
if target is not None or (limit is not None and len(paths) >= limit):
|
|
109
|
+
return paths
|
|
110
|
+
queue.append((other, new_ids, new_steps))
|
|
111
|
+
return paths
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _contradictions(edges: list[Edge], relevant: set[str],
|
|
115
|
+
relations: list[dict] | None = None) -> list[dict]:
|
|
116
|
+
conflicts = []
|
|
117
|
+
facts: dict[tuple[str, str, str], list[str]] = {}
|
|
118
|
+
for edge in edges:
|
|
119
|
+
if edge.source not in relevant or edge.target not in relevant:
|
|
120
|
+
continue
|
|
121
|
+
if _relation(edge) == "contradicts":
|
|
122
|
+
conflicts.append({"kind": "recorded", "source": edge.source,
|
|
123
|
+
"target": edge.target, "edge_ids": [edge.id]})
|
|
124
|
+
if edge.kind == "fact" and edge.predicate:
|
|
125
|
+
facts.setdefault((edge.source, edge.predicate, edge.target), []).append(edge.id)
|
|
126
|
+
# Include inferred support so a direct not_part_of can conflict with a
|
|
127
|
+
# multi-hop part_of proof. Negative predicates themselves are not transitive.
|
|
128
|
+
for relation in relations or []:
|
|
129
|
+
if relation["inferred"]:
|
|
130
|
+
key = relation["source"], relation["relation"], relation["target"]
|
|
131
|
+
facts.setdefault(key, []).extend(relation["edge_ids"])
|
|
132
|
+
for (source, predicate, target), ids in sorted(facts.items()):
|
|
133
|
+
if predicate.startswith("not_"):
|
|
134
|
+
positive = facts.get((source, predicate[4:], target))
|
|
135
|
+
if positive:
|
|
136
|
+
conflicts.append({"kind": "opposing_facts", "source": source,
|
|
137
|
+
"target": target, "relation": predicate[4:],
|
|
138
|
+
"edge_ids": sorted(set(ids + positive))})
|
|
139
|
+
return conflicts
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def infer(brain: Brain, query: str, *, max_depth: int = 4) -> dict:
|
|
143
|
+
"""Answer a pair query with direct facts, conservative inference and paths.
|
|
144
|
+
|
|
145
|
+
Results are bounded by max_depth and describe stored assertions, not
|
|
146
|
+
independently verified real-world facts. No brain files are written.
|
|
147
|
+
"""
|
|
148
|
+
if not 1 <= max_depth <= 20:
|
|
149
|
+
raise ValueError("max_depth must be between 1 and 20")
|
|
150
|
+
left, right = _query_pair(query)
|
|
151
|
+
nodes, edges = _snapshot(brain)
|
|
152
|
+
source, target = _resolve(nodes, left), _resolve(nodes, right)
|
|
153
|
+
paths = (_walks(source, _adjacency(edges), max_depth, target=target)
|
|
154
|
+
if source != target else [{"node_ids": [source], "steps": [], "hops": 0}])
|
|
155
|
+
relations = []
|
|
156
|
+
for edge in edges:
|
|
157
|
+
if {edge.source, edge.target} == {source, target}:
|
|
158
|
+
relations.append({"source": edge.source, "target": edge.target,
|
|
159
|
+
"relation": _relation(edge), "inferred": False,
|
|
160
|
+
"edge_ids": [edge.id], "rule": "recorded_edge"})
|
|
161
|
+
for predicate in sorted(TRANSITIVE):
|
|
162
|
+
# Direction matters: report reverse proofs with their real endpoints.
|
|
163
|
+
pairs = [(source, target)] if predicate == "same_as" else [(source, target), (target, source)]
|
|
164
|
+
for start, end in pairs:
|
|
165
|
+
proof = _walks(start, _adjacency(edges, predicate), max_depth, target=end)
|
|
166
|
+
if proof and proof[0]["hops"] > 1:
|
|
167
|
+
path = proof[0]
|
|
168
|
+
relations.append({"source": start, "target": end, "relation": predicate,
|
|
169
|
+
"inferred": True, "rule": f"transitive_{predicate}",
|
|
170
|
+
"edge_ids": [s["edge_id"] for s in path["steps"]]})
|
|
171
|
+
if path not in paths:
|
|
172
|
+
paths.append(path)
|
|
173
|
+
relevant = {source, target} | {nid for p in paths for nid in p["node_ids"]}
|
|
174
|
+
conflicts = _contradictions(edges, relevant, relations)
|
|
175
|
+
evidence_ids = ({s["edge_id"] for p in paths for s in p["steps"]}
|
|
176
|
+
| {eid for r in relations for eid in r["edge_ids"]}
|
|
177
|
+
| {eid for c in conflicts for eid in c["edge_ids"]})
|
|
178
|
+
labels = {nid: nodes[nid].entity_name or nodes[nid].text for nid in sorted(relevant)}
|
|
179
|
+
statements = [f"{labels[r['source']]} --{r['relation']}--> {labels[r['target']]}"
|
|
180
|
+
+ (" (transitive)" if r["inferred"] else " (recorded)") for r in relations]
|
|
181
|
+
if source == target:
|
|
182
|
+
answer = "Both references resolve to the same node."
|
|
183
|
+
elif statements:
|
|
184
|
+
answer = "; ".join(statements) + "."
|
|
185
|
+
elif paths:
|
|
186
|
+
answer = (f"Connected by a {paths[0]['hops']}-edge path; this connection "
|
|
187
|
+
"does not establish a transitive relation.")
|
|
188
|
+
else:
|
|
189
|
+
answer = f"No connection found within {max_depth} hops."
|
|
190
|
+
if conflicts:
|
|
191
|
+
answer += f" {len(conflicts)} recorded contradiction(s) or opposing fact(s) in the evidence."
|
|
192
|
+
return {"query": query, "source": source, "target": target, "answer": answer,
|
|
193
|
+
"max_depth": max_depth, "relations": relations, "paths": paths,
|
|
194
|
+
"contradictions": conflicts, "nodes": labels,
|
|
195
|
+
"edges": [e.to_dict() for e in edges if e.id in evidence_ids]}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def explain_node(brain: Brain, node_id: str, *, max_depth: int = 2, limit: int = 20) -> dict:
|
|
199
|
+
"""Explain a node's current graph context; never claim it caused retrieval."""
|
|
200
|
+
if not 1 <= max_depth <= 20 or not 1 <= limit <= 1000:
|
|
201
|
+
raise ValueError("max_depth must be 1..20 and limit must be 1..1000")
|
|
202
|
+
nodes, edges = _snapshot(brain)
|
|
203
|
+
if node_id not in nodes:
|
|
204
|
+
raise ValueError(f"No live node with id {node_id!r}.")
|
|
205
|
+
found = _walks(node_id, _adjacency(edges), max_depth, limit=limit + 1)
|
|
206
|
+
paths = found[:limit]
|
|
207
|
+
relevant = {node_id} | {nid for path in paths for nid in path["node_ids"]}
|
|
208
|
+
context = [e for e in edges if e.source in relevant and e.target in relevant]
|
|
209
|
+
return {"node_id": node_id, "text": nodes[node_id].text,
|
|
210
|
+
"graph_used_for_ranking": False, "max_depth": max_depth, "limit": limit,
|
|
211
|
+
"truncated": len(found) > limit, "paths": paths,
|
|
212
|
+
"graph_context": [e.to_dict() for e in context],
|
|
213
|
+
"contradictions": _contradictions(context, relevant),
|
|
214
|
+
"explanation": "Current graph paths are context only. Use --query to recompute "
|
|
215
|
+
"retrieval scores; graph paths are not used by hybrid ranking."}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def print_paths(paths: list[dict]) -> None:
|
|
219
|
+
for path in paths:
|
|
220
|
+
parts = [path["node_ids"][0]]
|
|
221
|
+
for step, nid in zip(path["steps"], path["node_ids"][1:]):
|
|
222
|
+
arrow = (f"--{step['relation']}[{step['edge_id']}]-->" if step["direction"] == "out"
|
|
223
|
+
else f"<--{step['relation']}[{step['edge_id']}]--")
|
|
224
|
+
parts.extend([arrow, nid])
|
|
225
|
+
print(" " + " ".join(parts))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_infer(engine: BrainEngine, args: list[str]) -> None:
|
|
229
|
+
import argparse
|
|
230
|
+
import json
|
|
231
|
+
import sys
|
|
232
|
+
|
|
233
|
+
parser = argparse.ArgumentParser(prog="ig infer", description=infer.__doc__)
|
|
234
|
+
parser.add_argument("query", nargs="+")
|
|
235
|
+
parser.add_argument("--max-depth", type=int, default=4)
|
|
236
|
+
parser.add_argument("--json", action="store_true")
|
|
237
|
+
options = parser.parse_args(args)
|
|
238
|
+
try:
|
|
239
|
+
result = infer(engine.brain, " ".join(options.query), max_depth=options.max_depth)
|
|
240
|
+
except ValueError as exc:
|
|
241
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
242
|
+
raise SystemExit(1)
|
|
243
|
+
if options.json:
|
|
244
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
245
|
+
else:
|
|
246
|
+
print(result["answer"])
|
|
247
|
+
print("Evidence paths:")
|
|
248
|
+
print_paths(result["paths"])
|
|
249
|
+
for conflict in result["contradictions"]:
|
|
250
|
+
print(f" Conflict {conflict['source']} / {conflict['target']}: "
|
|
251
|
+
+ ", ".join(conflict["edge_ids"]))
|
graph_engine/intent.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Intent-typed edges (Roadmap V2#3, Zettelkasten lesson).
|
|
2
|
+
|
|
3
|
+
Beyond bare similarity ("similar"/"extends") the engine detects
|
|
4
|
+
INTENT between two nodes from signals in the text:
|
|
5
|
+
|
|
6
|
+
supersedes new makes old obsolete ("API v2 replaces v1")
|
|
7
|
+
contradicts new denies/opposes old ("The Earth is NOT flat")
|
|
8
|
+
continues new continues/builds on old ("further ... based on")
|
|
9
|
+
|
|
10
|
+
Heuristic (no NLP dependency), deterministic and testable. Returns None
|
|
11
|
+
when no intent is detected — similarity then decides as before.
|
|
12
|
+
|
|
13
|
+
Audit #35/#36/#51/#61 (fix wave 3): markers match on token boundaries
|
|
14
|
+
("versetzt" no longer fires "ersetzt"), the subject check runs
|
|
15
|
+
clause-scoped (the marker must share a sentence with a shared content
|
|
16
|
+
word — mere presence in the text is not enough), the negation check runs
|
|
17
|
+
bidirectionally (new affirms what old denied -> contradicts too), and
|
|
18
|
+
the marker sets are fully bilingual.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
# Marker sequences (lowercase). Matching is token-based: a marker only
|
|
26
|
+
# matches when its tokens appear as a SEQUENCE in the token stream —
|
|
27
|
+
# "versetzt" (tokens: [versetzt]) no longer matches "ersetzt", while
|
|
28
|
+
# "findet statt" (tokens [findet, statt]) still matches the supersedes
|
|
29
|
+
# marker "statt" because it really occurs as its own word there (but only
|
|
30
|
+
# in the same clause as the subject — see _clause_hits).
|
|
31
|
+
# Bilingual (Audit #61): every set covers DE and EN.
|
|
32
|
+
SUPERSEDE_MARKERS: tuple[tuple[str, ...], ...] = (
|
|
33
|
+
("ersetzt",), ("ersetzt", "durch"), ("ersatz",),
|
|
34
|
+
("ablöst",), ("löst", "ab"), ("abgelöst",),
|
|
35
|
+
("supersedes",), ("superseded",), ("replaces",), ("replaced", "by"),
|
|
36
|
+
("obsolet",), ("obsolete",), ("deprecated",),
|
|
37
|
+
("statt",), ("anstelle", "von"), ("instead", "of"), ("replaces",),
|
|
38
|
+
)
|
|
39
|
+
CONTRADICT_MARKERS: tuple[tuple[str, ...], ...] = (
|
|
40
|
+
("ist", "nicht"), ("nicht", "mehr"), ("keineswegs",),
|
|
41
|
+
("widerspricht",), ("widersprochen",),
|
|
42
|
+
("keine",), ("kein",), ("niemals",), ("nie",),
|
|
43
|
+
("ist", "falsch"), ("falsch",), ("falsche",),
|
|
44
|
+
("is", "not"), ("no", "longer"), ("never",), ("not", "true"),
|
|
45
|
+
("false",), ("wrong",), ("contradicts",),
|
|
46
|
+
)
|
|
47
|
+
CONTINUE_MARKERS: tuple[tuple[str, ...], ...] = (
|
|
48
|
+
("setzt", "fort"), ("basiert", "auf"), ("aufbauend",),
|
|
49
|
+
("weiterentwicklung",), ("weiterentwickelt",), ("verfeinert",),
|
|
50
|
+
("erweitert", "um"), ("fortgeführt",),
|
|
51
|
+
("builds", "on"), ("based", "on"), ("extends",), ("extends", "with"),
|
|
52
|
+
("continues",), ("continued",), ("refines",), ("follows", "from"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Priority on multiple hits (audit #51): supersedes > contradicts
|
|
56
|
+
# > continues — documented and deterministic; the clause-scoped subject
|
|
57
|
+
# check already disambiguates most double hits.
|
|
58
|
+
_PRIORITY = ("supersedes", "contradicts", "continues")
|
|
59
|
+
|
|
60
|
+
# The three intent kinds, public and single-source: the review policy
|
|
61
|
+
# (`review.INTENT_KINDS` import) and the eval oracle both bound their behavior
|
|
62
|
+
# on this tuple instead of re-listing the strings.
|
|
63
|
+
INTENT_KINDS: tuple[str, ...] = _PRIORITY
|
|
64
|
+
|
|
65
|
+
_MARKER_TO_INTENT: dict[tuple[str, ...], str] = {}
|
|
66
|
+
for _m in SUPERSEDE_MARKERS:
|
|
67
|
+
_MARKER_TO_INTENT[_m] = "supersedes"
|
|
68
|
+
for _m in CONTRADICT_MARKERS:
|
|
69
|
+
_MARKER_TO_INTENT[_m] = "contradicts"
|
|
70
|
+
for _m in CONTINUE_MARKERS:
|
|
71
|
+
_MARKER_TO_INTENT[_m] = "continues"
|
|
72
|
+
|
|
73
|
+
_WORD_RE = re.compile(r"[a-zäöüß0-9]+")
|
|
74
|
+
|
|
75
|
+
# Common function/stop words (DE + EN) that do NOT indicate topical overlap.
|
|
76
|
+
# Without the filter `shared` would be almost always true (die/der/und/ist… in
|
|
77
|
+
# every text), which would make the intent subject check ineffective.
|
|
78
|
+
_STOPWORDS = frozenset(
|
|
79
|
+
"""
|
|
80
|
+
die der das und ist ein eine einer eines mit von für auf den dem aus bei
|
|
81
|
+
als wie nur auch sich nicht keine kein sind wird werden wurde sein ihre
|
|
82
|
+
ihren ihrer diesem diese dieses dieser gegen über unter zwischen ohne weil
|
|
83
|
+
dass durch zum zur im am in an wir ich du er sie es war hat habe haben
|
|
84
|
+
dann wenn so aber oder noch nach vor hier da bitte würde können sollen
|
|
85
|
+
muss worden indem obwohl deshalb trotz außer sowohl sowie bspw z.b bzw
|
|
86
|
+
the and is are with for on of a an to in not no this that but or as was
|
|
87
|
+
were been has have it its be been can will would should must shall may
|
|
88
|
+
""".split()
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _tokens(text: str) -> list[str]:
|
|
93
|
+
"""Token stream: lowercase, punctuation separated (Audit #36: "Erde," was
|
|
94
|
+
previously a different token than "Erde")."""
|
|
95
|
+
return _WORD_RE.findall(text.lower())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _content_words(text: str) -> set[str]:
|
|
99
|
+
return {w for w in _tokens(text) if len(w) >= 3 and w not in _STOPWORDS}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _clauses(text: str) -> list[list[str]]:
|
|
103
|
+
"""Sentence/clause boundaries: . ! ? ; , : and line breaks separate."""
|
|
104
|
+
parts = re.split(r"[.!?;,:()\[\]\"\']|\n+", text.lower())
|
|
105
|
+
return [_tokens(p) for p in parts if _tokens(p)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _marker_hits(tokens: list[str], markers: tuple[tuple[str, ...], ...]) -> list[tuple[str, ...]]:
|
|
109
|
+
"""Markers that occur as contiguous token sequences."""
|
|
110
|
+
hits = []
|
|
111
|
+
n = len(tokens)
|
|
112
|
+
for m in markers:
|
|
113
|
+
L = len(m)
|
|
114
|
+
for i in range(n - L + 1):
|
|
115
|
+
if tokens[i:i + L] == list(m):
|
|
116
|
+
hits.append(m)
|
|
117
|
+
break
|
|
118
|
+
return hits
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Noun-negation/replacement markers: here the negated/replaced thing is the
|
|
122
|
+
# OBJECT of the marker — it must appear as a shared content word SHORTLY AFTER
|
|
123
|
+
# the marker. "keine Zeit für Review" negates "Zeit", not "Review" (Audit #35);
|
|
124
|
+
# "findet statt" has no object after "statt" (stattfinden verb, no supersedes).
|
|
125
|
+
_OBJECT_MARKERS = frozenset(("keine", "kein", "nie", "niemals", "statt", "falsch",
|
|
126
|
+
"falsche", "no", "never", "false", "wrong"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _marker_object_shared(clause: list[str], marker: tuple[str, ...], subjects: set[str]) -> bool:
|
|
130
|
+
if marker[0] not in _OBJECT_MARKERS:
|
|
131
|
+
return True # verbal markers: subject-in-clause check suffices
|
|
132
|
+
# Find the end position of the marker; a shared content word within the
|
|
133
|
+
# next 2 tokens makes the negation the subject ("keine Scheibe" ✓,
|
|
134
|
+
# "keine Zeit für Review" ✗ — Review sits at position +3 and is not
|
|
135
|
+
# the negated thing). The window is positional (articles/adjectives in
|
|
136
|
+
# between are fine).
|
|
137
|
+
n = len(clause)
|
|
138
|
+
for i in range(n - len(marker) + 1):
|
|
139
|
+
if clause[i:i + len(marker)] == list(marker):
|
|
140
|
+
window = clause[i + len(marker):i + len(marker) + 2]
|
|
141
|
+
if set(window) & subjects:
|
|
142
|
+
return True
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _clause_hits(clauses: list[list[str]], markers: tuple[tuple[str, ...], ...],
|
|
147
|
+
subjects: set[str]) -> bool:
|
|
148
|
+
"""True when a marker shares a sentence/clause with a subject content word.
|
|
149
|
+
|
|
150
|
+
Audit #35: mere marker presence in the text fired against EVERY related
|
|
151
|
+
node ("Der Mitarbeiter wird versetzt" → supersedes because of the
|
|
152
|
+
'ersetzt' substring in 'versetzt' + shared domain words). Now the marker
|
|
153
|
+
must be in the same sentence as a shared content word — and for object
|
|
154
|
+
markers (keine/kein/nie/statt/falsch) the negated/replaced object itself
|
|
155
|
+
must be shared."""
|
|
156
|
+
for clause in clauses:
|
|
157
|
+
if not (_content_words(" ".join(clause)) & subjects):
|
|
158
|
+
continue
|
|
159
|
+
hits = _marker_hits(clause, markers)
|
|
160
|
+
if any(_marker_object_shared(clause, m, subjects) for m in hits):
|
|
161
|
+
return True
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def detect_intent(new_text: str, old_text: str) -> str | None:
|
|
166
|
+
"""Intent between the new and the existing node, or None.
|
|
167
|
+
|
|
168
|
+
`new_text` is the newly ingested statement, `old_text` the existing one.
|
|
169
|
+
All three intents require that both talk about the same subject
|
|
170
|
+
(shared content words) AND that the marker shares a sentence with a
|
|
171
|
+
shared content word (Audit #35).
|
|
172
|
+
|
|
173
|
+
Audit #36 (bidirectional negation): if OLD denies the subject and
|
|
174
|
+
NEW affirms it (or vice versa), that is also contradicts — before,
|
|
175
|
+
new-affirms-what-old-denies returned None.
|
|
176
|
+
"""
|
|
177
|
+
new_subjects = _content_words(new_text)
|
|
178
|
+
shared = new_subjects & _content_words(old_text)
|
|
179
|
+
if not shared:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
new_clauses = _clauses(new_text)
|
|
183
|
+
old_clauses = _clauses(old_text)
|
|
184
|
+
|
|
185
|
+
hits: dict[str, bool] = {}
|
|
186
|
+
for intent, markers in (
|
|
187
|
+
("supersedes", SUPERSEDE_MARKERS),
|
|
188
|
+
("contradicts", CONTRADICT_MARKERS),
|
|
189
|
+
("continues", CONTINUE_MARKERS),
|
|
190
|
+
):
|
|
191
|
+
hits[intent] = (
|
|
192
|
+
_clause_hits(new_clauses, markers, shared)
|
|
193
|
+
or _clause_hits(old_clauses, markers, shared)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Bidirectional negation (Audit #36): old denies, new affirms the same
|
|
197
|
+
# subject without negation → contradiction between the statements.
|
|
198
|
+
if not hits["contradicts"]:
|
|
199
|
+
old_denies = _clause_hits(old_clauses, CONTRADICT_MARKERS, shared)
|
|
200
|
+
new_denies = _clause_hits(new_clauses, CONTRADICT_MARKERS, shared)
|
|
201
|
+
if old_denies and not new_denies:
|
|
202
|
+
hits["contradicts"] = True
|
|
203
|
+
|
|
204
|
+
for intent in _PRIORITY:
|
|
205
|
+
if hits[intent]:
|
|
206
|
+
return intent
|
|
207
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Read-only MCP server for the GraphEngine brain (report #1)."""
|