deepagents-graph-memory 0.1.0__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.
@@ -0,0 +1,936 @@
1
+ # - Finds context for a question, then follows useful connections to fill in the story.
2
+ # - Stops when the next connection is unhelpful or the answer reaches its size limit.
3
+ # - Tests: test_recall.py covers finding matches, following connections, and stopping;
4
+ # test_trace.py checks bringing back the reasons and outcomes behind past work.
5
+
6
+ """Adaptive graph memory recall."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from collections.abc import Iterable, Sequence
14
+ from dataclasses import dataclass
15
+ from typing import Literal, cast
16
+
17
+ from deepagents_graph_memory.errors import GraphMemoryPathError, GraphMemoryValidationError
18
+ from deepagents_graph_memory.paths import node_path, parse_graph_path
19
+ from deepagents_graph_memory.stores import GraphEdge, GraphNode, GraphStoreAdapter, JsonValue, finding_observed_timestamp, valid_finding_link
20
+
21
+ RecallMode = Literal["auto", "local", "deep"]
22
+
23
+ _VALID_MODES = {"auto", "local", "deep"}
24
+ _TERM_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]*")
25
+ _MAX_QUERY_LENGTH = 1000
26
+ _MAX_DEPTH = 10
27
+ _TRACE_COMPONENTS = {"Situation": "situation", "Rationale": "rationale", "Action": "action", "Outcome": "outcome"}
28
+ _GENERATED_TRACE_LINKS = {"HAS_SITUATION", "HAS_RATIONALE", "HAS_ACTION", "HAS_OUTCOME", "LED_TO", "JUSTIFIED", "PRODUCED"}
29
+
30
+
31
+ @dataclass(frozen=True, order=True)
32
+ class _NodeRef:
33
+ label: str
34
+ node_id: str
35
+
36
+
37
+ @dataclass
38
+ class _NodeRecord:
39
+ node: GraphNode
40
+ distance: int
41
+ score: int
42
+
43
+
44
+ @dataclass
45
+ class _EdgeRecord:
46
+ edge: GraphEdge
47
+ distance: int
48
+ score: int
49
+
50
+
51
+ @dataclass
52
+ class _RecallState:
53
+ nodes: dict[_NodeRef, _NodeRecord]
54
+ edges: dict[tuple[str, str, str, str, str], _EdgeRecord]
55
+ search_truncated: bool = False
56
+ truncated_nodes: bool = False
57
+ truncated_edges: bool = False
58
+ stopped_reason: str = ""
59
+ related_findings: bool = False
60
+ related_incomplete: bool = False
61
+ omitted_anchors: list[_NodeRef] | None = None
62
+ dependency_notices: list[str] | None = None
63
+ dependency_unknown: bool = False
64
+ dependency_unknown_roots: list[str] | None = None
65
+ explicit_anchors: set[_NodeRef] | None = None
66
+
67
+
68
+ def recall_graph_memory(
69
+ store: GraphStoreAdapter,
70
+ query: str,
71
+ *,
72
+ scope_key: str | None = None,
73
+ anchors: Sequence[str] | None = None,
74
+ mode: RecallMode = "auto",
75
+ token_budget: int = 2000,
76
+ max_depth: int = 3,
77
+ max_nodes: int = 50,
78
+ max_edges: int = 100,
79
+ ) -> str:
80
+ """Recall a relevant slice of graph memory.
81
+
82
+ Args:
83
+ store: Internal graph integration adapter.
84
+ query: Natural-language recall query.
85
+ scope_key: Optional scope key used by the configured backend.
86
+ anchors: Optional concrete starting hints such as file paths, run ids, task ids, or subagent ids.
87
+ mode: Recall expansion mode. `auto` expands while relevant, `local` reads one hop, and `deep` expands to `max_depth`.
88
+ token_budget: Approximate output token budget.
89
+ max_depth: Maximum traversal depth.
90
+ max_nodes: Maximum nodes to include.
91
+ max_edges: Maximum edges to include.
92
+
93
+ Returns:
94
+ Compact markdown with source graph paths.
95
+ """
96
+ query = _validate_recall_query(query)
97
+ anchors = [_validate_anchor(anchor) for anchor in anchors or []]
98
+ mode = _validate_mode(mode)
99
+ token_budget, max_depth, max_nodes, max_edges = _validate_budgets(token_budget, max_depth, max_nodes, max_edges)
100
+
101
+ terms = _query_terms(query)
102
+ state = _RecallState(nodes={}, edges={})
103
+ state.explicit_anchors = {ref for anchor in anchors if (ref := _seed_from_anchor(anchor)) is not None}
104
+ seeds = _find_seed_nodes(store, query, terms, anchors=anchors, scope_key=scope_key, limit=min(max_nodes, 20), state=state)
105
+ if not seeds:
106
+ return _render_recall(query, state, token_budget=token_budget)
107
+
108
+ processed_subjects: set[str] = set()
109
+ anchored_refs = {_seed_from_anchor(anchor) for anchor in anchors}
110
+ _expand_subject_findings(
111
+ store,
112
+ seeds,
113
+ state,
114
+ processed_subjects=processed_subjects,
115
+ scope_key=scope_key,
116
+ max_nodes=max_nodes,
117
+ max_edges=max_edges,
118
+ anchored_refs=anchored_refs,
119
+ )
120
+
121
+ for index, seed in enumerate(seeds):
122
+ node = store.get_node(seed.label, seed.node_id, scope_key=scope_key)
123
+ if node is None:
124
+ continue
125
+ _add_node(state, node, distance=0, score=100 - index, max_nodes=max_nodes)
126
+ if seed in anchored_refs and seed not in state.nodes:
127
+ state.related_incomplete = True
128
+ state.omitted_anchors = [*(state.omitted_anchors or []), seed]
129
+
130
+ target_depth = 1 if mode == "local" else max_depth
131
+ frontier = list(seeds)
132
+ seen_frontier = set(frontier)
133
+
134
+ for depth in range(1, target_depth + 1):
135
+ if not frontier:
136
+ state.stopped_reason = "No new graph neighbors remained to inspect."
137
+ break
138
+ next_frontier: list[_NodeRef] = []
139
+ layer_added_relevant_edge = False
140
+
141
+ for ref in frontier:
142
+ if len(state.edges) >= max_edges:
143
+ state.truncated_edges = True
144
+ break
145
+ neighborhood = store.get_neighbors(ref.label, ref.node_id, scope_key=scope_key, depth=1, max_nodes=max_nodes, max_edges=max_edges)
146
+ if neighborhood is None:
147
+ continue
148
+ if neighborhood.truncated_nodes:
149
+ state.truncated_nodes = True
150
+ if neighborhood.truncated_edges:
151
+ state.truncated_edges = True
152
+ _add_node(state, neighborhood.node, distance=depth - 1, score=_score_node(neighborhood.node, terms, query), max_nodes=max_nodes)
153
+
154
+ for edge in neighborhood.edges:
155
+ if len(state.edges) >= max_edges:
156
+ state.truncated_edges = True
157
+ break
158
+ edge_score = _score_edge(edge, terms, query)
159
+ include_edge = mode in {"deep", "local"} or depth == 1 or edge_score > 0
160
+ if not include_edge:
161
+ continue
162
+ _add_edge(state, edge, distance=depth, score=edge_score, max_edges=max_edges)
163
+ if edge_score > 0:
164
+ layer_added_relevant_edge = True
165
+ for neighbor in _edge_neighbors(edge, ref):
166
+ node = store.get_node(neighbor.label, neighbor.node_id, scope_key=scope_key)
167
+ if node is None:
168
+ node = GraphNode(label=neighbor.label, id=neighbor.node_id)
169
+ node_score = _score_node(node, terms, query)
170
+ _add_node(state, node, distance=depth, score=node_score, max_nodes=max_nodes)
171
+ should_expand = mode == "deep" or (mode == "auto" and (depth == 1 or edge_score > 0 or node_score > 0))
172
+ if should_expand and neighbor not in seen_frontier:
173
+ next_frontier.append(neighbor)
174
+ seen_frontier.add(neighbor)
175
+
176
+ if state.truncated_edges or state.truncated_nodes:
177
+ state.stopped_reason = "Stopped at the configured node or edge safety budget."
178
+ break
179
+ if mode == "auto" and depth > 1 and not layer_added_relevant_edge:
180
+ state.stopped_reason = "Stopped because the next hop did not add relevant graph facts."
181
+ break
182
+ frontier = next_frontier
183
+
184
+ if not state.stopped_reason:
185
+ state.stopped_reason = f"Reached traversal depth {target_depth}."
186
+ _expand_subject_findings(
187
+ store,
188
+ list(state.nodes),
189
+ state,
190
+ processed_subjects=processed_subjects,
191
+ scope_key=scope_key,
192
+ max_nodes=max_nodes,
193
+ max_edges=max_edges,
194
+ anchored_refs=anchored_refs,
195
+ )
196
+ _review_dependencies(store, state, scope_key=scope_key, max_depth=max_depth, max_nodes=max_nodes, max_edges=max_edges)
197
+ return _render_recall(query, state, token_budget=token_budget)
198
+
199
+
200
+ def _review_dependencies(
201
+ store: GraphStoreAdapter,
202
+ state: _RecallState,
203
+ *,
204
+ scope_key: str | None,
205
+ max_depth: int,
206
+ max_nodes: int,
207
+ max_edges: int,
208
+ ) -> None:
209
+ """Flag conclusions that rely on changed premises using focused, bounded graph links."""
210
+ roots = list(
211
+ dict.fromkeys(
212
+ _NodeRef("Trace", trace_id)
213
+ for ref, record in state.nodes.items()
214
+ if (trace_id := ref.node_id if ref.label == "Trace" else record.node.properties.get("trace_id")) is not None and isinstance(trace_id, str)
215
+ )
216
+ )
217
+ notices: list[str] = []
218
+ unknown_roots: list[str] = []
219
+ for root in roots:
220
+ inspected: set[str] = set()
221
+ read_nodes: set[str] = set()
222
+ used_edges = 0
223
+ unknown = False
224
+ has_dependencies = False
225
+ changes: set[tuple[str, str, str]] = set()
226
+
227
+ def visit(
228
+ trace_id: str,
229
+ path: set[str],
230
+ depth: int,
231
+ inspected: set[str] = inspected,
232
+ read_nodes: set[str] = read_nodes,
233
+ changes: set[tuple[str, str, str]] = changes,
234
+ ) -> None:
235
+ nonlocal has_dependencies, used_edges, unknown
236
+ if trace_id in path:
237
+ unknown = True
238
+ return
239
+ if trace_id in inspected:
240
+ return
241
+ if len(read_nodes) >= max_nodes and trace_id not in read_nodes:
242
+ unknown = True
243
+ return
244
+ trace = store.get_node("Trace", trace_id, scope_key=scope_key)
245
+ if trace is None:
246
+ unknown = True
247
+ return
248
+ inspected.add(trace_id)
249
+ read_nodes.add(trace_id)
250
+ if depth >= max_depth:
251
+ if store.list_trace_edges(trace_id, "BASED_ON", scope_key=scope_key, limit=1).items:
252
+ has_dependencies = True
253
+ unknown = True
254
+ return
255
+ remaining = max_edges - used_edges
256
+ if remaining <= 0:
257
+ unknown = True
258
+ return
259
+ dependencies = store.list_trace_edges(trace_id, "BASED_ON", scope_key=scope_key, limit=remaining)
260
+ has_dependencies |= bool(dependencies.items) or dependencies.truncated
261
+ unknown |= dependencies.truncated
262
+ used_edges += len(dependencies.items)
263
+ next_path = path | {trace_id}
264
+ for dependency in dependencies.items:
265
+ premise_id = dependency.target_id
266
+ if len(read_nodes) >= max_nodes and premise_id not in read_nodes:
267
+ unknown = True
268
+ continue
269
+ premise = store.get_node("Trace", premise_id, scope_key=scope_key)
270
+ if premise is None:
271
+ unknown = True
272
+ continue
273
+ read_nodes.add(premise_id)
274
+ _add_node(state, trace, distance=0, score=125, max_nodes=max_nodes)
275
+ _add_node(state, premise, distance=1, score=125, max_nodes=max_nodes)
276
+ _add_edge(state, dependency, distance=1, score=125, max_edges=max_edges)
277
+ if _NodeRef("Trace", premise_id) not in state.nodes:
278
+ unknown = True
279
+ if ("Trace", trace_id, "BASED_ON", "Trace", premise_id) not in state.edges:
280
+ unknown = True
281
+ if not (premise.properties.get("evidence") or premise.properties.get("evidence_refs") or premise.properties.get("depends_on")) or (
282
+ premise.properties.get("finding_type") == "state" and finding_observed_timestamp(premise) is None
283
+ ):
284
+ unknown = True
285
+ for relationship in ("SUPERSEDES", "RESOLVES"):
286
+ remaining = max_edges - used_edges
287
+ if remaining <= 0:
288
+ unknown = True
289
+ break
290
+ updates = store.list_trace_edges(premise_id, relationship, incoming=True, scope_key=scope_key, limit=remaining)
291
+ unknown |= updates.truncated
292
+ used_edges += len(updates.items)
293
+ for update in updates.items:
294
+ if len(read_nodes) >= max_nodes and update.source_id not in read_nodes:
295
+ unknown = True
296
+ continue
297
+ successor = store.get_node("Trace", update.source_id, scope_key=scope_key)
298
+ if successor is None:
299
+ unknown = True
300
+ continue
301
+ read_nodes.add(successor.id)
302
+ reviewed_count = 0
303
+ if relationship == "RESOLVES":
304
+ remaining = max_edges - used_edges
305
+ if remaining <= 0:
306
+ unknown = True
307
+ continue
308
+ reviewed = store.list_trace_edges(successor.id, "RESOLVES", scope_key=scope_key, limit=remaining)
309
+ used_edges += len(reviewed.items)
310
+ for reviewed_edge in reviewed.items:
311
+ if len(read_nodes) >= max_nodes and reviewed_edge.target_id not in read_nodes:
312
+ unknown = True
313
+ break
314
+ target = store.get_node("Trace", reviewed_edge.target_id, scope_key=scope_key)
315
+ if target is None:
316
+ unknown = True
317
+ continue
318
+ read_nodes.add(target.id)
319
+ reviewed_count += target.properties.get("subject") == successor.properties.get("subject")
320
+ unknown |= reviewed.truncated and reviewed_count < 2
321
+ if valid_finding_link(successor, premise, relationship, reviewed_count=reviewed_count):
322
+ changes.add((premise_id, successor.id, relationship))
323
+ if store.list_trace_edges(successor.id, "BASED_ON", scope_key=scope_key, limit=1).items:
324
+ unknown = True
325
+ _add_node(state, successor, distance=1, score=125, max_nodes=max_nodes)
326
+ _add_edge(state, update, distance=1, score=125, max_edges=max_edges)
327
+ if _NodeRef("Trace", successor.id) not in state.nodes:
328
+ unknown = True
329
+ if ("Trace", successor.id, relationship, "Trace", premise_id) not in state.edges:
330
+ unknown = True
331
+ visit(premise_id, next_path, depth + 1)
332
+
333
+ visit(root.node_id, set(), 0)
334
+ if changes:
335
+ details = ", ".join(f"{old} updated by {new} via {relationship}" for old, new, relationship in sorted(changes))
336
+ notices.append(f"Trace {root.node_id} needs recheck: supporting premise changed ({details}). The conclusion remains recorded.")
337
+ if has_dependencies and unknown:
338
+ state.dependency_unknown = True
339
+ unknown_roots.append(root.node_id)
340
+ state.dependency_notices = notices
341
+ state.dependency_unknown_roots = unknown_roots
342
+
343
+
344
+ def _expand_subject_findings(
345
+ store: GraphStoreAdapter,
346
+ seeds: Sequence[_NodeRef],
347
+ state: _RecallState,
348
+ *,
349
+ processed_subjects: set[str],
350
+ scope_key: str | None,
351
+ max_nodes: int,
352
+ max_edges: int,
353
+ anchored_refs: set[_NodeRef | None],
354
+ ) -> None:
355
+ """Bring findings for a retrieved subject together before normal traversal uses budgets."""
356
+ subjects: set[str] = set()
357
+ for seed in seeds:
358
+ node = store.get_node(seed.label, seed.node_id, scope_key=scope_key)
359
+ if node is None:
360
+ continue
361
+ if seed.label == "Subject":
362
+ subjects.add(seed.node_id)
363
+ continue
364
+ trace = node if seed.label == "Trace" else None
365
+ trace_id = node.properties.get("trace_id")
366
+ if trace is None and isinstance(trace_id, str):
367
+ trace = store.get_node("Trace", trace_id, scope_key=scope_key)
368
+ if trace is None and seed.label in {"Artifact", "Evidence", "EvidenceSource"}:
369
+ neighbors = store.get_neighbors(seed.label, seed.node_id, scope_key=scope_key, max_nodes=max_nodes, max_edges=max_edges)
370
+ if neighbors is not None:
371
+ state.related_incomplete |= neighbors.truncated_nodes or neighbors.truncated_edges
372
+ for edge in neighbors.edges:
373
+ for ref in _edge_neighbors(edge, seed):
374
+ connected = store.get_node(ref.label, ref.node_id, scope_key=scope_key)
375
+ if connected is None:
376
+ continue
377
+ connected_trace_id = connected.id if connected.label == "Trace" else connected.properties.get("trace_id")
378
+ if isinstance(connected_trace_id, str):
379
+ connected_trace = store.get_node("Trace", connected_trace_id, scope_key=scope_key)
380
+ connected_subject = connected_trace.properties.get("subject") if connected_trace is not None else None
381
+ if isinstance(connected_subject, str):
382
+ subjects.add(f"subject-{hashlib.sha256(connected_subject.encode('utf-8')).hexdigest()}")
383
+ subject = trace.properties.get("subject") if trace is not None else None
384
+ if isinstance(subject, str):
385
+ subjects.add(f"subject-{hashlib.sha256(subject.encode('utf-8')).hexdigest()}")
386
+
387
+ for subject_id in sorted(subjects):
388
+ if subject_id in processed_subjects:
389
+ continue
390
+ processed_subjects.add(subject_id)
391
+ subject = store.get_node("Subject", subject_id, scope_key=scope_key)
392
+ if subject is None:
393
+ continue
394
+ state.related_findings = True
395
+ anchored_traces = [
396
+ ref
397
+ for ref in seeds
398
+ if ref in anchored_refs
399
+ and ref.label == "Trace"
400
+ and (anchor := store.get_node("Trace", ref.node_id, scope_key=scope_key)) is not None
401
+ and anchor.properties.get("subject") == subject.properties.get("value")
402
+ ]
403
+ available = max_nodes - len(state.nodes)
404
+ reserve_anchor = bool(anchored_traces and available > 1)
405
+ selected = store.list_subject_trace_ids(subject_id, scope_key=scope_key, limit=max(available - int(reserve_anchor), 0))
406
+ state.related_incomplete |= selected.truncated
407
+ trace_ids = selected.items
408
+ if reserve_anchor:
409
+ trace_ids.extend(ref.node_id for ref in anchored_traces if ref.node_id not in trace_ids)
410
+ for trace_id in trace_ids:
411
+ trace = store.get_node("Trace", trace_id, scope_key=scope_key)
412
+ if trace is None:
413
+ state.related_incomplete = True
414
+ continue
415
+ _add_node(state, trace, distance=1, score=110, max_nodes=max_nodes)
416
+ if _NodeRef("Trace", trace_id) not in state.nodes:
417
+ state.related_incomplete = True
418
+ if len(state.nodes) < max_nodes:
419
+ _add_node(state, subject, distance=0, score=120, max_nodes=max_nodes)
420
+ for trace_id in trace_ids:
421
+ trace_links = store.get_neighbors("Trace", trace_id, scope_key=scope_key, max_nodes=max_nodes, max_edges=max(max_edges, 100))
422
+ if trace_links is None:
423
+ continue
424
+ if trace_links.truncated_edges:
425
+ state.related_incomplete = True
426
+ for link in trace_links.edges:
427
+ if link.relationship in {"SUPERSEDES", "RESOLVES"} and link.source_id == trace_id:
428
+ if _NodeRef("Trace", link.target_id) in state.nodes:
429
+ _add_edge(state, link, distance=1, score=110, max_edges=max_edges)
430
+ else:
431
+ state.related_incomplete = True
432
+ elif link.relationship == "ABOUT" and _NodeRef("Subject", subject_id) in state.nodes:
433
+ _add_edge(state, link, distance=1, score=110, max_edges=max_edges)
434
+
435
+
436
+ def _find_seed_nodes(
437
+ store: GraphStoreAdapter,
438
+ query: str,
439
+ terms: set[str],
440
+ *,
441
+ anchors: Sequence[str],
442
+ scope_key: str | None,
443
+ limit: int,
444
+ state: _RecallState,
445
+ ) -> list[_NodeRef]:
446
+ seeds: list[_NodeRef] = []
447
+ seen: set[_NodeRef] = set()
448
+ for anchor in anchors:
449
+ ref = _seed_from_anchor(anchor)
450
+ if ref is None or ref in seen:
451
+ continue
452
+ if store.get_node(ref.label, ref.node_id, scope_key=scope_key) is None:
453
+ continue
454
+ seeds.append(ref)
455
+ seen.add(ref)
456
+ if len(seeds) >= limit:
457
+ return seeds
458
+ for search_query in _search_queries(query, terms, anchors):
459
+ result = store.search(search_query, scope_key=scope_key, limit=limit)
460
+ state.search_truncated = state.search_truncated or result.truncated
461
+ for item in result.items:
462
+ try:
463
+ parsed = parse_graph_path(item.path)
464
+ except GraphMemoryPathError:
465
+ continue
466
+ if parsed.kind != "node" or parsed.label is None or parsed.node_id is None:
467
+ continue
468
+ ref = _NodeRef(parsed.label, parsed.node_id)
469
+ if ref not in seen:
470
+ seeds.append(ref)
471
+ seen.add(ref)
472
+ if len(seeds) >= limit:
473
+ return seeds
474
+ return seeds
475
+
476
+
477
+ def _seed_from_anchor(anchor: str) -> _NodeRef | None:
478
+ try:
479
+ parsed = parse_graph_path(anchor)
480
+ except GraphMemoryPathError:
481
+ return None
482
+ if parsed.kind != "node" or parsed.label is None or parsed.node_id is None:
483
+ return None
484
+ return _NodeRef(parsed.label, parsed.node_id)
485
+
486
+
487
+ def _search_queries(query: str, terms: set[str], anchors: Sequence[str]) -> list[str]:
488
+ queries = list(anchors)
489
+ queries.append(query)
490
+ queries.extend(sorted(terms, key=lambda item: (-len(item), item)))
491
+ result: list[str] = []
492
+ seen: set[str] = set()
493
+ for item in queries:
494
+ normalized = item.strip()
495
+ if normalized and normalized.casefold() not in seen:
496
+ result.append(normalized)
497
+ seen.add(normalized.casefold())
498
+ return result
499
+
500
+
501
+ def _add_node(state: _RecallState, node: GraphNode, *, distance: int, score: int, max_nodes: int) -> None:
502
+ ref = _NodeRef(node.label, node.id)
503
+ existing = state.nodes.get(ref)
504
+ if existing is None:
505
+ if len(state.nodes) >= max_nodes:
506
+ state.truncated_nodes = True
507
+ return
508
+ state.nodes[ref] = _NodeRecord(node=node, distance=distance, score=score)
509
+ return
510
+ existing.distance = min(existing.distance, distance)
511
+ existing.score = max(existing.score, score)
512
+ if existing.node.properties == {} and node.properties:
513
+ existing.node = node
514
+
515
+
516
+ def _add_edge(state: _RecallState, edge: GraphEdge, *, distance: int, score: int, max_edges: int) -> None:
517
+ key = (edge.source_label, edge.source_id, edge.relationship, edge.target_label, edge.target_id)
518
+ existing = state.edges.get(key)
519
+ if existing is None:
520
+ if len(state.edges) >= max_edges:
521
+ state.truncated_edges = True
522
+ return
523
+ state.edges[key] = _EdgeRecord(edge=edge, distance=distance, score=score)
524
+ return
525
+ existing.distance = min(existing.distance, distance)
526
+ existing.score = max(existing.score, score)
527
+
528
+
529
+ def _edge_neighbors(edge: GraphEdge, source_ref: _NodeRef) -> list[_NodeRef]:
530
+ source = _NodeRef(edge.source_label, edge.source_id)
531
+ target = _NodeRef(edge.target_label, edge.target_id)
532
+ if source == source_ref:
533
+ return [target]
534
+ if target == source_ref:
535
+ return [source]
536
+ return [source, target]
537
+
538
+
539
+ def _score_node(node: GraphNode, terms: set[str], query: str) -> int:
540
+ return _score_text(" ".join([node.label, node.id, _properties_text(node.properties)]), terms, query)
541
+
542
+
543
+ def _score_edge(edge: GraphEdge, terms: set[str], query: str) -> int:
544
+ return _score_text(
545
+ " ".join(
546
+ [
547
+ edge.source_label,
548
+ edge.source_id,
549
+ edge.relationship,
550
+ edge.target_label,
551
+ edge.target_id,
552
+ _properties_text(edge.properties),
553
+ ]
554
+ ),
555
+ terms,
556
+ query,
557
+ )
558
+
559
+
560
+ def _score_text(text: str, terms: set[str], query: str) -> int:
561
+ haystack = text.casefold()
562
+ score = 0
563
+ if query.casefold() in haystack:
564
+ score += 5
565
+ for term in terms:
566
+ if term in haystack:
567
+ score += 1
568
+ return score
569
+
570
+
571
+ def _properties_text(properties: dict[str, JsonValue]) -> str:
572
+ public = {key: value for key, value in properties.items() if key not in {"scope_key", "created_at", "updated_at", "search_text"}}
573
+ return json.dumps(public, sort_keys=True)
574
+
575
+
576
+ def _query_terms(query: str) -> set[str]:
577
+ terms: set[str] = set()
578
+ for raw in _TERM_RE.findall(query.casefold()):
579
+ if len(raw) < 2:
580
+ continue
581
+ terms.add(raw)
582
+ terms.add(raw.replace("-", "_"))
583
+ if raw.endswith("s") and len(raw) > 3:
584
+ terms.add(raw[:-1])
585
+ return terms
586
+
587
+
588
+ def _render_recall(query: str, state: _RecallState, *, token_budget: int) -> str:
589
+ lines = [f"# Graph Memory Recall: {query}", ""]
590
+ if state.dependency_notices:
591
+ lines.extend(["## Dependency review", *state.dependency_notices, ""])
592
+ if state.dependency_unknown_roots:
593
+ lines.extend(
594
+ ["## Dependency coverage", *(f"Trace {trace_id}: dependency status unknown." for trace_id in state.dependency_unknown_roots), ""]
595
+ )
596
+ if not state.nodes and not state.edges:
597
+ lines.append("No matching graph memory found.")
598
+ return "\n".join(lines).rstrip() + "\n"
599
+
600
+ if state.related_findings:
601
+ for ref in state.omitted_anchors or []:
602
+ path = _prefixed(node_path(ref.label, ref.node_id))
603
+ lines.append(f"Anchor omitted from bounded history: `{path}`. Read it directly for earlier context.")
604
+ if state.omitted_anchors:
605
+ lines.append("")
606
+ lines.extend(_finding_history(state))
607
+
608
+ covered_traces = {
609
+ ref.node_id
610
+ for ref, record in state.nodes.items()
611
+ if ref.label == "Trace" and state.related_findings and isinstance(record.node.properties.get("subject"), str)
612
+ }
613
+ rendered_traces = {
614
+ ref.node_id
615
+ for ref, record in state.nodes.items()
616
+ if ref.label == "Trace"
617
+ and record.node.properties.get("kind") == "reasoning_trace"
618
+ and all(field in record.node.properties for field in _TRACE_COMPONENTS.values())
619
+ }
620
+ hidden_components = _redundant_trace_components(state, rendered_traces)
621
+ visible_nodes = [
622
+ record for ref, record in state.nodes.items() if ref not in hidden_components and not (ref.label == "Trace" and ref.node_id in covered_traces)
623
+ ]
624
+ visible_edges = [record for record in state.edges.values() if not _redundant_trace_link(record.edge, state, hidden_components)]
625
+
626
+ if visible_nodes:
627
+ lines.append("## Nodes")
628
+ for record in _sorted_nodes(visible_nodes):
629
+ path = _prefixed(node_path(record.node.label, record.node.id))
630
+ suffix = _property_suffix(record.node.properties)
631
+ if record.node.label == "Trace" and _has_recorded_reasoning_chain(state, record.node.id):
632
+ suffix += "; Situation -LED_TO-> Rationale -JUSTIFIED-> Action -PRODUCED-> Outcome"
633
+ lines.append(f"- [{record.node.label}: {record.node.id}]({path}){suffix}")
634
+ lines.append("")
635
+
636
+ if visible_edges:
637
+ lines.append("## Relationships")
638
+ for record in _sorted_edges(visible_edges):
639
+ edge = record.edge
640
+ source_path = _prefixed(node_path(edge.source_label, edge.source_id))
641
+ target_path = _prefixed(node_path(edge.target_label, edge.target_id))
642
+ lines.append(
643
+ f"- [{edge.source_label}: {edge.source_id}]({source_path}) -[{edge.relationship}]-> "
644
+ f"[{edge.target_label}: {edge.target_id}]({target_path}){_generated_edge_detail(edge, state)}"
645
+ )
646
+ lines.append("")
647
+
648
+ if state.stopped_reason:
649
+ lines.append(f"Traversal note: {state.stopped_reason}")
650
+ notes = _truncation_notes(state)
651
+ if notes:
652
+ lines.extend(notes)
653
+ if state.search_truncated:
654
+ lines.append("Search results were truncated before traversal.")
655
+ incomplete = state.related_findings and (state.related_incomplete or state.truncated_nodes or state.truncated_edges)
656
+ prefix = ""
657
+ if state.dependency_unknown:
658
+ prefix += "Dependency status unknown; inspect dependencies.\n"
659
+ if state.dependency_notices:
660
+ prefix += "Some conclusions need recheck.\n"
661
+ available_tokens = max(1, token_budget - (len(prefix) + 3) // 4)
662
+ if state.related_findings and not incomplete and not state.search_truncated:
663
+ source_ids = {
664
+ ref["source_id"]
665
+ for record in state.nodes.values()
666
+ if record.node.label == "Trace"
667
+ if isinstance(record.node.properties.get("evidence_refs"), list)
668
+ for ref in record.node.properties.get("evidence_refs", [])
669
+ if isinstance(ref, dict) and isinstance(ref.get("source_id"), str)
670
+ }
671
+ count_line = f"Distinct cited source IDs in returned findings: {len(source_ids)}; independence not established."
672
+ if source_ids and sum(len(line) + 1 for line in [*lines, count_line]) <= max(available_tokens * 4, 80):
673
+ lines.append(count_line)
674
+ return prefix + _fit_token_budget(
675
+ lines,
676
+ available_tokens,
677
+ related_findings=state.related_findings,
678
+ related_incomplete=incomplete,
679
+ )
680
+
681
+
682
+ def _finding_history(state: _RecallState) -> list[str]:
683
+ traces = {
684
+ ref.node_id: record.node
685
+ for ref, record in state.nodes.items()
686
+ if ref.label == "Trace" and isinstance(record.node.properties.get("subject"), str)
687
+ }
688
+ successors: dict[str, list[str]] = {}
689
+ resolution_targets: dict[str, set[str]] = {}
690
+ for record in state.edges.values():
691
+ edge = record.edge
692
+ if edge.source_label != "Trace" or edge.target_label != "Trace":
693
+ continue
694
+ newer, older = traces.get(edge.source_id), traces.get(edge.target_id)
695
+ if newer is None or older is None:
696
+ continue
697
+ if edge.relationship == "SUPERSEDES" and valid_finding_link(newer, older, "SUPERSEDES"):
698
+ successors.setdefault(edge.target_id, []).append(edge.source_id)
699
+ elif edge.relationship == "RESOLVES" and valid_finding_link(newer, older, "RESOLVES", reviewed_count=2):
700
+ resolution_targets.setdefault(edge.source_id, set()).add(edge.target_id)
701
+ reviewed: dict[str, list[str]] = {}
702
+ for resolution_id, targets in resolution_targets.items():
703
+ if len(targets) >= 2:
704
+ for target_id in targets:
705
+ reviewed.setdefault(target_id, []).append(resolution_id)
706
+ ordered = sorted(
707
+ traces.values(),
708
+ key=lambda node: (
709
+ node.id in successors or node.id in reviewed,
710
+ finding_observed_timestamp(node) is None,
711
+ -(finding_observed_timestamp(node) or 0),
712
+ node.id,
713
+ ),
714
+ )
715
+ lines = ["## Finding history"]
716
+ for trace in ordered:
717
+ path = _prefixed(node_path("Trace", trace.id))
718
+ later = successors.get(trace.id, [])
719
+ resolutions = reviewed.get(trace.id, [])
720
+ statuses = []
721
+ if later:
722
+ statuses.append(
723
+ "superseded by " + ", ".join(f"[Trace: {node_id}]({_prefixed(node_path('Trace', node_id))})" for node_id in sorted(later))
724
+ )
725
+ if resolutions:
726
+ statuses.append(
727
+ "reviewed in resolution "
728
+ + ", ".join(f"[Trace: {node_id}]({_prefixed(node_path('Trace', node_id))})" for node_id in sorted(resolutions))
729
+ )
730
+ status = "; ".join(statuses) if statuses else "not superseded (requires comparison)"
731
+ if not statuses and state.related_incomplete:
732
+ status = "status unknown in partial history (requires comparison)"
733
+ observed = trace.properties.get("observed_at", "unknown")
734
+ details = _property_suffix({key: value for key, value in trace.properties.items() if key not in {"observed_at", "evidence_refs"}})
735
+ refs = trace.properties.get("evidence_refs")
736
+ citations = f"; cited sources: {json.dumps(refs, sort_keys=True)}" if refs else ""
737
+ lines.append(f"- [Trace: {trace.id}]({path}) — {status}; observed_at: {observed}{details}{citations}")
738
+ return [*lines, ""]
739
+
740
+
741
+ def _sorted_nodes(records: Iterable[_NodeRecord]) -> list[_NodeRecord]:
742
+ return sorted(records, key=lambda item: (-item.score, item.distance, item.node.label, item.node.id))
743
+
744
+
745
+ def _sorted_edges(records: Iterable[_EdgeRecord]) -> list[_EdgeRecord]:
746
+ return sorted(
747
+ records,
748
+ key=lambda item: (
749
+ -item.score,
750
+ item.distance,
751
+ item.edge.relationship,
752
+ item.edge.source_label,
753
+ item.edge.source_id,
754
+ item.edge.target_label,
755
+ item.edge.target_id,
756
+ ),
757
+ )
758
+
759
+
760
+ def _redundant_trace_components(state: _RecallState, rendered_traces: set[str]) -> set[_NodeRef]:
761
+ hidden: set[_NodeRef] = set()
762
+ for ref, record in state.nodes.items():
763
+ field = _TRACE_COMPONENTS.get(ref.label)
764
+ owner_id = record.node.properties.get("trace_id")
765
+ if field is None or not isinstance(owner_id, str) or owner_id not in rendered_traces:
766
+ continue
767
+ owner = state.nodes[_NodeRef("Trace", owner_id)].node
768
+ if ref.node_id != f"{owner_id}-{field}" or ref in (state.explicit_anchors or set()):
769
+ continue
770
+ if record.node.properties.get("text") != owner.properties.get(field):
771
+ continue
772
+ if all(
773
+ key in {"text", "trace_id", "created_at", "updated_at"} or owner.properties.get(key) == value
774
+ for key, value in record.node.properties.items()
775
+ ):
776
+ hidden.add(ref)
777
+ for record in state.edges.values():
778
+ edge = record.edge
779
+ owner_id = _generated_trace_link_owner(edge)
780
+ if owner_id is None or owner_id not in rendered_traces:
781
+ continue
782
+ owner = state.nodes[_NodeRef("Trace", owner_id)].node
783
+ if not _generated_edge_metadata_matches(edge, owner):
784
+ hidden.discard(_NodeRef(edge.source_label, edge.source_id))
785
+ hidden.discard(_NodeRef(edge.target_label, edge.target_id))
786
+ return hidden
787
+
788
+
789
+ def _generated_trace_link_owner(edge: GraphEdge) -> str | None:
790
+ if edge.relationship not in _GENERATED_TRACE_LINKS:
791
+ return None
792
+ owner_id = edge.properties.get("trace_id")
793
+ if not isinstance(owner_id, str):
794
+ return None
795
+ expected = {
796
+ "HAS_SITUATION": ("Trace", owner_id, "Situation", f"{owner_id}-situation"),
797
+ "HAS_RATIONALE": ("Trace", owner_id, "Rationale", f"{owner_id}-rationale"),
798
+ "HAS_ACTION": ("Trace", owner_id, "Action", f"{owner_id}-action"),
799
+ "HAS_OUTCOME": ("Trace", owner_id, "Outcome", f"{owner_id}-outcome"),
800
+ "LED_TO": ("Situation", f"{owner_id}-situation", "Rationale", f"{owner_id}-rationale"),
801
+ "JUSTIFIED": ("Rationale", f"{owner_id}-rationale", "Action", f"{owner_id}-action"),
802
+ "PRODUCED": ("Action", f"{owner_id}-action", "Outcome", f"{owner_id}-outcome"),
803
+ }
804
+ return owner_id if (edge.source_label, edge.source_id, edge.target_label, edge.target_id) == expected[edge.relationship] else None
805
+
806
+
807
+ def _has_recorded_reasoning_chain(state: _RecallState, trace_id: str) -> bool:
808
+ relationships = {
809
+ edge.relationship
810
+ for record in state.edges.values()
811
+ if (edge := record.edge).relationship in {"LED_TO", "JUSTIFIED", "PRODUCED"} and _generated_trace_link_owner(edge) == trace_id
812
+ }
813
+ return relationships == {"LED_TO", "JUSTIFIED", "PRODUCED"}
814
+
815
+
816
+ def _redundant_trace_link(edge: GraphEdge, state: _RecallState, hidden_components: set[_NodeRef]) -> bool:
817
+ owner_id = _generated_trace_link_owner(edge)
818
+ if owner_id is None:
819
+ return False
820
+ owner_record = state.nodes.get(_NodeRef("Trace", owner_id))
821
+ if owner_record is None:
822
+ return False
823
+ if not _generated_edge_metadata_matches(edge, owner_record.node):
824
+ return False
825
+ endpoints = (_NodeRef(edge.source_label, edge.source_id), _NodeRef(edge.target_label, edge.target_id))
826
+ return all(ref.label == "Trace" or ref in hidden_components for ref in endpoints)
827
+
828
+
829
+ def _generated_edge_metadata_matches(edge: GraphEdge, trace: GraphNode) -> bool:
830
+ return all(
831
+ key in {"created_at", "updated_at"} or (key == "trace_id" and value == trace.id) or trace.properties.get(key) == value
832
+ for key, value in edge.properties.items()
833
+ )
834
+
835
+
836
+ def _generated_edge_detail(edge: GraphEdge, state: _RecallState) -> str:
837
+ owner_id = _generated_trace_link_owner(edge)
838
+ owner = state.nodes.get(_NodeRef("Trace", owner_id)) if owner_id is not None else None
839
+ if owner is None:
840
+ return ""
841
+ distinct = {
842
+ key: value
843
+ for key, value in edge.properties.items()
844
+ if key not in {"created_at", "updated_at", "trace_id"} and owner.node.properties.get(key) != value
845
+ }
846
+ return f" - `{json.dumps(distinct, sort_keys=True)}`" if distinct else ""
847
+
848
+
849
+ def _property_suffix(properties: dict[str, JsonValue]) -> str:
850
+ public = {key: value for key, value in properties.items() if key not in {"scope_key", "created_at", "updated_at", "search_text"}}
851
+ if not public:
852
+ return ""
853
+ return f" - `{json.dumps(public, sort_keys=True)}`"
854
+
855
+
856
+ def _truncation_notes(state: _RecallState) -> list[str]:
857
+ targets = []
858
+ if state.truncated_nodes:
859
+ targets.append("nodes")
860
+ if state.truncated_edges:
861
+ targets.append("edges")
862
+ if not targets:
863
+ return []
864
+ return [f"Results truncated for {' and '.join(targets)}. Ask a narrower question or increase the recall budgets."]
865
+
866
+
867
+ def _fit_token_budget(lines: list[str], token_budget: int, *, related_findings: bool = False, related_incomplete: bool = False) -> str:
868
+ char_budget = max(token_budget * 4, 80)
869
+ if related_incomplete or (related_findings and sum(len(line) + 1 for line in lines) > char_budget):
870
+ lines.insert(0, "Related findings incomplete; no resolved/current answer. Fetch more context.")
871
+ output: list[str] = []
872
+ total = 0
873
+ for line in lines:
874
+ projected = total + len(line) + 1
875
+ if projected > char_budget:
876
+ output.append("")
877
+ output.append("Results truncated for token budget. Ask a narrower question or increase token_budget.")
878
+ break
879
+ output.append(line)
880
+ total = projected
881
+ return "\n".join(output).rstrip() + "\n"
882
+
883
+
884
+ def _prefixed(path: str) -> str:
885
+ return f"/graph{path}"
886
+
887
+
888
+ def _validate_recall_query(query: str) -> str:
889
+ if not isinstance(query, str):
890
+ msg = "query must be a string."
891
+ raise GraphMemoryValidationError(msg)
892
+ normalized = query.strip()
893
+ if not normalized or len(normalized) > _MAX_QUERY_LENGTH:
894
+ msg = f"query must be between 1 and {_MAX_QUERY_LENGTH} characters."
895
+ raise GraphMemoryValidationError(msg)
896
+ if "\x00" in normalized or any(ord(char) < 32 for char in normalized):
897
+ msg = "query must not contain NUL bytes or control characters."
898
+ raise GraphMemoryValidationError(msg)
899
+ return normalized
900
+
901
+
902
+ def _validate_anchor(anchor: str) -> str:
903
+ if not isinstance(anchor, str):
904
+ msg = "anchors must be strings."
905
+ raise GraphMemoryValidationError(msg)
906
+ normalized = anchor.strip()
907
+ if not normalized or len(normalized) > _MAX_QUERY_LENGTH:
908
+ msg = f"anchors must be between 1 and {_MAX_QUERY_LENGTH} characters."
909
+ raise GraphMemoryValidationError(msg)
910
+ if "\x00" in normalized or any(ord(char) < 32 for char in normalized):
911
+ msg = "anchors must not contain NUL bytes or control characters."
912
+ raise GraphMemoryValidationError(msg)
913
+ return normalized
914
+
915
+
916
+ def _validate_mode(mode: str) -> RecallMode:
917
+ if mode not in _VALID_MODES:
918
+ msg = "mode must be one of: auto, local, deep."
919
+ raise GraphMemoryValidationError(msg)
920
+ return cast("RecallMode", mode)
921
+
922
+
923
+ def _validate_budgets(token_budget: int, max_depth: int, max_nodes: int, max_edges: int) -> tuple[int, int, int, int]:
924
+ for name, value in {
925
+ "token_budget": token_budget,
926
+ "max_depth": max_depth,
927
+ "max_nodes": max_nodes,
928
+ "max_edges": max_edges,
929
+ }.items():
930
+ if not isinstance(value, int) or value < 1:
931
+ msg = f"{name} must be a positive integer."
932
+ raise GraphMemoryValidationError(msg)
933
+ if max_depth > _MAX_DEPTH:
934
+ msg = f"max_depth must be less than or equal to {_MAX_DEPTH}."
935
+ raise GraphMemoryValidationError(msg)
936
+ return token_budget, max_depth, max_nodes, max_edges