use std::collections::{BTreeSet, HashMap, HashSet};
use serde_json::Value;
use crate::document::{BranchCondition, FoldBody, FoldJoin, Graph, MapBody, Node, SCHEMA_VERSION};
use crate::expr;
pub const MAX_NODE_NAME_LEN: usize = 64;
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum GraphError {
#[error("unsupported schema_version {found}: this build understands versions 1..={supported}")]
UnsupportedSchemaVersion {
found: u32,
supported: u32,
},
#[error("duplicate node id `{id}`")]
DuplicateNodeId {
id: String,
},
#[error("edge `{from}` -> `{to}` references unknown node id `{missing}`{}", suggest(.suggestion))]
DanglingEdge {
from: String,
to: String,
missing: String,
suggestion: Option<String>,
},
#[error("map node `{id}` maps unknown node id `{missing}`{}", suggest(.suggestion))]
DanglingMapBody {
id: String,
missing: String,
suggestion: Option<String>,
},
#[error("fold node `{id}` folds unknown node id `{missing}`{}", suggest(.suggestion))]
DanglingFoldBody {
id: String,
missing: String,
suggestion: Option<String>,
},
#[error("agent node `{id}`: `{hash}` is not a well-formed `sha256:<64 hex>` agent hash")]
MalformedAgentHash {
id: String,
hash: String,
},
#[error("map node `{id}`: concurrency cap must be at least 1, found {found}")]
NonPositiveConcurrency {
id: String,
found: u32,
},
#[error("fold node `{id}`: max_iterations must be at least 1, found {found}")]
NonPositiveMaxIterations {
id: String,
found: u32,
},
#[error("delay node `{id}`: seconds must be at least 1, found {found}")]
NonPositiveDelay {
id: String,
found: u64,
},
#[error("gate node `{id}`: approval_schema must be a JSON object")]
ApprovalSchemaNotObject {
id: String,
},
#[error("cycle detected: {path}")]
Cycle {
path: String,
},
#[error(
"edge `{from}` -> `{to}`: the output schema of `{from}` does not match the input schema of `{to}`"
)]
EdgeTypeMismatch {
from: String,
to: String,
},
#[error("branch node `{node}`: case `{case}` has an invalid condition expression: {error}")]
InvalidBranchExpression {
node: String,
case: String,
error: String,
},
#[error(
"branch node `{node}`: case `{case}` is a model decision but the branch declares no `agent_hash`"
)]
ModelDecisionWithoutAgent {
node: String,
case: String,
},
#[error(
"branch node `{node}`: case `{case}` has no outbound edge; point the case at a node, and point a route meant to end the run at a terminal node instead"
)]
BranchCaseWithoutEdge {
node: String,
case: String,
},
#[error(
"branch node `{node}`: outbound edge labeled `{label}` names no case; rename the label to match one of the branch's cases, or declare the case `{label}` on the branch"
)]
BranchEdgeWithoutCase {
node: String,
label: String,
},
#[error(
"branch node `{node}`: outbound edge to `{to}` has no label; label it with one of the branch's cases"
)]
BranchEdgeWithoutLabel {
node: String,
to: String,
},
#[error("fold node `{node}`: `stop_when` is not a valid condition expression: {error}")]
InvalidFoldStopExpression {
node: String,
error: String,
},
#[error(
"fold node `{node}`: the `best_by` join reference `{reference}` is not a valid path: {error}"
)]
InvalidFoldJoinReference {
node: String,
reference: String,
error: String,
},
#[error(
"fold node `{node}`: `stop_when` reads `{path}`, which body node `{body}`'s declared output schema does not describe"
)]
FoldStopPathNotInBodySchema {
node: String,
path: String,
body: String,
},
#[error(
"fold node `{node}`: the `best_by` join reference `{reference}` is not described by body node `{body}`'s declared output schema"
)]
FoldJoinReferenceNotInBodySchema {
node: String,
reference: String,
body: String,
},
#[error("node `{id}`: `name` is {len} characters, over the {max}-character cap")]
NodeNameTooLong {
id: String,
len: usize,
max: usize,
},
#[error("node `{id}`: `name`, if set, must not be empty or all whitespace")]
BlankNodeName {
id: String,
},
}
fn suggest(suggestion: &Option<String>) -> String {
match suggestion {
Some(name) => format!(" (did you mean `{name}`?)"),
None => String::new(),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GraphSummary {
pub node_count: usize,
pub edge_count: usize,
pub entry_nodes: Vec<String>,
pub terminal_nodes: Vec<String>,
}
pub fn validate(graph: &Graph) -> Result<GraphSummary, Vec<GraphError>> {
let mut errors = Vec::new();
check_schema_version(graph, &mut errors);
check_unique_node_ids(graph, &mut errors);
check_referential_integrity(graph, &mut errors);
check_node_fields(graph, &mut errors);
check_node_names(graph, &mut errors);
check_branch_expressions(graph, &mut errors);
check_branch_case_edges(graph, &mut errors);
check_branch_edge_labels(graph, &mut errors);
check_fold_expressions(graph, &mut errors);
check_fold_reference_shapes(graph, &mut errors);
check_acyclic(graph, &mut errors);
check_edge_type_compat(graph, &mut errors);
if errors.is_empty() {
Ok(summarize(graph))
} else {
Err(errors)
}
}
fn check_schema_version(graph: &Graph, errors: &mut Vec<GraphError>) {
if graph.schema_version == 0 || graph.schema_version > SCHEMA_VERSION {
errors.push(GraphError::UnsupportedSchemaVersion {
found: graph.schema_version,
supported: SCHEMA_VERSION,
});
}
}
fn check_unique_node_ids(graph: &Graph, errors: &mut Vec<GraphError>) {
let mut seen = HashSet::new();
for node in &graph.nodes {
if !seen.insert(node.id()) {
errors.push(GraphError::DuplicateNodeId {
id: node.id().to_owned(),
});
}
}
}
fn check_referential_integrity(graph: &Graph, errors: &mut Vec<GraphError>) {
let ids: BTreeSet<&str> = graph.nodes.iter().map(Node::id).collect();
for edge in &graph.edges {
if !ids.contains(edge.from.as_str()) {
errors.push(GraphError::DanglingEdge {
from: edge.from.clone(),
to: edge.to.clone(),
missing: edge.from.clone(),
suggestion: nearest(&edge.from, &ids),
});
}
if !ids.contains(edge.to.as_str()) {
errors.push(GraphError::DanglingEdge {
from: edge.from.clone(),
to: edge.to.clone(),
missing: edge.to.clone(),
suggestion: nearest(&edge.to, &ids),
});
}
}
for node in &graph.nodes {
if let Node::Map(map) = node
&& let MapBody::Node(target) = &map.body
&& !ids.contains(target.as_str())
{
errors.push(GraphError::DanglingMapBody {
id: map.id.clone(),
missing: target.clone(),
suggestion: nearest(target, &ids),
});
}
if let Node::Fold(fold) = node
&& let FoldBody::Node(target) = &fold.body
&& !ids.contains(target.as_str())
{
errors.push(GraphError::DanglingFoldBody {
id: fold.id.clone(),
missing: target.clone(),
suggestion: nearest(target, &ids),
});
}
}
}
fn check_node_fields(graph: &Graph, errors: &mut Vec<GraphError>) {
for node in &graph.nodes {
match node {
Node::Agent(agent) => {
if !is_well_formed_agent_hash(&agent.agent_hash) {
errors.push(GraphError::MalformedAgentHash {
id: agent.id.clone(),
hash: agent.agent_hash.clone(),
});
}
}
Node::Map(map) => {
if map.concurrency < 1 {
errors.push(GraphError::NonPositiveConcurrency {
id: map.id.clone(),
found: map.concurrency,
});
}
}
Node::Gate(gate) => {
if !gate.approval_schema.is_object() {
errors.push(GraphError::ApprovalSchemaNotObject {
id: gate.id.clone(),
});
}
}
Node::Fold(fold) => {
if fold.max_iterations < 1 {
errors.push(GraphError::NonPositiveMaxIterations {
id: fold.id.clone(),
found: fold.max_iterations,
});
}
}
Node::Delay(delay) => {
if delay.seconds < 1 {
errors.push(GraphError::NonPositiveDelay {
id: delay.id.clone(),
found: delay.seconds,
});
}
}
Node::Tool(_) | Node::Branch(_) => {}
}
}
}
fn check_node_names(graph: &Graph, errors: &mut Vec<GraphError>) {
for node in &graph.nodes {
let Some(name) = node.name() else {
continue;
};
if name.trim().is_empty() {
errors.push(GraphError::BlankNodeName {
id: node.id().to_owned(),
});
continue;
}
let len = name.chars().count();
if len > MAX_NODE_NAME_LEN {
errors.push(GraphError::NodeNameTooLong {
id: node.id().to_owned(),
len,
max: MAX_NODE_NAME_LEN,
});
}
}
}
fn check_branch_expressions(graph: &Graph, errors: &mut Vec<GraphError>) {
for node in &graph.nodes {
let Node::Branch(branch) = node else {
continue;
};
if let Some(hash) = &branch.agent_hash
&& !is_well_formed_agent_hash(hash)
{
errors.push(GraphError::MalformedAgentHash {
id: branch.id.clone(),
hash: hash.clone(),
});
}
for case in &branch.cases {
match &case.when {
BranchCondition::Expression(source) => {
if let Err(error) = expr::parse(source) {
errors.push(GraphError::InvalidBranchExpression {
node: branch.id.clone(),
case: case.name.clone(),
error: error.to_string(),
});
}
}
BranchCondition::ModelDecision => {
if branch.agent_hash.is_none() {
errors.push(GraphError::ModelDecisionWithoutAgent {
node: branch.id.clone(),
case: case.name.clone(),
});
}
}
}
}
}
}
fn check_branch_case_edges(graph: &Graph, errors: &mut Vec<GraphError>) {
for node in &graph.nodes {
let Node::Branch(branch) = node else {
continue;
};
let labels: HashSet<&str> = graph
.edges
.iter()
.filter(|edge| edge.from == branch.id)
.filter_map(|edge| edge.label.as_deref())
.collect();
for case in &branch.cases {
if !labels.contains(case.name.as_str()) {
errors.push(GraphError::BranchCaseWithoutEdge {
node: branch.id.clone(),
case: case.name.clone(),
});
}
}
}
}
fn check_branch_edge_labels(graph: &Graph, errors: &mut Vec<GraphError>) {
for node in &graph.nodes {
let Node::Branch(branch) = node else {
continue;
};
let case_names: HashSet<&str> =
branch.cases.iter().map(|case| case.name.as_str()).collect();
for edge in &graph.edges {
if edge.from != branch.id {
continue;
}
match &edge.label {
Some(label) if !case_names.contains(label.as_str()) => {
errors.push(GraphError::BranchEdgeWithoutCase {
node: branch.id.clone(),
label: label.clone(),
});
}
None => {
errors.push(GraphError::BranchEdgeWithoutLabel {
node: branch.id.clone(),
to: edge.to.clone(),
});
}
Some(_) => {}
}
}
}
}
fn check_fold_expressions(graph: &Graph, errors: &mut Vec<GraphError>) {
for node in &graph.nodes {
let Node::Fold(fold) = node else {
continue;
};
if let Err(error) = expr::parse(&fold.stop_when) {
errors.push(GraphError::InvalidFoldStopExpression {
node: fold.id.clone(),
error: error.to_string(),
});
}
if let FoldJoin::BestBy(reference) = &fold.join
&& let Err(error) = expr::parse_reference(reference)
{
errors.push(GraphError::InvalidFoldJoinReference {
node: fold.id.clone(),
reference: reference.clone(),
error: error.to_string(),
});
}
}
}
fn check_fold_reference_shapes(graph: &Graph, errors: &mut Vec<GraphError>) {
let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
for node in &graph.nodes {
let Node::Fold(fold) = node else {
continue;
};
let FoldBody::Node(body_id) = &fold.body else {
continue;
};
let Some(schema) = by_id
.get(body_id.as_str())
.and_then(|body| body.output_schema())
else {
continue;
};
if let Ok(predicate) = expr::parse(&fold.stop_when) {
for path in predicate.paths() {
if !schema_describes(schema, path) {
errors.push(GraphError::FoldStopPathNotInBodySchema {
node: fold.id.clone(),
path: render_path(path),
body: body_id.clone(),
});
}
}
}
if let FoldJoin::BestBy(reference) = &fold.join
&& let Ok(parsed) = expr::parse_reference(reference)
&& !schema_describes(schema, parsed.segments())
{
errors.push(GraphError::FoldJoinReferenceNotInBodySchema {
node: fold.id.clone(),
reference: reference.clone(),
body: body_id.clone(),
});
}
}
}
fn schema_describes(schema: &Value, path: &[expr::Segment]) -> bool {
let mut here = schema;
for segment in path {
match step_into(here, segment) {
Step::Into(next) => here = next,
Step::Unjudged => return true,
Step::Absent => return false,
}
}
true
}
enum Step<'a> {
Into(&'a Value),
Unjudged,
Absent,
}
fn step_into<'a>(schema: &'a Value, segment: &expr::Segment) -> Step<'a> {
let Some(object) = schema.as_object() else {
return Step::Unjudged;
};
if ["$ref", "anyOf", "oneOf", "allOf", "not"]
.iter()
.any(|keyword| object.contains_key(*keyword))
{
return Step::Unjudged;
}
match segment {
expr::Segment::Key(key) => {
if !admits_type(object, "object") {
return Step::Unjudged;
}
let Some(properties) = object.get("properties").and_then(Value::as_object) else {
return Step::Unjudged;
};
if let Some(property) = properties.get(key) {
return Step::Into(property);
}
if admits_extra_keys(object) {
Step::Unjudged
} else {
Step::Absent
}
}
expr::Segment::Index(index) => {
if !admits_type(object, "array") {
return Step::Unjudged;
}
match object.get("items") {
Some(items) if items.is_object() => Step::Into(items),
Some(Value::Array(entries)) => {
entries.get(*index).map_or(Step::Unjudged, Step::Into)
}
_ => Step::Unjudged,
}
}
}
}
fn admits_type(object: &serde_json::Map<String, Value>, wanted: &str) -> bool {
match object.get("type") {
None => true,
Some(Value::String(declared)) => declared == wanted,
Some(Value::Array(declared)) => declared.iter().any(|one| one.as_str() == Some(wanted)),
Some(_) => true,
}
}
fn admits_extra_keys(object: &serde_json::Map<String, Value>) -> bool {
if object.contains_key("patternProperties") {
return true;
}
match object.get("additionalProperties") {
None | Some(Value::Bool(false)) => false,
Some(_) => true,
}
}
fn render_path(path: &[expr::Segment]) -> String {
path.iter()
.map(|segment| match segment {
expr::Segment::Key(key) => key.clone(),
expr::Segment::Index(index) => index.to_string(),
})
.collect::<Vec<_>>()
.join(".")
}
fn is_well_formed_agent_hash(hash: &str) -> bool {
let Some(hex) = hash.strip_prefix("sha256:") else {
return false;
};
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn check_acyclic(graph: &Graph, errors: &mut Vec<GraphError>) {
let ids: HashSet<&str> = graph.nodes.iter().map(Node::id).collect();
let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in &graph.edges {
if ids.contains(edge.from.as_str()) && ids.contains(edge.to.as_str()) {
adjacency
.entry(edge.from.as_str())
.or_default()
.push(edge.to.as_str());
}
}
#[derive(Clone, Copy, PartialEq)]
enum Color {
White,
Gray,
Black,
}
let mut color: HashMap<&str, Color> = ids.iter().map(|id| (*id, Color::White)).collect();
let mut stack: Vec<&str> = Vec::new();
for start in graph.nodes.iter().map(Node::id) {
if color[start] != Color::White {
continue;
}
let mut frames: Vec<(&str, usize)> = vec![(start, 0)];
color.insert(start, Color::Gray);
stack.push(start);
while let Some(&mut (node, ref mut next)) = frames.last_mut() {
let neighbors = adjacency.get(node).map_or(&[][..], Vec::as_slice);
if *next < neighbors.len() {
let neighbor = neighbors[*next];
*next += 1;
match color[neighbor] {
Color::White => {
color.insert(neighbor, Color::Gray);
stack.push(neighbor);
frames.push((neighbor, 0));
}
Color::Gray => {
let start_at = stack.iter().position(|n| *n == neighbor).unwrap_or(0);
let mut path: Vec<&str> = stack[start_at..].to_vec();
path.push(neighbor);
errors.push(GraphError::Cycle {
path: path.join(" -> "),
});
return;
}
Color::Black => {}
}
} else {
color.insert(node, Color::Black);
stack.pop();
frames.pop();
}
}
}
}
fn check_edge_type_compat(graph: &Graph, errors: &mut Vec<GraphError>) {
let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
for edge in &graph.edges {
let (Some(from), Some(to)) = (by_id.get(edge.from.as_str()), by_id.get(edge.to.as_str()))
else {
continue;
};
if let (Some(out), Some(inp)) = (from.output_schema(), to.input_schema())
&& out != inp
{
errors.push(GraphError::EdgeTypeMismatch {
from: edge.from.clone(),
to: edge.to.clone(),
});
}
}
}
fn summarize(graph: &Graph) -> GraphSummary {
let has_inbound: HashSet<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
let has_outbound: HashSet<&str> = graph.edges.iter().map(|e| e.from.as_str()).collect();
let mut entry_nodes: Vec<String> = graph
.nodes
.iter()
.map(Node::id)
.filter(|id| !has_inbound.contains(id))
.map(str::to_owned)
.collect();
let mut terminal_nodes: Vec<String> = graph
.nodes
.iter()
.map(Node::id)
.filter(|id| !has_outbound.contains(id))
.map(str::to_owned)
.collect();
entry_nodes.sort();
terminal_nodes.sort();
GraphSummary {
node_count: graph.nodes.len(),
edge_count: graph.edges.len(),
entry_nodes,
terminal_nodes,
}
}
fn nearest(missing: &str, ids: &BTreeSet<&str>) -> Option<String> {
let mut best: Option<(usize, &str)> = None;
for candidate in ids {
let distance = levenshtein(missing, candidate);
if best.is_none_or(|(d, _)| distance < d) {
best = Some((distance, candidate));
}
}
best.and_then(|(distance, candidate)| {
let threshold = (missing.len().max(candidate.len()) / 3).max(1);
(distance <= threshold).then(|| candidate.to_owned())
})
}
fn levenshtein(a: &str, b: &str) -> usize {
let a = a.as_bytes();
let b = b.as_bytes();
let mut previous: Vec<usize> = (0..=b.len()).collect();
let mut current = vec![0usize; b.len() + 1];
for (i, &ac) in a.iter().enumerate() {
current[0] = i + 1;
for (j, &bc) in b.iter().enumerate() {
let cost = usize::from(ac != bc);
current[j + 1] = (previous[j + 1] + 1)
.min(current[j] + 1)
.min(previous[j] + cost);
}
std::mem::swap(&mut previous, &mut current);
}
previous[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{
AgentNode, BranchCase, BranchCondition, BranchNode, DelayNode, Edge, FoldBody, FoldJoin,
FoldNode, GateNode, MapBody, MapNode, ToolNode,
};
use serde_json::json;
use std::collections::BTreeMap;
fn hash() -> String {
format!("sha256:{}", "a".repeat(64))
}
fn agent(id: &str) -> Node {
Node::Agent(AgentNode {
name: None,
id: id.into(),
agent_hash: hash(),
input_schema: None,
output_schema: None,
})
}
fn gate(id: &str) -> Node {
Node::Gate(GateNode {
name: None,
id: id.into(),
prompt: None,
approval_schema: json!({"type": "object"}),
})
}
fn edge(from: &str, to: &str) -> Edge {
Edge {
from: from.into(),
to: to.into(),
label: None,
}
}
fn labeled_edge(from: &str, to: &str, label: &str) -> Edge {
Edge {
from: from.into(),
to: to.into(),
label: Some(label.into()),
}
}
fn graph(nodes: Vec<Node>, edges: Vec<Edge>) -> Graph {
Graph {
schema_version: SCHEMA_VERSION,
nodes,
edges,
}
}
#[test]
fn valid_linear_graph_summarizes() {
let g = graph(
vec![agent("research"), agent("review"), gate("approve")],
vec![edge("research", "review"), edge("review", "approve")],
);
let summary = validate(&g).expect("valid");
assert_eq!(summary.node_count, 3);
assert_eq!(summary.edge_count, 2);
assert_eq!(summary.entry_nodes, vec!["research"]);
assert_eq!(summary.terminal_nodes, vec!["approve"]);
}
#[test]
fn dangling_edge_is_reported_with_suggestion() {
let g = graph(vec![agent("research")], vec![edge("research", "reviewx")]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::DanglingEdge {
from: "research".into(),
to: "reviewx".into(),
missing: "reviewx".into(),
suggestion: Some("research".into()),
}) || matches!(
errors.first(),
Some(GraphError::DanglingEdge { missing, .. }) if missing == "reviewx"
)
);
let message = errors[0].to_string();
assert!(
message.contains("reviewx"),
"names the missing id: {message}"
);
}
#[test]
fn malformed_agent_hash_is_reported() {
let g = graph(
vec![Node::Agent(AgentNode {
name: None,
id: "research".into(),
agent_hash: "sha256:not-hex".into(),
input_schema: None,
output_schema: None,
})],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert_eq!(
errors,
vec![GraphError::MalformedAgentHash {
id: "research".into(),
hash: "sha256:not-hex".into(),
}]
);
}
#[test]
fn non_positive_concurrency_is_reported() {
let g = graph(
vec![
agent("worker"),
Node::Map(MapNode {
name: None,
id: "fanout".into(),
over: "items".into(),
concurrency: 0,
body: MapBody::Node("worker".into()),
output_schema: None,
}),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::NonPositiveConcurrency {
id: "fanout".into(),
found: 0,
}));
}
#[test]
fn non_positive_delay_is_reported() {
let g = graph(
vec![Node::Delay(DelayNode {
id: "cooloff".into(),
name: None,
seconds: 0,
})],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::NonPositiveDelay {
id: "cooloff".into(),
found: 0,
}));
let g = graph(
vec![Node::Delay(DelayNode {
id: "cooloff".into(),
name: None,
seconds: 1,
})],
vec![],
);
validate(&g).expect("a one-second wait is a legal wait");
}
#[test]
fn dangling_map_body_is_reported() {
let g = graph(
vec![Node::Map(MapNode {
name: None,
id: "fanout".into(),
over: "items".into(),
concurrency: 2,
body: MapBody::Node("ghost".into()),
output_schema: None,
})],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::DanglingMapBody {
id: "fanout".into(),
missing: "ghost".into(),
suggestion: None,
}));
}
#[test]
fn cycle_is_reported_with_path() {
let g = graph(
vec![agent("a"), agent("b"), agent("c")],
vec![edge("a", "b"), edge("b", "c"), edge("c", "a")],
);
let errors = validate(&g).expect_err("invalid");
let cycle = errors
.iter()
.find_map(|e| match e {
GraphError::Cycle { path } => Some(path.clone()),
_ => None,
})
.expect("a cycle error");
assert!(cycle.starts_with("a -> "), "path from a: {cycle}");
assert!(cycle.ends_with("-> a"), "path closes on a: {cycle}");
}
#[test]
fn edge_type_mismatch_is_reported() {
let producer = Node::Agent(AgentNode {
name: None,
id: "producer".into(),
agent_hash: hash(),
input_schema: None,
output_schema: Some(json!({"type": "string"})),
});
let consumer = Node::Tool(ToolNode {
name: None,
id: "consumer".into(),
tool: "t".into(),
input: BTreeMap::new(),
input_schema: Some(json!({"type": "number"})),
output_schema: None,
});
let g = graph(vec![producer, consumer], vec![edge("producer", "consumer")]);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::EdgeTypeMismatch {
from: "producer".into(),
to: "consumer".into(),
}));
}
#[test]
fn matching_edge_schemas_pass() {
let producer = Node::Agent(AgentNode {
name: None,
id: "producer".into(),
agent_hash: hash(),
input_schema: None,
output_schema: Some(json!({"type": "string"})),
});
let consumer = Node::Tool(ToolNode {
name: None,
id: "consumer".into(),
tool: "t".into(),
input: BTreeMap::new(),
input_schema: Some(json!({"type": "string"})),
output_schema: None,
});
let g = graph(vec![producer, consumer], vec![edge("producer", "consumer")]);
assert!(validate(&g).is_ok());
}
#[test]
fn future_schema_version_is_rejected() {
let mut g = graph(vec![agent("a")], vec![]);
g.schema_version = SCHEMA_VERSION + 1;
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::UnsupportedSchemaVersion {
found: SCHEMA_VERSION + 1,
supported: SCHEMA_VERSION,
}));
}
#[test]
fn all_errors_are_collected() {
let g = graph(
vec![
Node::Agent(AgentNode {
name: None,
id: "bad".into(),
agent_hash: "nope".into(),
input_schema: None,
output_schema: None,
}),
agent("bad"), ],
vec![edge("bad", "missing")],
);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.len() >= 3,
"duplicate id, malformed hash, and dangling edge: {errors:?}"
);
}
#[test]
fn duplicate_node_id_is_reported() {
let g = graph(vec![agent("dup"), gate("dup")], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::DuplicateNodeId { id: "dup".into() }));
}
#[test]
fn valid_branch_expression_passes() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: Some("score".into()),
agent_hash: Some(hash()),
cases: vec![
BranchCase {
name: "high".into(),
when: BranchCondition::Expression("score > 0.8".into()),
},
BranchCase {
name: "review".into(),
when: BranchCondition::ModelDecision,
},
],
});
let g = graph(
vec![
agent("score"),
branch,
agent("high_target"),
agent("review_target"),
],
vec![
edge("score", "route"),
labeled_edge("route", "high_target", "high"),
labeled_edge("route", "review_target", "review"),
],
);
assert!(validate(&g).is_ok(), "{:?}", validate(&g));
}
#[test]
fn invalid_branch_expression_is_reported() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: None,
agent_hash: Some(hash()),
cases: vec![
BranchCase {
name: "broken".into(),
when: BranchCondition::Expression("score >".into()),
},
BranchCase {
name: "fallback".into(),
when: BranchCondition::ModelDecision,
},
],
});
let g = graph(
vec![branch, agent("broken_target"), agent("fallback_target")],
vec![
labeled_edge("route", "broken_target", "broken"),
labeled_edge("route", "fallback_target", "fallback"),
],
);
let errors = validate(&g).expect_err("invalid");
assert!(
matches!(
errors.as_slice(),
[GraphError::InvalidBranchExpression { node, case, .. }]
if node == "route" && case == "broken"
),
"one node/case-precise expression error: {errors:?}"
);
}
#[test]
fn branch_case_without_edge_is_reported() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: None,
agent_hash: None,
cases: vec![
BranchCase {
name: "won".into(),
when: BranchCondition::Expression("outcome == \"won\"".into()),
},
BranchCase {
name: "lost".into(),
when: BranchCondition::Expression("outcome == \"lost\"".into()),
},
],
});
let g = graph(
vec![branch, agent("celebrate")],
vec![
labeled_edge("route", "celebrate", "won"),
labeled_edge("route", "celebrate", "lst"),
],
);
let errors = validate(&g).expect_err("invalid");
assert_eq!(
errors,
vec![
GraphError::BranchCaseWithoutEdge {
node: "route".into(),
case: "lost".into(),
},
GraphError::BranchEdgeWithoutCase {
node: "route".into(),
label: "lst".into(),
},
],
"names the unrouted case AND the mislabeled edge that caused it, nothing else: {errors:?}"
);
let message = errors[0].to_string();
assert!(
message.contains("route") && message.contains("lost"),
"{message}"
);
assert!(
message.contains("terminal node"),
"says what to do about a route meant to end the run: {message}"
);
}
#[test]
fn branch_edge_without_case_is_reported() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: None,
agent_hash: None,
cases: vec![
BranchCase {
name: "lost".into(),
when: BranchCondition::Expression("outcome == \"lost\"".into()),
},
BranchCase {
name: "paid".into(),
when: BranchCondition::Expression("outcome == \"paid\"".into()),
},
],
});
let g = graph(
vec![branch, agent("celebrate"), agent("close")],
vec![
labeled_edge("route", "close", "lst"),
labeled_edge("route", "celebrate", "paid"),
],
);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::BranchEdgeWithoutCase {
node: "route".into(),
label: "lst".into(),
}),
"names the node and the offending label: {errors:?}"
);
assert!(
errors.contains(&GraphError::BranchCaseWithoutEdge {
node: "route".into(),
case: "lost".into(),
}),
"the case left unrouted by the typo is also named: {errors:?}"
);
let message = errors
.iter()
.find_map(|e| match e {
GraphError::BranchEdgeWithoutCase { .. } => Some(e.to_string()),
_ => None,
})
.expect("a BranchEdgeWithoutCase error");
assert!(
message.contains("route") && message.contains("lst"),
"{message}"
);
assert!(
message.contains("case"),
"says what to do about the mismatched label: {message}"
);
}
#[test]
fn branch_edge_without_label_is_reported() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: None,
agent_hash: None,
cases: vec![
BranchCase {
name: "paid".into(),
when: BranchCondition::Expression("outcome == \"paid\"".into()),
},
BranchCase {
name: "lost".into(),
when: BranchCondition::Expression("outcome == \"lost\"".into()),
},
],
});
let g = graph(
vec![branch, agent("celebrate"), agent("close")],
vec![
labeled_edge("route", "celebrate", "paid"),
edge("route", "close"),
],
);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::BranchEdgeWithoutLabel {
node: "route".into(),
to: "close".into(),
}),
"names the branch node and the unlabelled edge's target: {errors:?}"
);
let message = errors
.iter()
.find_map(|e| match e {
GraphError::BranchEdgeWithoutLabel { .. } => Some(e.to_string()),
_ => None,
})
.expect("a BranchEdgeWithoutLabel error");
assert!(
message.contains("route") && message.contains("close"),
"{message}"
);
assert!(
message.contains("label"),
"says what to do about the unlabelled edge: {message}"
);
}
#[test]
fn model_decision_without_agent_is_reported() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: None,
agent_hash: None,
cases: vec![BranchCase {
name: "ask".into(),
when: BranchCondition::ModelDecision,
}],
});
let g = graph(vec![branch], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::ModelDecisionWithoutAgent {
node: "route".into(),
case: "ask".into(),
}),
"names the node and case: {errors:?}"
);
}
#[test]
fn malformed_branch_agent_hash_is_reported() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: None,
agent_hash: Some("sha256:not-hex".into()),
cases: vec![BranchCase {
name: "ask".into(),
when: BranchCondition::ModelDecision,
}],
});
let g = graph(vec![branch], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::MalformedAgentHash {
id: "route".into(),
hash: "sha256:not-hex".into(),
}),
"names the branch node and its malformed hash: {errors:?}"
);
}
fn fold(id: &str, body: &str, max_iterations: u32, stop_when: &str, join: FoldJoin) -> Node {
Node::Fold(FoldNode {
id: id.into(),
name: None,
body: FoldBody::Node(body.into()),
max_iterations,
stop_when: stop_when.into(),
join,
on_bound: None,
accumulator_schema: None,
})
}
#[test]
fn valid_fold_node_passes() {
let g = graph(
vec![
agent("tailor"),
fold(
"refine",
"tailor",
3,
"score >= 0.85",
FoldJoin::BestBy("score".into()),
),
],
vec![],
);
assert!(validate(&g).is_ok(), "{:?}", validate(&g));
}
#[test]
fn fold_with_unit_joins_passes() {
for join in [FoldJoin::Last, FoldJoin::All] {
let g = graph(
vec![agent("tailor"), fold("refine", "tailor", 2, "done", join)],
vec![],
);
assert!(validate(&g).is_ok());
}
}
#[test]
fn non_positive_max_iterations_is_reported() {
let g = graph(
vec![
agent("tailor"),
fold("refine", "tailor", 0, "done", FoldJoin::Last),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::NonPositiveMaxIterations {
id: "refine".into(),
found: 0,
}));
}
#[test]
fn dangling_fold_body_is_reported() {
let g = graph(
vec![fold("refine", "ghost", 2, "done", FoldJoin::Last)],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::DanglingFoldBody {
id: "refine".into(),
missing: "ghost".into(),
suggestion: None,
}));
}
#[test]
fn invalid_fold_stop_expression_is_reported() {
let g = graph(
vec![
agent("tailor"),
fold("refine", "tailor", 2, "score >", FoldJoin::Last),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(
matches!(
errors.as_slice(),
[GraphError::InvalidFoldStopExpression { node, .. }] if node == "refine"
),
"one node-precise stop-expression error: {errors:?}"
);
}
#[test]
fn invalid_fold_join_reference_is_reported() {
let g = graph(
vec![
agent("tailor"),
fold("refine", "tailor", 2, "done", FoldJoin::BestBy("42".into())),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.iter().any(
|e| matches!(e, GraphError::InvalidFoldJoinReference { node, reference, .. }
if node == "refine" && reference == "42")
),
"names the node and the bad reference: {errors:?}"
);
}
fn scorer(id: &str, output_schema: Value) -> Node {
Node::Agent(AgentNode {
name: None,
id: id.into(),
agent_hash: hash(),
input_schema: None,
output_schema: Some(output_schema),
})
}
fn score_schema() -> Value {
json!({
"type": "object",
"properties": { "score": { "type": "number" } },
"required": ["score"]
})
}
#[test]
fn fold_references_inside_the_body_schema_pass() {
let schema = json!({
"type": "object",
"properties": {
"score": { "type": "number" },
"review": {
"type": "object",
"properties": {
"overall_score": { "type": "number" },
"notes": {
"type": "array",
"items": { "type": "object", "properties": { "text": { "type": "string" } } }
}
}
}
}
});
let g = graph(
vec![
scorer("tailor", schema),
fold(
"refine",
"tailor",
3,
"score >= 0.85 && review.notes.0.text != \"\"",
FoldJoin::BestBy("review.overall_score".into()),
),
],
vec![],
);
assert!(validate(&g).is_ok(), "{:?}", validate(&g));
}
#[test]
fn fold_stop_path_outside_the_body_schema_is_reported() {
let g = graph(
vec![
scorer("tailor", score_schema()),
fold(
"refine",
"tailor",
3,
"scoer >= 0.85",
FoldJoin::BestBy("score".into()),
),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert_eq!(
errors,
vec![GraphError::FoldStopPathNotInBodySchema {
node: "refine".into(),
path: "scoer".into(),
body: "tailor".into(),
}]
);
let message = errors[0].to_string();
assert!(
message.contains("scoer") && message.contains("tailor"),
"names the path and the body node: {message}"
);
}
#[test]
fn fold_nested_stop_path_outside_the_body_schema_is_reported() {
let schema = json!({
"type": "object",
"properties": {
"review": { "type": "object", "properties": { "score": { "type": "number" } } }
}
});
let g = graph(
vec![
scorer("tailor", schema),
fold("refine", "tailor", 3, "review.rating > 3", FoldJoin::Last),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::FoldStopPathNotInBodySchema {
node: "refine".into(),
path: "review.rating".into(),
body: "tailor".into(),
}),
"{errors:?}"
);
}
#[test]
fn fold_join_reference_outside_the_body_schema_is_reported() {
let g = graph(
vec![
scorer("tailor", score_schema()),
fold(
"refine",
"tailor",
3,
"score >= 0.85",
FoldJoin::BestBy("review.overall_score".into()),
),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert_eq!(
errors,
vec![GraphError::FoldJoinReferenceNotInBodySchema {
node: "refine".into(),
reference: "review.overall_score".into(),
body: "tailor".into(),
}]
);
}
#[test]
fn every_fold_reference_fault_is_collected() {
let g = graph(
vec![
scorer("tailor", score_schema()),
fold(
"refine",
"tailor",
3,
"scoer >= 0.85 || rating > 3",
FoldJoin::BestBy("overall".into()),
),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert_eq!(errors.len(), 3, "{errors:?}");
}
#[test]
fn fold_references_go_unjudged_where_the_schema_says_nothing() {
let quiet: Vec<Option<Value>> = vec![
None,
Some(json!({ "type": "object" })),
Some(json!({ "type": "string" })),
Some(json!({
"type": "object",
"properties": { "score": { "type": "number" } },
"additionalProperties": true
})),
Some(json!({
"type": "object",
"properties": { "score": { "type": "number" } },
"patternProperties": { "^x_": { "type": "string" } }
})),
Some(json!({ "$ref": "#/$defs/pass" })),
Some(json!({
"anyOf": [{ "type": "object", "properties": { "score": { "type": "number" } } }]
})),
];
for schema in quiet {
let body = match schema {
Some(schema) => scorer("tailor", schema),
None => agent("tailor"),
};
let g = graph(
vec![
body,
fold(
"refine",
"tailor",
3,
"anything.at.all >= 0.85",
FoldJoin::BestBy("nothing.declared".into()),
),
],
vec![],
);
assert!(validate(&g).is_ok(), "{:?}", validate(&g));
}
let subgraph = Node::Fold(FoldNode {
name: None,
id: "refine".into(),
body: FoldBody::Subgraph(Box::new(graph(
vec![scorer("tailor", score_schema())],
vec![],
))),
max_iterations: 3,
stop_when: "anything.at.all >= 0.85".into(),
join: FoldJoin::BestBy("nothing.declared".into()),
on_bound: None,
accumulator_schema: None,
});
assert!(validate(&graph(vec![subgraph], vec![])).is_ok());
}
#[test]
fn a_closed_body_schema_reports_the_same_missing_path() {
let schema = json!({
"type": "object",
"properties": { "score": { "type": "number" } },
"additionalProperties": false
});
let g = graph(
vec![
scorer("tailor", schema),
fold("refine", "tailor", 3, "scoer >= 0.85", FoldJoin::Last),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::FoldStopPathNotInBodySchema {
node: "refine".into(),
path: "scoer".into(),
body: "tailor".into(),
}));
}
#[test]
fn an_unparseable_stop_predicate_is_not_also_a_shape_error() {
let g = graph(
vec![
scorer("tailor", score_schema()),
fold("refine", "tailor", 3, "score >", FoldJoin::Last),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(
matches!(
errors.as_slice(),
[GraphError::InvalidFoldStopExpression { node, .. }] if node == "refine"
),
"only the parse error: {errors:?}"
);
}
#[test]
fn node_name_at_the_cap_is_valid() {
let mut named = agent("research");
if let Node::Agent(a) = &mut named {
a.name = Some("a".repeat(MAX_NODE_NAME_LEN));
}
let g = graph(
vec![named, agent("review")],
vec![edge("research", "review")],
);
assert!(validate(&g).is_ok());
}
#[test]
fn node_name_too_long_is_reported() {
let mut named = agent("research");
let long_name = "é".repeat(MAX_NODE_NAME_LEN + 1);
if let Node::Agent(a) = &mut named {
a.name = Some(long_name.clone());
}
let g = graph(vec![named], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::NodeNameTooLong {
id: "research".into(),
len: MAX_NODE_NAME_LEN + 1,
max: MAX_NODE_NAME_LEN,
}),
"names the node and the character count, not the byte count: {errors:?}"
);
}
#[test]
fn blank_node_name_is_reported() {
for blank in ["", " ", "\t\n"] {
let mut named = gate("approve");
if let Node::Gate(g) = &mut named {
g.name = Some(blank.to_owned());
}
let g = graph(vec![named], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::BlankNodeName {
id: "approve".into(),
}),
"blank name {blank:?} should be reported: {errors:?}"
);
}
}
#[test]
fn multiple_node_name_errors_are_all_collected() {
let mut blank = agent("research");
if let Node::Agent(a) = &mut blank {
a.name = Some(" ".into());
}
let mut long = gate("approve");
if let Node::Gate(g) = &mut long {
g.name = Some("x".repeat(MAX_NODE_NAME_LEN + 5));
}
let g = graph(vec![blank, long], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::BlankNodeName {
id: "research".into(),
}),
"{errors:?}"
);
assert!(
errors.contains(&GraphError::NodeNameTooLong {
id: "approve".into(),
len: MAX_NODE_NAME_LEN + 5,
max: MAX_NODE_NAME_LEN,
}),
"{errors:?}"
);
}
}