execweave 0.6.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.
- execweave/__init__.py +3 -0
- execweave/__main__.py +5 -0
- execweave/analysis.py +406 -0
- execweave/backends.py +63 -0
- execweave/benchmark.py +80 -0
- execweave/claude_adapter.py +448 -0
- execweave/claude_hook_cli.py +101 -0
- execweave/claude_record.py +106 -0
- execweave/cli.py +588 -0
- execweave/codex_adapter.py +314 -0
- execweave/codex_hook_cli.py +98 -0
- execweave/codex_record.py +111 -0
- execweave/collector.py +301 -0
- execweave/correlation.py +604 -0
- execweave/cursor_adapter.py +347 -0
- execweave/cursor_hook_cli.py +82 -0
- execweave/cursor_record.py +96 -0
- execweave/filesystem.py +103 -0
- execweave/focus.py +118 -0
- execweave/gemini_adapter.py +265 -0
- execweave/gemini_hook_cli.py +77 -0
- execweave/gemini_record.py +94 -0
- execweave/graph.py +300 -0
- execweave/graph_ops.py +446 -0
- execweave/inference_gateway.py +422 -0
- execweave/inference_gateway_cli.py +106 -0
- execweave/inference_identity.py +76 -0
- execweave/inference_identity_cli.py +60 -0
- execweave/live.py +275 -0
- execweave/model_runtime.py +535 -0
- execweave/model_runtime_cli.py +154 -0
- execweave/opencode_adapter.py +316 -0
- execweave/opencode_hook_cli.py +57 -0
- execweave/opencode_plugin_cli.py +110 -0
- execweave/opencode_record.py +96 -0
- execweave/overhead_benchmark.py +440 -0
- execweave/provider_record.py +215 -0
- execweave/schema.py +62 -0
- execweave/semantic.py +346 -0
- execweave/sink.py +33 -0
- execweave/strace_backend.py +682 -0
- execweave/validate.py +193 -0
- execweave/viewer.py +283 -0
- execweave/workflow.py +114 -0
- execweave-0.6.0.dist-info/METADATA +356 -0
- execweave-0.6.0.dist-info/RECORD +49 -0
- execweave-0.6.0.dist-info/WHEEL +4 -0
- execweave-0.6.0.dist-info/entry_points.txt +17 -0
- execweave-0.6.0.dist-info/licenses/LICENSE +21 -0
execweave/graph_ops.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from collections import Counter, defaultdict, deque
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Any, Iterable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_graph(path: str | Path) -> dict[str, Any]:
|
|
12
|
+
graph_path = Path(path).expanduser().resolve()
|
|
13
|
+
try:
|
|
14
|
+
payload = json.loads(graph_path.read_text(encoding="utf-8"))
|
|
15
|
+
except FileNotFoundError as exc:
|
|
16
|
+
raise ValueError(f"graph does not exist: {graph_path}") from exc
|
|
17
|
+
except json.JSONDecodeError as exc:
|
|
18
|
+
raise ValueError(f"graph is not valid JSON: {exc.msg}") from exc
|
|
19
|
+
if not isinstance(payload, dict):
|
|
20
|
+
raise ValueError("graph root must be a JSON object")
|
|
21
|
+
if not isinstance(payload.get("nodes"), list) or not isinstance(payload.get("edges"), list):
|
|
22
|
+
raise ValueError("graph must contain nodes and edges arrays")
|
|
23
|
+
return payload
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def write_graph_payload(graph: dict[str, Any], path: str | Path) -> Path:
|
|
27
|
+
output = Path(path).expanduser().resolve()
|
|
28
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
if output.exists() and output.stat().st_size > 0:
|
|
30
|
+
raise FileExistsError(f"ExecWeave graph output already exists: {output}")
|
|
31
|
+
output.write_text(
|
|
32
|
+
json.dumps(graph, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
33
|
+
encoding="utf-8",
|
|
34
|
+
)
|
|
35
|
+
return output
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def graph_summary(graph: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
nodes = [node for node in graph.get("nodes", []) if isinstance(node, dict)]
|
|
40
|
+
edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
|
|
41
|
+
node_types = Counter(
|
|
42
|
+
node.get("type") for node in nodes if isinstance(node.get("type"), str)
|
|
43
|
+
)
|
|
44
|
+
relations = Counter(
|
|
45
|
+
edge.get("relation") for edge in edges if isinstance(edge.get("relation"), str)
|
|
46
|
+
)
|
|
47
|
+
causal_edges = sum(1 for edge in edges if edge.get("causal") is True)
|
|
48
|
+
noncausal_edges = sum(1 for edge in edges if edge.get("causal") is False)
|
|
49
|
+
mixed_edges = len(edges) - causal_edges - noncausal_edges
|
|
50
|
+
expansion = graph.get("expansion")
|
|
51
|
+
expansion_clusters = (
|
|
52
|
+
expansion.get("clusters", {})
|
|
53
|
+
if isinstance(expansion, dict) and isinstance(expansion.get("clusters"), dict)
|
|
54
|
+
else {}
|
|
55
|
+
)
|
|
56
|
+
return {
|
|
57
|
+
"session_id": graph.get("session_id"),
|
|
58
|
+
"event_count": graph.get("event_count"),
|
|
59
|
+
"node_count": len(nodes),
|
|
60
|
+
"edge_count": len(edges),
|
|
61
|
+
"node_types": dict(sorted(node_types.items())),
|
|
62
|
+
"relations": dict(sorted(relations.items())),
|
|
63
|
+
"causal_edges": causal_edges,
|
|
64
|
+
"noncausal_edges": noncausal_edges,
|
|
65
|
+
"mixed_or_unknown_causal_edges": mixed_edges,
|
|
66
|
+
"condensed": bool(graph.get("condensed")),
|
|
67
|
+
"expandable_cluster_count": len(expansion_clusters),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def filter_graph(
|
|
72
|
+
graph: dict[str, Any],
|
|
73
|
+
*,
|
|
74
|
+
node_types: Iterable[str] = (),
|
|
75
|
+
relations: Iterable[str] = (),
|
|
76
|
+
causal_only: bool = False,
|
|
77
|
+
backends: Iterable[str] = (),
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
requested_node_types = set(node_types)
|
|
80
|
+
requested_relations = set(relations)
|
|
81
|
+
requested_backends = set(backends)
|
|
82
|
+
|
|
83
|
+
nodes = [node for node in graph.get("nodes", []) if isinstance(node, dict)]
|
|
84
|
+
edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
|
|
85
|
+
|
|
86
|
+
if requested_node_types:
|
|
87
|
+
selected_nodes = {
|
|
88
|
+
node.get("id")
|
|
89
|
+
for node in nodes
|
|
90
|
+
if node.get("type") in requested_node_types and isinstance(node.get("id"), str)
|
|
91
|
+
}
|
|
92
|
+
else:
|
|
93
|
+
selected_nodes = {
|
|
94
|
+
node.get("id") for node in nodes if isinstance(node.get("id"), str)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
filtered_edges: list[dict[str, Any]] = []
|
|
98
|
+
for edge in edges:
|
|
99
|
+
source = edge.get("source")
|
|
100
|
+
target = edge.get("target")
|
|
101
|
+
if source not in selected_nodes or target not in selected_nodes:
|
|
102
|
+
continue
|
|
103
|
+
if requested_relations and edge.get("relation") not in requested_relations:
|
|
104
|
+
continue
|
|
105
|
+
if causal_only and edge.get("causal") is not True:
|
|
106
|
+
continue
|
|
107
|
+
edge_backends = set(edge.get("backends") or [])
|
|
108
|
+
if requested_backends and not requested_backends.intersection(edge_backends):
|
|
109
|
+
continue
|
|
110
|
+
filtered_edges.append(deepcopy(edge))
|
|
111
|
+
|
|
112
|
+
connected_nodes: set[str] = set()
|
|
113
|
+
for edge in filtered_edges:
|
|
114
|
+
source = edge.get("source")
|
|
115
|
+
target = edge.get("target")
|
|
116
|
+
if isinstance(source, str):
|
|
117
|
+
connected_nodes.add(source)
|
|
118
|
+
if isinstance(target, str):
|
|
119
|
+
connected_nodes.add(target)
|
|
120
|
+
|
|
121
|
+
keep_nodes = selected_nodes if requested_node_types else connected_nodes
|
|
122
|
+
filtered_nodes = [
|
|
123
|
+
deepcopy(node)
|
|
124
|
+
for node in nodes
|
|
125
|
+
if isinstance(node.get("id"), str) and node.get("id") in keep_nodes
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
payload = deepcopy(graph)
|
|
129
|
+
payload["nodes"] = filtered_nodes
|
|
130
|
+
payload["edges"] = filtered_edges
|
|
131
|
+
payload["node_count"] = len(filtered_nodes)
|
|
132
|
+
payload["edge_count"] = len(filtered_edges)
|
|
133
|
+
payload["filter"] = {
|
|
134
|
+
"node_types": sorted(requested_node_types),
|
|
135
|
+
"relations": sorted(requested_relations),
|
|
136
|
+
"causal_only": causal_only,
|
|
137
|
+
"backends": sorted(requested_backends),
|
|
138
|
+
}
|
|
139
|
+
return payload
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _node_bucket(node: dict[str, Any]) -> str:
|
|
143
|
+
node_id = str(node.get("id") or "")
|
|
144
|
+
prefix, _, value = node_id.partition(":")
|
|
145
|
+
if prefix in {"file", "directory", "executable"} and value:
|
|
146
|
+
canonical = value.replace("\\", "/")
|
|
147
|
+
parent = str(PurePosixPath(canonical).parent)
|
|
148
|
+
return parent if parent != "." else "<relative>"
|
|
149
|
+
return "<unknown>"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _cluster_id(parts: tuple[object, ...]) -> str:
|
|
153
|
+
raw = json.dumps(parts, ensure_ascii=False, separators=(",", ":"), sort_keys=False)
|
|
154
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
155
|
+
return f"cluster:{digest}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def condense_graph(
|
|
159
|
+
graph: dict[str, Any],
|
|
160
|
+
*,
|
|
161
|
+
threshold: int = 8,
|
|
162
|
+
sample_size: int = 8,
|
|
163
|
+
collapsible_types: Iterable[str] = ("file", "directory", "executable"),
|
|
164
|
+
include_expansion: bool = False,
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
"""Collapse repetitive leaf resources while preserving runtime topology.
|
|
167
|
+
|
|
168
|
+
By default the result stays compact. When ``include_expansion`` is true, the
|
|
169
|
+
original member nodes and their incoming evidence edges are preserved under the
|
|
170
|
+
top-level ``expansion.clusters`` map so a viewer can materialize them on demand.
|
|
171
|
+
The expansion payload copies observed evidence; it does not infer new relations.
|
|
172
|
+
"""
|
|
173
|
+
if threshold < 2:
|
|
174
|
+
raise ValueError("threshold must be >= 2")
|
|
175
|
+
if sample_size < 1:
|
|
176
|
+
raise ValueError("sample_size must be >= 1")
|
|
177
|
+
|
|
178
|
+
nodes = [deepcopy(node) for node in graph.get("nodes", []) if isinstance(node, dict)]
|
|
179
|
+
edges = [deepcopy(edge) for edge in graph.get("edges", []) if isinstance(edge, dict)]
|
|
180
|
+
node_by_id = {
|
|
181
|
+
node["id"]: node for node in nodes if isinstance(node.get("id"), str)
|
|
182
|
+
}
|
|
183
|
+
incoming: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
184
|
+
outgoing: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
185
|
+
for edge in edges:
|
|
186
|
+
source = edge.get("source")
|
|
187
|
+
target = edge.get("target")
|
|
188
|
+
if isinstance(source, str):
|
|
189
|
+
outgoing[source].append(edge)
|
|
190
|
+
if isinstance(target, str):
|
|
191
|
+
incoming[target].append(edge)
|
|
192
|
+
|
|
193
|
+
allowed_types = set(collapsible_types)
|
|
194
|
+
groups: dict[tuple[object, ...], list[tuple[dict[str, Any], dict[str, Any]]]] = defaultdict(list)
|
|
195
|
+
for node_id, node in node_by_id.items():
|
|
196
|
+
node_type = node.get("type")
|
|
197
|
+
if node_type not in allowed_types:
|
|
198
|
+
continue
|
|
199
|
+
if outgoing.get(node_id) or len(incoming.get(node_id, [])) != 1:
|
|
200
|
+
continue
|
|
201
|
+
edge = incoming[node_id][0]
|
|
202
|
+
source = edge.get("source")
|
|
203
|
+
relation = edge.get("relation")
|
|
204
|
+
if not isinstance(source, str) or not isinstance(relation, str):
|
|
205
|
+
continue
|
|
206
|
+
key = (
|
|
207
|
+
source,
|
|
208
|
+
relation,
|
|
209
|
+
node_type,
|
|
210
|
+
_node_bucket(node),
|
|
211
|
+
edge.get("causal"),
|
|
212
|
+
tuple(sorted(str(item) for item in (edge.get("backends") or []))),
|
|
213
|
+
)
|
|
214
|
+
groups[key].append((node, edge))
|
|
215
|
+
|
|
216
|
+
collapsed_node_ids: set[str] = set()
|
|
217
|
+
collapsed_edge_ids: set[str] = set()
|
|
218
|
+
cluster_nodes: list[dict[str, Any]] = []
|
|
219
|
+
cluster_edges: list[dict[str, Any]] = []
|
|
220
|
+
expansion_clusters: dict[str, dict[str, Any]] = {}
|
|
221
|
+
collapsed_groups = 0
|
|
222
|
+
|
|
223
|
+
for key, members in groups.items():
|
|
224
|
+
if len(members) < threshold:
|
|
225
|
+
continue
|
|
226
|
+
collapsed_groups += 1
|
|
227
|
+
source, relation, node_type, bucket, causal, backends = key
|
|
228
|
+
cluster_id = _cluster_id(key)
|
|
229
|
+
member_nodes = [node for node, _ in members]
|
|
230
|
+
member_edges = [edge for _, edge in members]
|
|
231
|
+
collapsed_node_ids.update(
|
|
232
|
+
node["id"] for node in member_nodes if isinstance(node.get("id"), str)
|
|
233
|
+
)
|
|
234
|
+
collapsed_edge_ids.update(
|
|
235
|
+
edge["id"] for edge in member_edges if isinstance(edge.get("id"), str)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
names = [
|
|
239
|
+
str(node.get("name") or node.get("id"))
|
|
240
|
+
for node in member_nodes
|
|
241
|
+
if node.get("name") or node.get("id")
|
|
242
|
+
]
|
|
243
|
+
first_seen_values = [
|
|
244
|
+
node.get("first_seen") for node in member_nodes if isinstance(node.get("first_seen"), str)
|
|
245
|
+
]
|
|
246
|
+
last_seen_values = [
|
|
247
|
+
node.get("last_seen") for node in member_nodes if isinstance(node.get("last_seen"), str)
|
|
248
|
+
]
|
|
249
|
+
first_seq_values = [
|
|
250
|
+
edge.get("first_sequence")
|
|
251
|
+
for edge in member_edges
|
|
252
|
+
if isinstance(edge.get("first_sequence"), int)
|
|
253
|
+
]
|
|
254
|
+
last_seq_values = [
|
|
255
|
+
edge.get("last_sequence")
|
|
256
|
+
for edge in member_edges
|
|
257
|
+
if isinstance(edge.get("last_sequence"), int)
|
|
258
|
+
]
|
|
259
|
+
event_ids = [
|
|
260
|
+
str(event_id)
|
|
261
|
+
for edge in member_edges
|
|
262
|
+
for event_id in (edge.get("event_ids") or [])
|
|
263
|
+
if isinstance(event_id, str)
|
|
264
|
+
]
|
|
265
|
+
event_types = sorted(
|
|
266
|
+
{
|
|
267
|
+
str(event_type)
|
|
268
|
+
for edge in member_edges
|
|
269
|
+
for event_type in (edge.get("event_types") or [])
|
|
270
|
+
if isinstance(event_type, str)
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
attributions = sorted(
|
|
274
|
+
{
|
|
275
|
+
str(value)
|
|
276
|
+
for edge in member_edges
|
|
277
|
+
for value in (edge.get("attributions") or [])
|
|
278
|
+
if isinstance(value, str)
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
cluster_nodes.append(
|
|
283
|
+
{
|
|
284
|
+
"id": cluster_id,
|
|
285
|
+
"type": f"{node_type}_cluster",
|
|
286
|
+
"name": f"{len(member_nodes)} {node_type}s in {bucket}",
|
|
287
|
+
"attributes": {
|
|
288
|
+
"collapsed": True,
|
|
289
|
+
"member_count": len(member_nodes),
|
|
290
|
+
"member_type": node_type,
|
|
291
|
+
"directory_bucket": bucket,
|
|
292
|
+
"sample_members": names[:sample_size],
|
|
293
|
+
"sample_truncated": len(names) > sample_size,
|
|
294
|
+
"expandable": include_expansion,
|
|
295
|
+
},
|
|
296
|
+
"first_seen": min(first_seen_values) if first_seen_values else None,
|
|
297
|
+
"last_seen": max(last_seen_values) if last_seen_values else None,
|
|
298
|
+
"event_count": sum(
|
|
299
|
+
int(node.get("event_count") or 0) for node in member_nodes
|
|
300
|
+
),
|
|
301
|
+
"event_types": event_types,
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
cluster_edge_id = f"{source}--{relation}-->{cluster_id}"
|
|
305
|
+
cluster_edges.append(
|
|
306
|
+
{
|
|
307
|
+
"id": cluster_edge_id,
|
|
308
|
+
"source": source,
|
|
309
|
+
"target": cluster_id,
|
|
310
|
+
"relation": relation,
|
|
311
|
+
"count": sum(int(edge.get("count") or 0) for edge in member_edges),
|
|
312
|
+
"first_seen": min(first_seen_values) if first_seen_values else None,
|
|
313
|
+
"last_seen": max(last_seen_values) if last_seen_values else None,
|
|
314
|
+
"first_sequence": min(first_seq_values) if first_seq_values else None,
|
|
315
|
+
"last_sequence": max(last_seq_values) if last_seq_values else None,
|
|
316
|
+
"event_ids": event_ids[:32],
|
|
317
|
+
"event_ids_truncated": len(event_ids) > 32,
|
|
318
|
+
"evidence_event_count": len(event_ids),
|
|
319
|
+
"event_types": event_types,
|
|
320
|
+
"backends": list(backends),
|
|
321
|
+
"attributions": attributions,
|
|
322
|
+
"causal": causal,
|
|
323
|
+
"collapsed_member_count": len(member_nodes),
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
if include_expansion:
|
|
327
|
+
expansion_clusters[cluster_id] = {
|
|
328
|
+
"cluster_node_id": cluster_id,
|
|
329
|
+
"cluster_edge_id": cluster_edge_id,
|
|
330
|
+
"nodes": deepcopy(member_nodes),
|
|
331
|
+
"edges": deepcopy(member_edges),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
condensed_nodes = [
|
|
335
|
+
node for node in nodes if node.get("id") not in collapsed_node_ids
|
|
336
|
+
] + cluster_nodes
|
|
337
|
+
condensed_edges = [
|
|
338
|
+
edge for edge in edges if edge.get("id") not in collapsed_edge_ids
|
|
339
|
+
] + cluster_edges
|
|
340
|
+
|
|
341
|
+
payload = deepcopy(graph)
|
|
342
|
+
payload["nodes"] = sorted(
|
|
343
|
+
condensed_nodes,
|
|
344
|
+
key=lambda node: (str(node.get("type") or ""), str(node.get("id") or "")),
|
|
345
|
+
)
|
|
346
|
+
payload["edges"] = sorted(
|
|
347
|
+
condensed_edges,
|
|
348
|
+
key=lambda edge: (
|
|
349
|
+
str(edge.get("source") or ""),
|
|
350
|
+
str(edge.get("relation") or ""),
|
|
351
|
+
str(edge.get("target") or ""),
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
payload["node_count"] = len(payload["nodes"])
|
|
355
|
+
payload["edge_count"] = len(payload["edges"])
|
|
356
|
+
payload["condensed"] = True
|
|
357
|
+
payload["condensation"] = {
|
|
358
|
+
"threshold": threshold,
|
|
359
|
+
"sample_size": sample_size,
|
|
360
|
+
"collapsible_types": sorted(allowed_types),
|
|
361
|
+
"original_node_count": len(nodes),
|
|
362
|
+
"original_edge_count": len(edges),
|
|
363
|
+
"collapsed_group_count": collapsed_groups,
|
|
364
|
+
"collapsed_node_count": len(collapsed_node_ids),
|
|
365
|
+
"result_node_count": len(payload["nodes"]),
|
|
366
|
+
"result_edge_count": len(payload["edges"]),
|
|
367
|
+
"expansion_embedded": include_expansion,
|
|
368
|
+
}
|
|
369
|
+
if include_expansion:
|
|
370
|
+
payload["expansion"] = {
|
|
371
|
+
"schema_version": "0.1",
|
|
372
|
+
"clusters": expansion_clusters,
|
|
373
|
+
}
|
|
374
|
+
else:
|
|
375
|
+
payload.pop("expansion", None)
|
|
376
|
+
return payload
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def find_paths(
|
|
380
|
+
graph: dict[str, Any],
|
|
381
|
+
*,
|
|
382
|
+
source: str,
|
|
383
|
+
target: str,
|
|
384
|
+
max_depth: int = 6,
|
|
385
|
+
max_paths: int = 20,
|
|
386
|
+
relations: Iterable[str] = (),
|
|
387
|
+
causal_only: bool = False,
|
|
388
|
+
) -> list[dict[str, Any]]:
|
|
389
|
+
if max_depth < 1:
|
|
390
|
+
raise ValueError("max_depth must be >= 1")
|
|
391
|
+
if max_paths < 1:
|
|
392
|
+
raise ValueError("max_paths must be >= 1")
|
|
393
|
+
|
|
394
|
+
nodes = {
|
|
395
|
+
node.get("id"): node
|
|
396
|
+
for node in graph.get("nodes", [])
|
|
397
|
+
if isinstance(node, dict) and isinstance(node.get("id"), str)
|
|
398
|
+
}
|
|
399
|
+
if source not in nodes:
|
|
400
|
+
raise ValueError(f"source node not found: {source}")
|
|
401
|
+
if target not in nodes:
|
|
402
|
+
raise ValueError(f"target node not found: {target}")
|
|
403
|
+
|
|
404
|
+
requested_relations = set(relations)
|
|
405
|
+
adjacency: dict[str, list[dict[str, Any]]] = {}
|
|
406
|
+
for edge in graph.get("edges", []):
|
|
407
|
+
if not isinstance(edge, dict):
|
|
408
|
+
continue
|
|
409
|
+
if requested_relations and edge.get("relation") not in requested_relations:
|
|
410
|
+
continue
|
|
411
|
+
if causal_only and edge.get("causal") is not True:
|
|
412
|
+
continue
|
|
413
|
+
edge_source = edge.get("source")
|
|
414
|
+
edge_target = edge.get("target")
|
|
415
|
+
if not isinstance(edge_source, str) or not isinstance(edge_target, str):
|
|
416
|
+
continue
|
|
417
|
+
adjacency.setdefault(edge_source, []).append(edge)
|
|
418
|
+
|
|
419
|
+
queue: deque[tuple[str, list[str], list[dict[str, Any]]]] = deque()
|
|
420
|
+
queue.append((source, [source], []))
|
|
421
|
+
results: list[dict[str, Any]] = []
|
|
422
|
+
|
|
423
|
+
while queue and len(results) < max_paths:
|
|
424
|
+
current, node_path, edge_path = queue.popleft()
|
|
425
|
+
if len(edge_path) >= max_depth:
|
|
426
|
+
continue
|
|
427
|
+
for edge in adjacency.get(current, []):
|
|
428
|
+
next_node = edge["target"]
|
|
429
|
+
if next_node in node_path:
|
|
430
|
+
continue
|
|
431
|
+
next_nodes = [*node_path, next_node]
|
|
432
|
+
next_edges = [*edge_path, edge]
|
|
433
|
+
if next_node == target:
|
|
434
|
+
results.append(
|
|
435
|
+
{
|
|
436
|
+
"nodes": next_nodes,
|
|
437
|
+
"relations": [item.get("relation") for item in next_edges],
|
|
438
|
+
"edges": deepcopy(next_edges),
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
if len(results) >= max_paths:
|
|
442
|
+
break
|
|
443
|
+
continue
|
|
444
|
+
queue.append((next_node, next_nodes, next_edges))
|
|
445
|
+
|
|
446
|
+
return results
|