Skip to main content

agentic_graph_spec/
plan.rs

1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2
3use thiserror::Error;
4
5use crate::{Document, EffectiveEdge, GraphPlan, graph_digest, object, strings};
6
7/// Failure while constructing a deterministic graph plan.
8#[derive(Debug, Error)]
9pub enum PlanError {
10    /// The effective graph contains a directed cycle.
11    #[error("graph contains a cycle")]
12    Cycle,
13    /// Canonical graph identity calculation failed.
14    #[error(transparent)]
15    Digest(#[from] crate::canonical::CanonicalError),
16}
17
18/// Combines `depends_on` relationships and explicit edges into a sorted edge set.
19pub 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
73/// Computes a deterministic, identifier-tiebroken topological node order.
74pub 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
114/// Produces a deterministic, non-executing AGS conformance-level-0 plan.
115pub 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}