agentic_graph_spec/
plan.rs1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2
3use thiserror::Error;
4
5use crate::{Document, EffectiveEdge, GraphPlan, graph_digest, object, strings};
6
7#[derive(Debug, Error)]
9pub enum PlanError {
10 #[error("graph contains a cycle")]
12 Cycle,
13 #[error(transparent)]
15 Digest(#[from] crate::canonical::CanonicalError),
16}
17
18pub fn graph_effective_edges(document: &Document) -> Vec<EffectiveEdge> {
20 let nodes = object(document.get("nodes"));
21 let mut edges = Vec::new();
22 let mut seen = BTreeSet::new();
23 for (id, raw) in nodes {
24 for dependency in strings(raw.as_object().and_then(|node| node.get("depends_on"))) {
25 let key = (dependency.clone(), id.clone(), "sequence".to_owned());
26 if seen.insert(key.clone()) {
27 edges.push(EffectiveEdge {
28 from: key.0,
29 to: key.1,
30 kind: key.2,
31 when: None,
32 });
33 }
34 }
35 }
36 for raw in document
37 .get("edges")
38 .and_then(|value| value.as_array())
39 .into_iter()
40 .flatten()
41 {
42 let edge = object(Some(raw));
43 let from = edge
44 .get("from")
45 .and_then(|v| v.as_str())
46 .unwrap_or_default()
47 .to_owned();
48 let to = edge
49 .get("to")
50 .and_then(|v| v.as_str())
51 .unwrap_or_default()
52 .to_owned();
53 let kind = edge
54 .get("kind")
55 .and_then(|v| v.as_str())
56 .unwrap_or("sequence")
57 .to_owned();
58 if seen.insert((from.clone(), to.clone(), kind.clone())) {
59 edges.push(EffectiveEdge {
60 from,
61 to,
62 kind,
63 when: edge.get("when").and_then(|v| v.as_str()).map(str::to_owned),
64 });
65 }
66 }
67 edges.sort_by(|left, right| {
68 (&left.from, &left.to, &left.kind).cmp(&(&right.from, &right.to, &right.kind))
69 });
70 edges
71}
72
73pub fn topological_order(document: &Document) -> Result<Vec<String>, PlanError> {
75 let nodes = object(document.get("nodes"));
76 let edges = graph_effective_edges(document);
77 let mut incoming: BTreeMap<String, usize> = nodes.keys().map(|id| (id.clone(), 0)).collect();
78 let mut outgoing: BTreeMap<String, Vec<String>> =
79 nodes.keys().map(|id| (id.clone(), vec![])).collect();
80 for edge in &edges {
81 if incoming.contains_key(&edge.from) && incoming.contains_key(&edge.to) {
82 *incoming.get_mut(&edge.to).expect("known node") += 1;
83 outgoing
84 .get_mut(&edge.from)
85 .expect("known node")
86 .push(edge.to.clone());
87 }
88 }
89 for targets in outgoing.values_mut() {
90 targets.sort();
91 }
92 let mut ready: BTreeSet<String> = incoming
93 .iter()
94 .filter(|(_, count)| **count == 0)
95 .map(|(id, _)| id.clone())
96 .collect();
97 let mut order = Vec::with_capacity(nodes.len());
98 while let Some(id) = ready.pop_first() {
99 order.push(id.clone());
100 for target in outgoing.get(&id).into_iter().flatten() {
101 let count = incoming.get_mut(target).expect("known target");
102 *count -= 1;
103 if *count == 0 {
104 ready.insert(target.clone());
105 }
106 }
107 }
108 if order.len() != nodes.len() {
109 return Err(PlanError::Cycle);
110 }
111 Ok(order)
112}
113
114pub fn plan_graph(document: &Document) -> Result<GraphPlan, PlanError> {
116 let nodes = object(document.get("nodes"));
117 let edges = graph_effective_edges(document);
118 let order = topological_order(document)?;
119 let entrypoints = strings(document.get("entrypoints"));
120 let mut outgoing: BTreeMap<String, Vec<String>> = BTreeMap::new();
121 for edge in &edges {
122 outgoing
123 .entry(edge.from.clone())
124 .or_default()
125 .push(edge.to.clone());
126 }
127 let mut reachable = BTreeSet::new();
128 let mut queue: VecDeque<String> = entrypoints.iter().cloned().collect();
129 while let Some(id) = queue.pop_front() {
130 if reachable.insert(id.clone()) {
131 queue.extend(outgoing.get(&id).into_iter().flatten().cloned());
132 }
133 }
134 let unreachable = nodes
135 .keys()
136 .filter(|id| !reachable.contains(*id))
137 .cloned()
138 .collect();
139 let mut histogram = BTreeMap::new();
140 let mut worst_case = 0_u64;
141 let mut unsupported = BTreeSet::new();
142 for raw in nodes.values() {
143 let node = object(Some(raw));
144 let tier = object(node.get("intelligence"))
145 .get("tier")
146 .and_then(|v| v.as_str())
147 .unwrap_or("none");
148 *histogram.entry(tier.to_owned()).or_insert(0) += 1;
149 let mut executions = 1_u64;
150 if node.get("type").and_then(|v| v.as_str()) == Some("loop") {
151 executions = object(node.get("loop"))
152 .get("max_iterations")
153 .and_then(|v| v.as_u64())
154 .unwrap_or(1);
155 unsupported.insert("loop".to_owned());
156 }
157 if node.get("type").and_then(|v| v.as_str()) == Some("map") {
158 executions = object(node.get("map"))
159 .get("max_items")
160 .and_then(|v| v.as_u64())
161 .unwrap_or(1);
162 unsupported.insert("map".to_owned());
163 }
164 if matches!(
165 node.get("type").and_then(|v| v.as_str()),
166 Some("decision" | "subgraph")
167 ) {
168 unsupported.insert(
169 node.get("type")
170 .and_then(|v| v.as_str())
171 .unwrap()
172 .to_owned(),
173 );
174 }
175 worst_case = worst_case.saturating_add(executions);
176 }
177 Ok(GraphPlan {
178 graph_id: document
179 .get("id")
180 .and_then(|v| v.as_str())
181 .unwrap_or_default()
182 .to_owned(),
183 graph_digest: graph_digest(document)?,
184 order,
185 entrypoints,
186 effective_edges: edges,
187 reachable: reachable.into_iter().collect(),
188 unreachable,
189 tier_histogram: histogram,
190 worst_case_node_executions: worst_case,
191 executable: false,
192 unsupported_features: unsupported.into_iter().collect(),
193 })
194}