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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. graph_engine/__init__.py +1 -0
  2. graph_engine/__main__.py +1275 -0
  3. graph_engine/access.py +202 -0
  4. graph_engine/agent_memory.py +103 -0
  5. graph_engine/brain.py +731 -0
  6. graph_engine/brain_engine.py +559 -0
  7. graph_engine/communities.py +503 -0
  8. graph_engine/consolidation.py +191 -0
  9. graph_engine/context.py +203 -0
  10. graph_engine/decay.py +65 -0
  11. graph_engine/demo.py +165 -0
  12. graph_engine/dream.py +408 -0
  13. graph_engine/embedder.py +84 -0
  14. graph_engine/engine.py +58 -0
  15. graph_engine/entities.py +376 -0
  16. graph_engine/evals.py +1206 -0
  17. graph_engine/feedback.py +155 -0
  18. graph_engine/gaps.py +209 -0
  19. graph_engine/graph.py +133 -0
  20. graph_engine/hygiene.py +213 -0
  21. graph_engine/inference.py +251 -0
  22. graph_engine/intent.py +207 -0
  23. graph_engine/mcp/__init__.py +1 -0
  24. graph_engine/mcp/agent/instructions.md +34 -0
  25. graph_engine/mcp/format.py +66 -0
  26. graph_engine/mcp/server.py +407 -0
  27. graph_engine/merge.py +220 -0
  28. graph_engine/model.py +56 -0
  29. graph_engine/namespaces.py +74 -0
  30. graph_engine/recall.py +161 -0
  31. graph_engine/report.py +401 -0
  32. graph_engine/reranker.py +86 -0
  33. graph_engine/retrieval.py +296 -0
  34. graph_engine/review.py +95 -0
  35. graph_engine/runtime.py +83 -0
  36. graph_engine/server.py +239 -0
  37. graph_engine/similarity.py +42 -0
  38. graph_engine/store.py +63 -0
  39. graph_engine/suggester.py +63 -0
  40. graph_engine/temporal.py +216 -0
  41. graph_engine/tiers.py +135 -0
  42. graph_engine/web/app.js +578 -0
  43. graph_engine/web/index.html +169 -0
  44. graph_engine/web/report.html +39 -0
  45. graph_engine/web/report.js +50 -0
  46. graph_engine/web/review.html +104 -0
  47. graph_engine/web/review.js +211 -0
  48. graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
  49. graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
  50. graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
  51. graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
  52. graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
  53. graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
graph_engine/server.py ADDED
@@ -0,0 +1,239 @@
1
+ """FastAPI server on top of the Brain (private git repo as storage).
2
+
3
+ Env control:
4
+ IG_BRAIN_PATH — path to the brain clone (default: ~/graph-engine-brain)
5
+ IG_BRAIN_REMOTE — SSH/GitHub URL (only used for `git clone` on first
6
+ setup; no personal default, existing clones keep
7
+ their own origin)
8
+ IG_BRAIN_MODE — "git" (real repo) or "local" (FS only, for tests)
9
+ IDEAGRAPH_EMBEDDER — "st" (sentence-transformers) or "hash" (demo/tests)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
17
+ from fastapi.concurrency import run_in_threadpool
18
+ from fastapi.responses import FileResponse, JSONResponse
19
+ from pydantic import BaseModel
20
+
21
+ from . import runtime, access, namespaces
22
+
23
+ # Shipped UI assets live INSIDE the package so a pip install serves them too
24
+ # (repo-root docs/ would not exist in site-packages).
25
+ DOCS_DIR = Path(__file__).resolve().parent / "web"
26
+
27
+ app = FastAPI(title="GraphEngine")
28
+
29
+
30
+ @app.middleware("http")
31
+ async def access_control(request, call_next):
32
+ # Pin selection before any awaits: switching the CLI while an ingest runs
33
+ # must not send the old graph's payload to the new graph's subscribers.
34
+ graph_token = namespaces.request_graph.set(namespaces.selected())
35
+ identity_token = None
36
+ try:
37
+ if access.policy_path():
38
+ try:
39
+ identity = access.token_principal(request.headers.get("authorization", ""))
40
+ except PermissionError:
41
+ access.audit("http", "", "authenticate", False)
42
+ return JSONResponse({"error": "Authentication required"}, status_code=401)
43
+ identity_token = access.request_principal.set(identity)
44
+ return await call_next(request)
45
+ finally:
46
+ if identity_token is not None:
47
+ access.request_principal.reset(identity_token)
48
+ namespaces.request_graph.reset(graph_token)
49
+
50
+
51
+ @app.exception_handler(PermissionError)
52
+ async def permission_error_handler(_request, _exc):
53
+ return JSONResponse({"error": "Access denied"}, status_code=403)
54
+
55
+
56
+
57
+ class ConnectionManager:
58
+ def __init__(self):
59
+ self.active: list[WebSocket] = []
60
+ self.graphs: dict[WebSocket, str] = {}
61
+
62
+ async def connect(self, ws: WebSocket):
63
+ await ws.accept()
64
+ self.active.append(ws)
65
+ self.graphs[ws] = runtime.brain_path()
66
+
67
+ def disconnect(self, ws: WebSocket):
68
+ if ws in self.active:
69
+ self.active.remove(ws)
70
+ self.graphs.pop(ws, None)
71
+
72
+ async def broadcast(self, message: dict):
73
+ if access.policy_path():
74
+ return # protected realtime transport is intentionally disabled
75
+ for ws in list(self.active):
76
+ if self.graphs.get(ws) != runtime.brain_path():
77
+ continue
78
+ try:
79
+ await ws.send_json(message)
80
+ except Exception:
81
+ self.disconnect(ws)
82
+
83
+
84
+ manager = ConnectionManager()
85
+
86
+
87
+ NO_CACHE = {"Cache-Control": "no-store"}
88
+
89
+
90
+ @app.get("/")
91
+ def index():
92
+ return FileResponse(DOCS_DIR / "index.html", headers=NO_CACHE)
93
+
94
+
95
+ @app.get("/app.js")
96
+ def app_js():
97
+ return FileResponse(DOCS_DIR / "app.js", headers=NO_CACHE)
98
+
99
+
100
+ @app.get("/review")
101
+ def review():
102
+ return FileResponse(DOCS_DIR / "review.html", headers=NO_CACHE)
103
+
104
+
105
+ @app.get("/review.js")
106
+ def review_js():
107
+ return FileResponse(DOCS_DIR / "review.js", headers=NO_CACHE)
108
+
109
+
110
+ @app.get("/api/graph")
111
+ def graph():
112
+ return JSONResponse(runtime.make_brain().graph_state())
113
+
114
+
115
+ @app.get("/report")
116
+ def report_page():
117
+ return FileResponse(DOCS_DIR / "report.html", headers=NO_CACHE)
118
+
119
+
120
+ @app.get("/report.js")
121
+ def report_js():
122
+ return FileResponse(DOCS_DIR / "report.js", headers=NO_CACHE)
123
+
124
+
125
+ @app.get("/api/report")
126
+ def api_report(since: str | None = None, top: int = 5):
127
+ """Read-only BRAIN_REPORT digest (report #7). Never mutates the brain."""
128
+ from .report import report_data, render_report
129
+ brain = runtime.make_brain()
130
+ try:
131
+ data = report_data(brain, since=since, top=top)
132
+ data["markdown"] = render_report(brain, since=since, top=top)
133
+ return JSONResponse(data)
134
+ finally:
135
+ if hasattr(brain, "close"):
136
+ brain.close()
137
+
138
+
139
+ class IngestBody(BaseModel):
140
+ text: str
141
+ source: str = "human"
142
+ tags: list[str] = []
143
+ allow_duplicates: bool = False
144
+
145
+
146
+ @app.exception_handler(ValueError)
147
+ async def value_error_handler(_req, exc: ValueError):
148
+ """Audit #57: engine ValueErrors (e.g. empty ingest text) are client errors,
149
+ not 500s — return a clean 400 with the message."""
150
+ from fastapi.responses import JSONResponse
151
+ return JSONResponse({"error": str(exc)}, status_code=400)
152
+
153
+
154
+ @app.post("/api/ingest")
155
+ async def ingest(body: IngestBody):
156
+ # Audit #16: git pull/push + embedding inference are blocking I/O — they
157
+ # run in the threadpool, not on the event loop (otherwise a slow ingest
158
+ # stalls all requests and WS broadcasts).
159
+ engine = runtime.make_engine()
160
+ node, edges, is_dup = await run_in_threadpool(
161
+ engine.ingest, body.text, body.source, body.tags,
162
+ body.allow_duplicates)
163
+ await manager.broadcast({
164
+ "type": "ingested",
165
+ "node": node.to_dict(),
166
+ "edges": [e.to_dict() for e in edges],
167
+ "duplicate": is_dup,
168
+ })
169
+ return {"node": node.to_dict(), "suggested": [e.to_dict() for e in edges],
170
+ "duplicate": is_dup}
171
+
172
+
173
+ @app.post("/api/edge/{edge_id}/accept")
174
+ async def accept_edge(edge_id: str):
175
+ edge = await run_in_threadpool(runtime.make_engine().resolve, edge_id, True)
176
+ if edge is None:
177
+ return JSONResponse({"error": "edge not found or not pending"}, status_code=404)
178
+ await manager.broadcast({"type": "edge_resolved", "edge": edge.to_dict(), "accepted": True})
179
+ return edge.to_dict()
180
+
181
+
182
+ @app.post("/api/edge/{edge_id}/reject")
183
+ async def reject_edge(edge_id: str):
184
+ edge = await run_in_threadpool(runtime.make_engine().resolve, edge_id, False)
185
+ if edge is None:
186
+ return JSONResponse({"error": "edge not found or not pending"}, status_code=404)
187
+ await manager.broadcast({"type": "edge_resolved", "edge": edge.to_dict(), "accepted": False})
188
+ return {"rejected": edge_id}
189
+
190
+
191
+ class LinkBody(BaseModel):
192
+ source: str
193
+ target: str
194
+ kind: str = "same_as"
195
+
196
+
197
+ @app.post("/api/edge")
198
+ async def link_edge(body: LinkBody):
199
+ def _link():
200
+ try:
201
+ return runtime.make_engine().link(body.source, body.target, body.kind), None
202
+ except ValueError as exc:
203
+ return None, str(exc)
204
+ edge, err = await run_in_threadpool(_link)
205
+ if err is not None or edge is None:
206
+ return JSONResponse({"error": err or "link failed"}, status_code=400)
207
+ await manager.broadcast({"type": "edge_linked", "edge": edge.to_dict()})
208
+ return edge.to_dict()
209
+
210
+
211
+ @app.websocket("/ws")
212
+ async def ws_endpoint(ws: WebSocket):
213
+ if access.policy_path():
214
+ await ws.close(code=1008)
215
+ return
216
+ await manager.connect(ws)
217
+ try:
218
+ while True:
219
+ # Audit #29: catching only WebSocketDisconnect left zombies behind
220
+ # (binary frame → KeyError, TCP reset → RuntimeError) — the socket
221
+ # stayed in manager.active forever and was never closed.
222
+ await ws.receive_text()
223
+ except WebSocketDisconnect:
224
+ manager.disconnect(ws)
225
+ except Exception:
226
+ manager.disconnect(ws)
227
+ try:
228
+ await ws.close()
229
+ except Exception:
230
+ pass
231
+
232
+
233
+ @app.post("/api/edge/{edge_id}/undo")
234
+ async def undo_edge(edge_id: str):
235
+ edge = await run_in_threadpool(runtime.make_engine().undo, edge_id)
236
+ if edge is None:
237
+ return JSONResponse({"error": "edge not found or already resolved"}, status_code=409)
238
+ await manager.broadcast({"type": "edge_restored", "edge": edge.to_dict()})
239
+ return edge.to_dict()
@@ -0,0 +1,42 @@
1
+ """Similarity: cosine similarity + k nearest neighbors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+
8
+ def cosine(a: list[float], b: list[float]) -> float:
9
+ """Cosine similarity. Audit #8: mismatched dimensions are a data error
10
+ (e.g. 384-dim ST vector vs. 64-dim hash vector in one brain) — a silent
11
+ truncation to the shorter length produces plausible-looking scores and can
12
+ trigger a wrong auto-merge at the dedup threshold. Therefore: hard fail."""
13
+ if len(a) != len(b):
14
+ raise ValueError(
15
+ f"cosine: dimension mismatch ({len(a)} vs {len(b)}) — "
16
+ "inhomogeneous embedders in the same brain? Vectors are not comparable.")
17
+ dot = sum(x * y for x, y in zip(a, b))
18
+ na = math.sqrt(sum(x * x for x in a))
19
+ nb = math.sqrt(sum(x * x for x in b))
20
+ if na == 0.0 or nb == 0.0:
21
+ return 0.0
22
+ return dot / (na * nb)
23
+
24
+
25
+ def knn(query: list[float], candidates: dict[str, list[float]], k: int = 3) -> list[tuple[str, float]]:
26
+ """Returns the k nearest neighbors as (id, similarity), sorted descending.
27
+
28
+ Audit #8 follow-up (fix wave 2): foreign-dimension candidates are
29
+ skipped instead of crashing the whole query — the same two-level
30
+ decision as in _find_duplicate: the primitive cosine() is strict,
31
+ the call sites are tolerant. A brain with a few legacy vectors of the
32
+ wrong dimension (e.g. after an embedder switch) degrades cleanly to the
33
+ compatible neighbors.
34
+ Audit #60: k<=0 returns [] instead of all items (k=0) or the last one
35
+ being dropped (k=-1) — a limit means limit."""
36
+ if k <= 0:
37
+ return []
38
+ query_dim = len(query)
39
+ scored = [(nid, cosine(query, vec)) for nid, vec in candidates.items()
40
+ if vec and len(vec) == query_dim]
41
+ scored.sort(key=lambda t: t[1], reverse=True)
42
+ return scored[:k]
graph_engine/store.py ADDED
@@ -0,0 +1,63 @@
1
+ """JSONL store: one JSON line per object. No DB overhead."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .model import Node, Edge
9
+
10
+
11
+ class Store:
12
+ """Holds nodes and edges as JSONL (separate files)."""
13
+
14
+ def __init__(self, path: str | Path = "data.jsonl"):
15
+ self.path = Path(path)
16
+ self.edges_path = self.path.with_suffix(".edges.jsonl")
17
+ self.nodes: list[Node] = []
18
+ self.edges: list[Edge] = []
19
+ self._load()
20
+
21
+ def _load(self) -> None:
22
+ if self.path.exists():
23
+ for line in self.path.read_text(encoding="utf-8").splitlines():
24
+ if line.strip():
25
+ self.nodes.append(Node.from_dict(json.loads(line)))
26
+ if self.edges_path.exists():
27
+ for line in self.edges_path.read_text(encoding="utf-8").splitlines():
28
+ if line.strip():
29
+ self.edges.append(Edge.from_dict(json.loads(line)))
30
+
31
+ def add_node(self, node: Node) -> None:
32
+ self.nodes.append(node)
33
+ with self.path.open("a", encoding="utf-8") as f:
34
+ f.write(json.dumps(node.to_dict(), ensure_ascii=False) + "\n")
35
+
36
+ def add_edge(self, edge: Edge) -> None:
37
+ self.edges.append(edge)
38
+ with self.edges_path.open("a", encoding="utf-8") as f:
39
+ f.write(json.dumps(edge.to_dict(), ensure_ascii=False) + "\n")
40
+
41
+ def resolve_edge(self, edge_id: str, accept: bool) -> Edge | None:
42
+ """Accept a pending edge (pending=False) or discard it (remove)."""
43
+ edge = next((e for e in self.edges if e.id == edge_id), None)
44
+ if edge is None or not edge.pending:
45
+ return None
46
+ if accept:
47
+ edge.pending = False
48
+ self._rewrite_edges()
49
+ return edge
50
+ self.edges.remove(edge)
51
+ self._rewrite_edges()
52
+ return edge
53
+
54
+ def _rewrite_edges(self) -> None:
55
+ with self.edges_path.open("w", encoding="utf-8") as f:
56
+ for e in self.edges:
57
+ f.write(json.dumps(e.to_dict(), ensure_ascii=False) + "\n")
58
+
59
+ def graph_state(self) -> dict:
60
+ return {
61
+ "nodes": [n.to_dict() for n in self.nodes],
62
+ "edges": [e.to_dict() for e in self.edges],
63
+ }
@@ -0,0 +1,63 @@
1
+ """Edge suggestions: the k nearest neighbors become typed suggestions.
2
+
3
+ Rules (V2#3 — confidence bands instead of bare thresholds):
4
+ - sim >= 0.95 → "similar", AUTO-ACCEPT (pending=False), confidence=sim
5
+ - 0.75 <= sim < 0.95 → "similar", pending
6
+ - 0.45 <= sim < 0.75 → "extends", pending
7
+ - sim < 0.45 → no suggestion
8
+
9
+ Every suggestion carries a confidence (the cosine similarity). The caller
10
+ (BrainEngine) decides from the band + env override whether it stays pending.
11
+
12
+ "same_as" is never suggested automatically — it is created manually
13
+ (CLI `link`, cockpit) for translation/alias pairs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ from .similarity import knn
21
+
22
+ THRESHOLD_SIMILAR = 0.75
23
+ THRESHOLD_EXTEND = 0.45
24
+ AUTO_ACCEPT_CONFIDENCE = 0.95
25
+
26
+
27
+ @dataclass
28
+ class Suggestion:
29
+ source: str
30
+ target: str
31
+ kind: str
32
+ confidence: float = 0.0
33
+
34
+
35
+ def suggest(source_id: str, query_vec: list[float],
36
+ candidates: dict[str, list[float]], k: int = 3) -> list[Suggestion]:
37
+ out: list[Suggestion] = []
38
+ seen: set[str] = set()
39
+ for nid, sim in knn(query_vec, candidates, k):
40
+ if nid == source_id or nid in seen:
41
+ continue
42
+ if sim >= THRESHOLD_SIMILAR:
43
+ kind = "similar"
44
+ elif sim >= THRESHOLD_EXTEND:
45
+ kind = "extends"
46
+ else:
47
+ continue
48
+ seen.add(nid)
49
+ out.append(Suggestion(source=source_id, target=nid, kind=kind, confidence=sim))
50
+ return out
51
+
52
+
53
+ def is_auto_accept(confidence: float) -> bool:
54
+ """Confidence band: >=0.95 is accepted without HITL."""
55
+ return confidence >= AUTO_ACCEPT_CONFIDENCE
56
+
57
+
58
+ # Backwards-compatible for the JSONL engine (v0.0.1)
59
+ def suggest_edges(source_id: str, query_vec: list[float],
60
+ candidates: dict[str, list[float]], k: int = 3):
61
+ from .model import Edge
62
+ return [Edge(source=s.source, target=s.target, kind=s.kind, pending=True)
63
+ for s in suggest(source_id, query_vec, candidates, k)]
@@ -0,0 +1,216 @@
1
+ """Temporal reasoning (Phase 2): bi-temporal queries over the graph.
2
+
3
+ The engine has bi-temporal edges (valid_from/valid_to) since the Zep/Graphiti
4
+ lesson, but no way to QUERY them. This module adds the read side:
5
+
6
+ valid_at(brain, timestamp) — the graph as it was at a point in time
7
+ history(brain, node_id) — how a node's edges evolved over time
8
+ when(engine, query, at) — retrieval restricted to what was known then
9
+
10
+ All functions are read-only. They never modify the brain.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from .brain import Brain, Edge, Node
20
+ from .brain_engine import BrainEngine
21
+
22
+
23
+ def _parse_ts(ts: str) -> datetime.datetime:
24
+ """Parse ISO-8601 timestamp, tolerating 'Z' suffix."""
25
+ return datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
26
+
27
+
28
+ def _is_valid_at(edge: "Edge", ts: datetime.datetime) -> bool:
29
+ """Was this edge valid at the given timestamp?
30
+
31
+ An edge is valid at T when:
32
+ - valid_from <= T (it had been created by then)
33
+ - valid_to is None OR valid_to > T (its validity had not ended)
34
+ - invalidated_at is None OR invalidated_at > T
35
+ """
36
+ return edge.is_valid_at(ts)
37
+
38
+
39
+ def valid_at(brain: "Brain", timestamp: str) -> dict:
40
+ """The graph as it was at a point in time.
41
+
42
+ Returns {nodes: [...], edges: [...]} — only nodes and edges that were
43
+ live at the given timestamp. Nodes are included if they existed (created
44
+ <= T) and were not yet tombstoned. Edges are included if they were valid
45
+ at T (see _is_valid_at).
46
+ """
47
+ try:
48
+ ts = _parse_ts(timestamp)
49
+ except (ValueError, TypeError):
50
+ raise ValueError(f"Invalid timestamp: {timestamp!r}")
51
+
52
+ nodes: list[Node] = []
53
+ for n in brain.read_nodes():
54
+ if n.status == "tombstone":
55
+ continue
56
+ try:
57
+ created = _parse_ts(n.created) if n.created else None
58
+ except (ValueError, TypeError):
59
+ created = None
60
+ if created and created > ts:
61
+ continue
62
+ nodes.append(n)
63
+
64
+ edges: list[Edge] = []
65
+ for e in brain.read_edges(include_rejected=True):
66
+ if e.pending or e.rejected:
67
+ continue
68
+ if _is_valid_at(e, ts):
69
+ edges.append(e)
70
+
71
+ return {"nodes": nodes, "edges": edges}
72
+
73
+
74
+ def history(brain: "Brain", node_id: str) -> list[dict]:
75
+ """How a node's edges evolved over time.
76
+
77
+ Returns a chronological list of edge events (creation, expiry, invalidation)
78
+ involving this node, sorted by timestamp. Each entry has:
79
+ {timestamp, event, edge_id, kind, other_node, origin}
80
+ """
81
+ events: list[dict] = []
82
+ for e in brain.read_edges(include_rejected=True):
83
+ if node_id not in (e.source, e.target):
84
+ continue
85
+ other = e.target if e.source == node_id else e.source
86
+ # Creation event
87
+ events.append({
88
+ "timestamp": e.valid_from,
89
+ "event": "created",
90
+ "edge_id": e.id,
91
+ "kind": e.kind,
92
+ "other_node": other,
93
+ "origin": e.origin,
94
+ "rejected": e.rejected,
95
+ })
96
+ # Keep an extracted expiry separate from a later review invalidation.
97
+ endings = [(e.valid_to, "expired" if e.extracted_validity is not None else "invalidated")]
98
+ if e.extracted_validity is not None:
99
+ endings.append((e.invalidated_at, "invalidated"))
100
+ for timestamp, event in endings:
101
+ if not timestamp:
102
+ continue
103
+ events.append({
104
+ "timestamp": timestamp,
105
+ "event": event,
106
+ "edge_id": e.id,
107
+ "kind": e.kind,
108
+ "other_node": other,
109
+ "origin": e.origin,
110
+ "rejected": e.rejected,
111
+ })
112
+ events.sort(key=lambda x: x["timestamp"] or "")
113
+ return events
114
+
115
+
116
+ def when(engine: "BrainEngine", query: str, at: str, k: int = 5) -> list[tuple[str, float]]:
117
+ """Retrieval restricted to what was known at a point in time (Phase 2).
118
+
119
+ Runs the normal hybrid retrieval, then filters the results to only
120
+ include nodes that existed at the given timestamp. This is the
121
+ "what did I know then?" query — useful for understanding how the
122
+ brain's knowledge has evolved.
123
+ """
124
+ from .retrieval import retrieve
125
+
126
+ # Parse timestamp FIRST — an invalid timestamp must raise even when
127
+ # there are no results to filter.
128
+ try:
129
+ ts = _parse_ts(at)
130
+ except (ValueError, TypeError):
131
+ raise ValueError(f"Invalid timestamp: {at!r}")
132
+
133
+ # Get the full ranking (no tracking — this is a historical query)
134
+ results = retrieve(engine, query, k=k * 3, track=False)
135
+ if not results:
136
+ return []
137
+
138
+ # Filter to nodes that existed at the timestamp
139
+ filtered: list[tuple[str, float]] = []
140
+ for node_id, score in results:
141
+ node = engine.brain.read_node(node_id)
142
+ if node is None:
143
+ continue
144
+ if node.status == "tombstone":
145
+ continue
146
+ try:
147
+ created = _parse_ts(node.created) if node.created else None
148
+ except (ValueError, TypeError):
149
+ created = None
150
+ if created and created > ts:
151
+ continue
152
+ filtered.append((node_id, score))
153
+ if len(filtered) >= k:
154
+ break
155
+
156
+ return filtered
157
+
158
+
159
+ def edge_timeline(brain: "Brain", *, since: str | None = None,
160
+ until: str | None = None) -> list[dict]:
161
+ """All edge events (created/expired/invalidated) in a time range.
162
+
163
+ Useful for "what changed in the brain recently?" — the temporal
164
+ equivalent of `ig report --since`.
165
+ """
166
+ events: list[dict] = []
167
+ for e in brain.read_edges(include_rejected=True):
168
+ if e.pending or e.rejected:
169
+ continue
170
+ try:
171
+ vf = _parse_ts(e.valid_from) if e.valid_from else None
172
+ except (ValueError, TypeError):
173
+ vf = None
174
+
175
+ if vf:
176
+ if since and vf < _parse_ts(since):
177
+ pass # still include, filtered below
178
+ if until and vf > _parse_ts(until):
179
+ pass
180
+ events.append({
181
+ "timestamp": e.valid_from,
182
+ "event": "created",
183
+ "edge_id": e.id,
184
+ "kind": e.kind,
185
+ "source": e.source,
186
+ "target": e.target,
187
+ "origin": e.origin,
188
+ })
189
+ endings = [(e.valid_to, "expired" if e.extracted_validity is not None else "invalidated")]
190
+ if e.extracted_validity is not None:
191
+ endings.append((e.invalidated_at, "invalidated"))
192
+ for timestamp, event in endings:
193
+ if not timestamp:
194
+ continue
195
+ try:
196
+ _parse_ts(timestamp)
197
+ except (ValueError, TypeError):
198
+ continue
199
+ events.append({
200
+ "timestamp": timestamp,
201
+ "event": event,
202
+ "edge_id": e.id,
203
+ "kind": e.kind,
204
+ "source": e.source,
205
+ "target": e.target,
206
+ "origin": e.origin,
207
+ })
208
+
209
+ # Filter by range
210
+ if since:
211
+ events = [e for e in events if (e["timestamp"] or "") >= since]
212
+ if until:
213
+ events = [e for e in events if (e["timestamp"] or "") <= until]
214
+
215
+ events.sort(key=lambda x: x["timestamp"] or "")
216
+ return events