1use std::collections::{BTreeSet, HashMap, HashSet};
16
17use crate::document::{BranchCondition, FoldBody, FoldJoin, Graph, MapBody, Node, SCHEMA_VERSION};
18use crate::expr;
19
20pub const MAX_NODE_NAME_LEN: usize = 64;
26
27#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
32pub enum GraphError {
33 #[error("unsupported schema_version {found}: this build understands versions 1..={supported}")]
36 UnsupportedSchemaVersion {
37 found: u32,
39 supported: u32,
41 },
42
43 #[error("duplicate node id `{id}`")]
45 DuplicateNodeId {
46 id: String,
48 },
49
50 #[error("edge `{from}` -> `{to}` references unknown node id `{missing}`{}", suggest(.suggestion))]
52 DanglingEdge {
53 from: String,
55 to: String,
57 missing: String,
59 suggestion: Option<String>,
61 },
62
63 #[error("map node `{id}` maps unknown node id `{missing}`{}", suggest(.suggestion))]
65 DanglingMapBody {
66 id: String,
68 missing: String,
70 suggestion: Option<String>,
72 },
73
74 #[error("fold node `{id}` folds unknown node id `{missing}`{}", suggest(.suggestion))]
76 DanglingFoldBody {
77 id: String,
79 missing: String,
81 suggestion: Option<String>,
83 },
84
85 #[error("agent node `{id}`: `{hash}` is not a well-formed `sha256:<64 hex>` agent hash")]
88 MalformedAgentHash {
89 id: String,
91 hash: String,
93 },
94
95 #[error("map node `{id}`: concurrency cap must be at least 1, found {found}")]
97 NonPositiveConcurrency {
98 id: String,
100 found: u32,
102 },
103
104 #[error("fold node `{id}`: max_iterations must be at least 1, found {found}")]
106 NonPositiveMaxIterations {
107 id: String,
109 found: u32,
111 },
112
113 #[error("gate node `{id}`: approval_schema must be a JSON object")]
115 ApprovalSchemaNotObject {
116 id: String,
118 },
119
120 #[error("cycle detected: {path}")]
122 Cycle {
123 path: String,
125 },
126
127 #[error(
130 "edge `{from}` -> `{to}`: the output schema of `{from}` does not match the input schema of `{to}`"
131 )]
132 EdgeTypeMismatch {
133 from: String,
135 to: String,
137 },
138
139 #[error("branch node `{node}`: case `{case}` has an invalid condition expression: {error}")]
143 InvalidBranchExpression {
144 node: String,
146 case: String,
148 error: String,
150 },
151
152 #[error(
156 "branch node `{node}`: case `{case}` is a model decision but the branch declares no `agent_hash`"
157 )]
158 ModelDecisionWithoutAgent {
159 node: String,
161 case: String,
163 },
164
165 #[error("fold node `{node}`: `stop_when` is not a valid condition expression: {error}")]
169 InvalidFoldStopExpression {
170 node: String,
172 error: String,
174 },
175
176 #[error(
180 "fold node `{node}`: the `best_by` join reference `{reference}` is not a valid path: {error}"
181 )]
182 InvalidFoldJoinReference {
183 node: String,
185 reference: String,
187 error: String,
189 },
190
191 #[error("node `{id}`: `name` is {len} characters, over the {max}-character cap")]
193 NodeNameTooLong {
194 id: String,
196 len: usize,
198 max: usize,
200 },
201
202 #[error("node `{id}`: `name`, if set, must not be empty or all whitespace")]
204 BlankNodeName {
205 id: String,
207 },
208}
209
210fn suggest(suggestion: &Option<String>) -> String {
212 match suggestion {
213 Some(name) => format!(" (did you mean `{name}`?)"),
214 None => String::new(),
215 }
216}
217
218#[derive(Clone, Debug, PartialEq, Eq)]
223pub struct GraphSummary {
224 pub node_count: usize,
226 pub edge_count: usize,
228 pub entry_nodes: Vec<String>,
230 pub terminal_nodes: Vec<String>,
232}
233
234pub fn validate(graph: &Graph) -> Result<GraphSummary, Vec<GraphError>> {
245 let mut errors = Vec::new();
246
247 check_schema_version(graph, &mut errors);
248 check_unique_node_ids(graph, &mut errors);
249 check_referential_integrity(graph, &mut errors);
250 check_node_fields(graph, &mut errors);
251 check_node_names(graph, &mut errors);
252 check_branch_expressions(graph, &mut errors);
253 check_fold_expressions(graph, &mut errors);
254 check_acyclic(graph, &mut errors);
255 check_edge_type_compat(graph, &mut errors);
256
257 if errors.is_empty() {
258 Ok(summarize(graph))
259 } else {
260 Err(errors)
261 }
262}
263
264fn check_schema_version(graph: &Graph, errors: &mut Vec<GraphError>) {
268 if graph.schema_version == 0 || graph.schema_version > SCHEMA_VERSION {
269 errors.push(GraphError::UnsupportedSchemaVersion {
270 found: graph.schema_version,
271 supported: SCHEMA_VERSION,
272 });
273 }
274}
275
276fn check_unique_node_ids(graph: &Graph, errors: &mut Vec<GraphError>) {
278 let mut seen = HashSet::new();
279 for node in &graph.nodes {
280 if !seen.insert(node.id()) {
281 errors.push(GraphError::DuplicateNodeId {
282 id: node.id().to_owned(),
283 });
284 }
285 }
286}
287
288fn check_referential_integrity(graph: &Graph, errors: &mut Vec<GraphError>) {
293 let ids: BTreeSet<&str> = graph.nodes.iter().map(Node::id).collect();
294
295 for edge in &graph.edges {
296 if !ids.contains(edge.from.as_str()) {
297 errors.push(GraphError::DanglingEdge {
298 from: edge.from.clone(),
299 to: edge.to.clone(),
300 missing: edge.from.clone(),
301 suggestion: nearest(&edge.from, &ids),
302 });
303 }
304 if !ids.contains(edge.to.as_str()) {
305 errors.push(GraphError::DanglingEdge {
306 from: edge.from.clone(),
307 to: edge.to.clone(),
308 missing: edge.to.clone(),
309 suggestion: nearest(&edge.to, &ids),
310 });
311 }
312 }
313
314 for node in &graph.nodes {
315 if let Node::Map(map) = node
316 && let MapBody::Node(target) = &map.body
317 && !ids.contains(target.as_str())
318 {
319 errors.push(GraphError::DanglingMapBody {
320 id: map.id.clone(),
321 missing: target.clone(),
322 suggestion: nearest(target, &ids),
323 });
324 }
325 if let Node::Fold(fold) = node
326 && let FoldBody::Node(target) = &fold.body
327 && !ids.contains(target.as_str())
328 {
329 errors.push(GraphError::DanglingFoldBody {
330 id: fold.id.clone(),
331 missing: target.clone(),
332 suggestion: nearest(target, &ids),
333 });
334 }
335 }
336}
337
338fn check_node_fields(graph: &Graph, errors: &mut Vec<GraphError>) {
342 for node in &graph.nodes {
343 match node {
344 Node::Agent(agent) => {
345 if !is_well_formed_agent_hash(&agent.agent_hash) {
346 errors.push(GraphError::MalformedAgentHash {
347 id: agent.id.clone(),
348 hash: agent.agent_hash.clone(),
349 });
350 }
351 }
352 Node::Map(map) => {
353 if map.concurrency < 1 {
354 errors.push(GraphError::NonPositiveConcurrency {
355 id: map.id.clone(),
356 found: map.concurrency,
357 });
358 }
359 }
360 Node::Gate(gate) => {
361 if !gate.approval_schema.is_object() {
362 errors.push(GraphError::ApprovalSchemaNotObject {
363 id: gate.id.clone(),
364 });
365 }
366 }
367 Node::Fold(fold) => {
368 if fold.max_iterations < 1 {
369 errors.push(GraphError::NonPositiveMaxIterations {
370 id: fold.id.clone(),
371 found: fold.max_iterations,
372 });
373 }
374 }
375 Node::Tool(_) | Node::Branch(_) => {}
377 }
378 }
379}
380
381fn check_node_names(graph: &Graph, errors: &mut Vec<GraphError>) {
387 for node in &graph.nodes {
388 let Some(name) = node.name() else {
389 continue;
390 };
391 if name.trim().is_empty() {
392 errors.push(GraphError::BlankNodeName {
393 id: node.id().to_owned(),
394 });
395 continue;
396 }
397 let len = name.chars().count();
398 if len > MAX_NODE_NAME_LEN {
399 errors.push(GraphError::NodeNameTooLong {
400 id: node.id().to_owned(),
401 len,
402 max: MAX_NODE_NAME_LEN,
403 });
404 }
405 }
406}
407
408fn check_branch_expressions(graph: &Graph, errors: &mut Vec<GraphError>) {
424 for node in &graph.nodes {
425 let Node::Branch(branch) = node else {
426 continue;
427 };
428 if let Some(hash) = &branch.agent_hash
429 && !is_well_formed_agent_hash(hash)
430 {
431 errors.push(GraphError::MalformedAgentHash {
432 id: branch.id.clone(),
433 hash: hash.clone(),
434 });
435 }
436 for case in &branch.cases {
437 match &case.when {
438 BranchCondition::Expression(source) => {
439 if let Err(error) = expr::parse(source) {
440 errors.push(GraphError::InvalidBranchExpression {
441 node: branch.id.clone(),
442 case: case.name.clone(),
443 error: error.to_string(),
444 });
445 }
446 }
447 BranchCondition::ModelDecision => {
448 if branch.agent_hash.is_none() {
449 errors.push(GraphError::ModelDecisionWithoutAgent {
450 node: branch.id.clone(),
451 case: case.name.clone(),
452 });
453 }
454 }
455 }
456 }
457 }
458}
459
460fn check_fold_expressions(graph: &Graph, errors: &mut Vec<GraphError>) {
473 for node in &graph.nodes {
474 let Node::Fold(fold) = node else {
475 continue;
476 };
477 if let Err(error) = expr::parse(&fold.stop_when) {
478 errors.push(GraphError::InvalidFoldStopExpression {
479 node: fold.id.clone(),
480 error: error.to_string(),
481 });
482 }
483 if let FoldJoin::BestBy(reference) = &fold.join
484 && let Err(error) = expr::parse_reference(reference)
485 {
486 errors.push(GraphError::InvalidFoldJoinReference {
487 node: fold.id.clone(),
488 reference: reference.clone(),
489 error: error.to_string(),
490 });
491 }
492 }
493}
494
495fn is_well_formed_agent_hash(hash: &str) -> bool {
497 let Some(hex) = hash.strip_prefix("sha256:") else {
498 return false;
499 };
500 hex.len() == 64
501 && hex
502 .bytes()
503 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
504}
505
506fn check_acyclic(graph: &Graph, errors: &mut Vec<GraphError>) {
513 let ids: HashSet<&str> = graph.nodes.iter().map(Node::id).collect();
516 let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
517 for edge in &graph.edges {
518 if ids.contains(edge.from.as_str()) && ids.contains(edge.to.as_str()) {
519 adjacency
520 .entry(edge.from.as_str())
521 .or_default()
522 .push(edge.to.as_str());
523 }
524 }
525
526 #[derive(Clone, Copy, PartialEq)]
527 enum Color {
528 White,
529 Gray,
530 Black,
531 }
532 let mut color: HashMap<&str, Color> = ids.iter().map(|id| (*id, Color::White)).collect();
533 let mut stack: Vec<&str> = Vec::new();
534
535 for start in graph.nodes.iter().map(Node::id) {
539 if color[start] != Color::White {
540 continue;
541 }
542 let mut frames: Vec<(&str, usize)> = vec![(start, 0)];
543 color.insert(start, Color::Gray);
544 stack.push(start);
545
546 while let Some(&mut (node, ref mut next)) = frames.last_mut() {
547 let neighbors = adjacency.get(node).map_or(&[][..], Vec::as_slice);
548 if *next < neighbors.len() {
549 let neighbor = neighbors[*next];
550 *next += 1;
551 match color[neighbor] {
552 Color::White => {
553 color.insert(neighbor, Color::Gray);
554 stack.push(neighbor);
555 frames.push((neighbor, 0));
556 }
557 Color::Gray => {
558 let start_at = stack.iter().position(|n| *n == neighbor).unwrap_or(0);
562 let mut path: Vec<&str> = stack[start_at..].to_vec();
563 path.push(neighbor);
564 errors.push(GraphError::Cycle {
565 path: path.join(" -> "),
566 });
567 return;
568 }
569 Color::Black => {}
570 }
571 } else {
572 color.insert(node, Color::Black);
573 stack.pop();
574 frames.pop();
575 }
576 }
577 }
578}
579
580fn check_edge_type_compat(graph: &Graph, errors: &mut Vec<GraphError>) {
597 let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
598
599 for edge in &graph.edges {
600 let (Some(from), Some(to)) = (by_id.get(edge.from.as_str()), by_id.get(edge.to.as_str()))
601 else {
602 continue;
604 };
605 if let (Some(out), Some(inp)) = (from.output_schema(), to.input_schema())
606 && out != inp
607 {
608 errors.push(GraphError::EdgeTypeMismatch {
609 from: edge.from.clone(),
610 to: edge.to.clone(),
611 });
612 }
613 }
614}
615
616fn summarize(graph: &Graph) -> GraphSummary {
619 let has_inbound: HashSet<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
620 let has_outbound: HashSet<&str> = graph.edges.iter().map(|e| e.from.as_str()).collect();
621
622 let mut entry_nodes: Vec<String> = graph
623 .nodes
624 .iter()
625 .map(Node::id)
626 .filter(|id| !has_inbound.contains(id))
627 .map(str::to_owned)
628 .collect();
629 let mut terminal_nodes: Vec<String> = graph
630 .nodes
631 .iter()
632 .map(Node::id)
633 .filter(|id| !has_outbound.contains(id))
634 .map(str::to_owned)
635 .collect();
636 entry_nodes.sort();
637 terminal_nodes.sort();
638
639 GraphSummary {
640 node_count: graph.nodes.len(),
641 edge_count: graph.edges.len(),
642 entry_nodes,
643 terminal_nodes,
644 }
645}
646
647fn nearest(missing: &str, ids: &BTreeSet<&str>) -> Option<String> {
651 let mut best: Option<(usize, &str)> = None;
652 for candidate in ids {
653 let distance = levenshtein(missing, candidate);
654 if best.is_none_or(|(d, _)| distance < d) {
655 best = Some((distance, candidate));
656 }
657 }
658 best.and_then(|(distance, candidate)| {
659 let threshold = (missing.len().max(candidate.len()) / 3).max(1);
660 (distance <= threshold).then(|| candidate.to_owned())
661 })
662}
663
664fn levenshtein(a: &str, b: &str) -> usize {
667 let a = a.as_bytes();
668 let b = b.as_bytes();
669 let mut previous: Vec<usize> = (0..=b.len()).collect();
670 let mut current = vec![0usize; b.len() + 1];
671 for (i, &ac) in a.iter().enumerate() {
672 current[0] = i + 1;
673 for (j, &bc) in b.iter().enumerate() {
674 let cost = usize::from(ac != bc);
675 current[j + 1] = (previous[j + 1] + 1)
676 .min(current[j] + 1)
677 .min(previous[j] + cost);
678 }
679 std::mem::swap(&mut previous, &mut current);
680 }
681 previous[b.len()]
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use crate::document::{
688 AgentNode, BranchCase, BranchCondition, BranchNode, Edge, FoldBody, FoldJoin, FoldNode,
689 GateNode, MapBody, MapNode, ToolNode,
690 };
691 use serde_json::json;
692 use std::collections::BTreeMap;
693
694 fn hash() -> String {
695 format!("sha256:{}", "a".repeat(64))
696 }
697
698 fn agent(id: &str) -> Node {
699 Node::Agent(AgentNode {
700 name: None,
701 id: id.into(),
702 agent_hash: hash(),
703 input_schema: None,
704 output_schema: None,
705 })
706 }
707
708 fn gate(id: &str) -> Node {
709 Node::Gate(GateNode {
710 name: None,
711 id: id.into(),
712 prompt: None,
713 approval_schema: json!({"type": "object"}),
714 })
715 }
716
717 fn edge(from: &str, to: &str) -> Edge {
718 Edge {
719 from: from.into(),
720 to: to.into(),
721 label: None,
722 }
723 }
724
725 fn graph(nodes: Vec<Node>, edges: Vec<Edge>) -> Graph {
726 Graph {
727 schema_version: SCHEMA_VERSION,
728 nodes,
729 edges,
730 }
731 }
732
733 #[test]
736 fn valid_linear_graph_summarizes() {
737 let g = graph(
738 vec![agent("research"), agent("review"), gate("approve")],
739 vec![edge("research", "review"), edge("review", "approve")],
740 );
741 let summary = validate(&g).expect("valid");
742 assert_eq!(summary.node_count, 3);
743 assert_eq!(summary.edge_count, 2);
744 assert_eq!(summary.entry_nodes, vec!["research"]);
745 assert_eq!(summary.terminal_nodes, vec!["approve"]);
746 }
747
748 #[test]
751 fn dangling_edge_is_reported_with_suggestion() {
752 let g = graph(vec![agent("research")], vec![edge("research", "reviewx")]);
753 let errors = validate(&g).expect_err("invalid");
754 assert!(
755 errors.contains(&GraphError::DanglingEdge {
756 from: "research".into(),
757 to: "reviewx".into(),
758 missing: "reviewx".into(),
759 suggestion: Some("research".into()),
760 }) || matches!(
761 errors.first(),
762 Some(GraphError::DanglingEdge { missing, .. }) if missing == "reviewx"
763 )
764 );
765 let message = errors[0].to_string();
766 assert!(
767 message.contains("reviewx"),
768 "names the missing id: {message}"
769 );
770 }
771
772 #[test]
774 fn malformed_agent_hash_is_reported() {
775 let g = graph(
776 vec![Node::Agent(AgentNode {
777 name: None,
778 id: "research".into(),
779 agent_hash: "sha256:not-hex".into(),
780 input_schema: None,
781 output_schema: None,
782 })],
783 vec![],
784 );
785 let errors = validate(&g).expect_err("invalid");
786 assert_eq!(
787 errors,
788 vec![GraphError::MalformedAgentHash {
789 id: "research".into(),
790 hash: "sha256:not-hex".into(),
791 }]
792 );
793 }
794
795 #[test]
797 fn non_positive_concurrency_is_reported() {
798 let g = graph(
799 vec![
800 agent("worker"),
801 Node::Map(MapNode {
802 name: None,
803 id: "fanout".into(),
804 over: "items".into(),
805 concurrency: 0,
806 body: MapBody::Node("worker".into()),
807 output_schema: None,
808 }),
809 ],
810 vec![],
811 );
812 let errors = validate(&g).expect_err("invalid");
813 assert!(errors.contains(&GraphError::NonPositiveConcurrency {
814 id: "fanout".into(),
815 found: 0,
816 }));
817 }
818
819 #[test]
821 fn dangling_map_body_is_reported() {
822 let g = graph(
823 vec![Node::Map(MapNode {
824 name: None,
825 id: "fanout".into(),
826 over: "items".into(),
827 concurrency: 2,
828 body: MapBody::Node("ghost".into()),
829 output_schema: None,
830 })],
831 vec![],
832 );
833 let errors = validate(&g).expect_err("invalid");
834 assert!(errors.contains(&GraphError::DanglingMapBody {
835 id: "fanout".into(),
836 missing: "ghost".into(),
837 suggestion: None,
838 }));
839 }
840
841 #[test]
843 fn cycle_is_reported_with_path() {
844 let g = graph(
845 vec![agent("a"), agent("b"), agent("c")],
846 vec![edge("a", "b"), edge("b", "c"), edge("c", "a")],
847 );
848 let errors = validate(&g).expect_err("invalid");
849 let cycle = errors
850 .iter()
851 .find_map(|e| match e {
852 GraphError::Cycle { path } => Some(path.clone()),
853 _ => None,
854 })
855 .expect("a cycle error");
856 assert!(cycle.starts_with("a -> "), "path from a: {cycle}");
857 assert!(cycle.ends_with("-> a"), "path closes on a: {cycle}");
858 }
859
860 #[test]
863 fn edge_type_mismatch_is_reported() {
864 let producer = Node::Agent(AgentNode {
865 name: None,
866 id: "producer".into(),
867 agent_hash: hash(),
868 input_schema: None,
869 output_schema: Some(json!({"type": "string"})),
870 });
871 let consumer = Node::Tool(ToolNode {
872 name: None,
873 id: "consumer".into(),
874 tool: "t".into(),
875 input: BTreeMap::new(),
876 input_schema: Some(json!({"type": "number"})),
877 output_schema: None,
878 });
879 let g = graph(vec![producer, consumer], vec![edge("producer", "consumer")]);
880 let errors = validate(&g).expect_err("invalid");
881 assert!(errors.contains(&GraphError::EdgeTypeMismatch {
882 from: "producer".into(),
883 to: "consumer".into(),
884 }));
885 }
886
887 #[test]
889 fn matching_edge_schemas_pass() {
890 let producer = Node::Agent(AgentNode {
891 name: None,
892 id: "producer".into(),
893 agent_hash: hash(),
894 input_schema: None,
895 output_schema: Some(json!({"type": "string"})),
896 });
897 let consumer = Node::Tool(ToolNode {
898 name: None,
899 id: "consumer".into(),
900 tool: "t".into(),
901 input: BTreeMap::new(),
902 input_schema: Some(json!({"type": "string"})),
903 output_schema: None,
904 });
905 let g = graph(vec![producer, consumer], vec![edge("producer", "consumer")]);
906 assert!(validate(&g).is_ok());
907 }
908
909 #[test]
911 fn future_schema_version_is_rejected() {
912 let mut g = graph(vec![agent("a")], vec![]);
913 g.schema_version = SCHEMA_VERSION + 1;
914 let errors = validate(&g).expect_err("invalid");
915 assert!(errors.contains(&GraphError::UnsupportedSchemaVersion {
916 found: SCHEMA_VERSION + 1,
917 supported: SCHEMA_VERSION,
918 }));
919 }
920
921 #[test]
924 fn all_errors_are_collected() {
925 let g = graph(
926 vec![
927 Node::Agent(AgentNode {
928 name: None,
929 id: "bad".into(),
930 agent_hash: "nope".into(),
931 input_schema: None,
932 output_schema: None,
933 }),
934 agent("bad"), ],
936 vec![edge("bad", "missing")],
937 );
938 let errors = validate(&g).expect_err("invalid");
939 assert!(
940 errors.len() >= 3,
941 "duplicate id, malformed hash, and dangling edge: {errors:?}"
942 );
943 }
944
945 #[test]
947 fn duplicate_node_id_is_reported() {
948 let g = graph(vec![agent("dup"), gate("dup")], vec![]);
949 let errors = validate(&g).expect_err("invalid");
950 assert!(errors.contains(&GraphError::DuplicateNodeId { id: "dup".into() }));
951 }
952
953 #[test]
956 fn valid_branch_expression_passes() {
957 let branch = Node::Branch(BranchNode {
958 name: None,
959 id: "route".into(),
960 on: Some("score".into()),
961 agent_hash: Some(hash()),
962 cases: vec![
963 BranchCase {
964 name: "high".into(),
965 when: BranchCondition::Expression("score > 0.8".into()),
966 },
967 BranchCase {
968 name: "review".into(),
969 when: BranchCondition::ModelDecision,
970 },
971 ],
972 });
973 let g = graph(vec![agent("score"), branch], vec![edge("score", "route")]);
974 assert!(validate(&g).is_ok());
975 }
976
977 #[test]
980 fn invalid_branch_expression_is_reported() {
981 let branch = Node::Branch(BranchNode {
982 name: None,
983 id: "route".into(),
984 on: None,
985 agent_hash: Some(hash()),
986 cases: vec![
987 BranchCase {
988 name: "broken".into(),
989 when: BranchCondition::Expression("score >".into()),
990 },
991 BranchCase {
992 name: "fallback".into(),
993 when: BranchCondition::ModelDecision,
994 },
995 ],
996 });
997 let g = graph(vec![branch], vec![]);
998 let errors = validate(&g).expect_err("invalid");
999 assert!(
1000 matches!(
1001 errors.as_slice(),
1002 [GraphError::InvalidBranchExpression { node, case, .. }]
1003 if node == "route" && case == "broken"
1004 ),
1005 "one node/case-precise expression error: {errors:?}"
1006 );
1007 }
1008
1009 #[test]
1013 fn model_decision_without_agent_is_reported() {
1014 let branch = Node::Branch(BranchNode {
1015 name: None,
1016 id: "route".into(),
1017 on: None,
1018 agent_hash: None,
1019 cases: vec![BranchCase {
1020 name: "ask".into(),
1021 when: BranchCondition::ModelDecision,
1022 }],
1023 });
1024 let g = graph(vec![branch], vec![]);
1025 let errors = validate(&g).expect_err("invalid");
1026 assert!(
1027 errors.contains(&GraphError::ModelDecisionWithoutAgent {
1028 node: "route".into(),
1029 case: "ask".into(),
1030 }),
1031 "names the node and case: {errors:?}"
1032 );
1033 }
1034
1035 #[test]
1038 fn malformed_branch_agent_hash_is_reported() {
1039 let branch = Node::Branch(BranchNode {
1040 name: None,
1041 id: "route".into(),
1042 on: None,
1043 agent_hash: Some("sha256:not-hex".into()),
1044 cases: vec![BranchCase {
1045 name: "ask".into(),
1046 when: BranchCondition::ModelDecision,
1047 }],
1048 });
1049 let g = graph(vec![branch], vec![]);
1050 let errors = validate(&g).expect_err("invalid");
1051 assert!(
1052 errors.contains(&GraphError::MalformedAgentHash {
1053 id: "route".into(),
1054 hash: "sha256:not-hex".into(),
1055 }),
1056 "names the branch node and its malformed hash: {errors:?}"
1057 );
1058 }
1059
1060 fn fold(id: &str, body: &str, max_iterations: u32, stop_when: &str, join: FoldJoin) -> Node {
1063 Node::Fold(FoldNode {
1064 id: id.into(),
1065 name: None,
1066 body: FoldBody::Node(body.into()),
1067 max_iterations,
1068 stop_when: stop_when.into(),
1069 join,
1070 accumulator_schema: None,
1071 })
1072 }
1073
1074 #[test]
1077 fn valid_fold_node_passes() {
1078 let g = graph(
1079 vec![
1080 agent("tailor"),
1081 fold(
1082 "refine",
1083 "tailor",
1084 3,
1085 "score >= 0.85",
1086 FoldJoin::BestBy("score".into()),
1087 ),
1088 ],
1089 vec![],
1090 );
1091 assert!(validate(&g).is_ok(), "{:?}", validate(&g));
1092 }
1093
1094 #[test]
1097 fn fold_with_unit_joins_passes() {
1098 for join in [FoldJoin::Last, FoldJoin::All] {
1099 let g = graph(
1100 vec![agent("tailor"), fold("refine", "tailor", 2, "done", join)],
1101 vec![],
1102 );
1103 assert!(validate(&g).is_ok());
1104 }
1105 }
1106
1107 #[test]
1109 fn non_positive_max_iterations_is_reported() {
1110 let g = graph(
1111 vec![
1112 agent("tailor"),
1113 fold("refine", "tailor", 0, "done", FoldJoin::Last),
1114 ],
1115 vec![],
1116 );
1117 let errors = validate(&g).expect_err("invalid");
1118 assert!(errors.contains(&GraphError::NonPositiveMaxIterations {
1119 id: "refine".into(),
1120 found: 0,
1121 }));
1122 }
1123
1124 #[test]
1127 fn dangling_fold_body_is_reported() {
1128 let g = graph(
1129 vec![fold("refine", "ghost", 2, "done", FoldJoin::Last)],
1130 vec![],
1131 );
1132 let errors = validate(&g).expect_err("invalid");
1133 assert!(errors.contains(&GraphError::DanglingFoldBody {
1134 id: "refine".into(),
1135 missing: "ghost".into(),
1136 suggestion: None,
1137 }));
1138 }
1139
1140 #[test]
1142 fn invalid_fold_stop_expression_is_reported() {
1143 let g = graph(
1144 vec![
1145 agent("tailor"),
1146 fold("refine", "tailor", 2, "score >", FoldJoin::Last),
1147 ],
1148 vec![],
1149 );
1150 let errors = validate(&g).expect_err("invalid");
1151 assert!(
1152 matches!(
1153 errors.as_slice(),
1154 [GraphError::InvalidFoldStopExpression { node, .. }] if node == "refine"
1155 ),
1156 "one node-precise stop-expression error: {errors:?}"
1157 );
1158 }
1159
1160 #[test]
1163 fn invalid_fold_join_reference_is_reported() {
1164 let g = graph(
1165 vec![
1166 agent("tailor"),
1167 fold("refine", "tailor", 2, "done", FoldJoin::BestBy("42".into())),
1168 ],
1169 vec![],
1170 );
1171 let errors = validate(&g).expect_err("invalid");
1172 assert!(
1173 errors.iter().any(
1174 |e| matches!(e, GraphError::InvalidFoldJoinReference { node, reference, .. }
1175 if node == "refine" && reference == "42")
1176 ),
1177 "names the node and the bad reference: {errors:?}"
1178 );
1179 }
1180
1181 #[test]
1184 fn node_name_at_the_cap_is_valid() {
1185 let mut named = agent("research");
1186 if let Node::Agent(a) = &mut named {
1187 a.name = Some("a".repeat(MAX_NODE_NAME_LEN));
1188 }
1189 let g = graph(
1190 vec![named, agent("review")],
1191 vec![edge("research", "review")],
1192 );
1193 assert!(validate(&g).is_ok());
1194 }
1195
1196 #[test]
1200 fn node_name_too_long_is_reported() {
1201 let mut named = agent("research");
1202 let long_name = "é".repeat(MAX_NODE_NAME_LEN + 1);
1203 if let Node::Agent(a) = &mut named {
1204 a.name = Some(long_name.clone());
1205 }
1206 let g = graph(vec![named], vec![]);
1207 let errors = validate(&g).expect_err("invalid");
1208 assert!(
1209 errors.contains(&GraphError::NodeNameTooLong {
1210 id: "research".into(),
1211 len: MAX_NODE_NAME_LEN + 1,
1212 max: MAX_NODE_NAME_LEN,
1213 }),
1214 "names the node and the character count, not the byte count: {errors:?}"
1215 );
1216 }
1217
1218 #[test]
1221 fn blank_node_name_is_reported() {
1222 for blank in ["", " ", "\t\n"] {
1223 let mut named = gate("approve");
1224 if let Node::Gate(g) = &mut named {
1225 g.name = Some(blank.to_owned());
1226 }
1227 let g = graph(vec![named], vec![]);
1228 let errors = validate(&g).expect_err("invalid");
1229 assert!(
1230 errors.contains(&GraphError::BlankNodeName {
1231 id: "approve".into(),
1232 }),
1233 "blank name {blank:?} should be reported: {errors:?}"
1234 );
1235 }
1236 }
1237
1238 #[test]
1241 fn multiple_node_name_errors_are_all_collected() {
1242 let mut blank = agent("research");
1243 if let Node::Agent(a) = &mut blank {
1244 a.name = Some(" ".into());
1245 }
1246 let mut long = gate("approve");
1247 if let Node::Gate(g) = &mut long {
1248 g.name = Some("x".repeat(MAX_NODE_NAME_LEN + 5));
1249 }
1250 let g = graph(vec![blank, long], vec![]);
1251 let errors = validate(&g).expect_err("invalid");
1252 assert!(
1253 errors.contains(&GraphError::BlankNodeName {
1254 id: "research".into(),
1255 }),
1256 "{errors:?}"
1257 );
1258 assert!(
1259 errors.contains(&GraphError::NodeNameTooLong {
1260 id: "approve".into(),
1261 len: MAX_NODE_NAME_LEN + 5,
1262 max: MAX_NODE_NAME_LEN,
1263 }),
1264 "{errors:?}"
1265 );
1266 }
1267}