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,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)."""