1use std::collections::{BTreeSet, HashMap, HashSet};
16
17use serde_json::Value;
18
19use crate::document::{BranchCondition, FoldBody, FoldJoin, Graph, MapBody, Node, SCHEMA_VERSION};
20use crate::expr;
21
22pub const MAX_NODE_NAME_LEN: usize = 64;
28
29#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
34pub enum GraphError {
35 #[error("unsupported schema_version {found}: this build understands versions 1..={supported}")]
38 UnsupportedSchemaVersion {
39 found: u32,
41 supported: u32,
43 },
44
45 #[error("duplicate node id `{id}`")]
47 DuplicateNodeId {
48 id: String,
50 },
51
52 #[error("edge `{from}` -> `{to}` references unknown node id `{missing}`{}", suggest(.suggestion))]
54 DanglingEdge {
55 from: String,
57 to: String,
59 missing: String,
61 suggestion: Option<String>,
63 },
64
65 #[error("map node `{id}` maps unknown node id `{missing}`{}", suggest(.suggestion))]
67 DanglingMapBody {
68 id: String,
70 missing: String,
72 suggestion: Option<String>,
74 },
75
76 #[error("fold node `{id}` folds unknown node id `{missing}`{}", suggest(.suggestion))]
78 DanglingFoldBody {
79 id: String,
81 missing: String,
83 suggestion: Option<String>,
85 },
86
87 #[error("agent node `{id}`: `{hash}` is not a well-formed `sha256:<64 hex>` agent hash")]
90 MalformedAgentHash {
91 id: String,
93 hash: String,
95 },
96
97 #[error("map node `{id}`: concurrency cap must be at least 1, found {found}")]
99 NonPositiveConcurrency {
100 id: String,
102 found: u32,
104 },
105
106 #[error("fold node `{id}`: max_iterations must be at least 1, found {found}")]
108 NonPositiveMaxIterations {
109 id: String,
111 found: u32,
113 },
114
115 #[error("delay node `{id}`: seconds must be at least 1, found {found}")]
125 NonPositiveDelay {
126 id: String,
128 found: u64,
130 },
131
132 #[error("gate node `{id}`: approval_schema must be a JSON object")]
134 ApprovalSchemaNotObject {
135 id: String,
137 },
138
139 #[error("cycle detected: {path}")]
141 Cycle {
142 path: String,
144 },
145
146 #[error(
149 "edge `{from}` -> `{to}`: the output schema of `{from}` does not match the input schema of `{to}`"
150 )]
151 EdgeTypeMismatch {
152 from: String,
154 to: String,
156 },
157
158 #[error("branch node `{node}`: case `{case}` has an invalid condition expression: {error}")]
162 InvalidBranchExpression {
163 node: String,
165 case: String,
167 error: String,
169 },
170
171 #[error(
175 "branch node `{node}`: case `{case}` is a model decision but the branch declares no `agent_hash`"
176 )]
177 ModelDecisionWithoutAgent {
178 node: String,
180 case: String,
182 },
183
184 #[error(
190 "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"
191 )]
192 BranchCaseWithoutEdge {
193 node: String,
195 case: String,
197 },
198
199 #[error(
206 "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"
207 )]
208 BranchEdgeWithoutCase {
209 node: String,
211 label: String,
213 },
214
215 #[error(
226 "branch node `{node}`: outbound edge to `{to}` has no label; label it with one of the branch's cases"
227 )]
228 BranchEdgeWithoutLabel {
229 node: String,
231 to: String,
233 },
234
235 #[error("fold node `{node}`: `stop_when` is not a valid condition expression: {error}")]
239 InvalidFoldStopExpression {
240 node: String,
242 error: String,
244 },
245
246 #[error(
250 "fold node `{node}`: the `best_by` join reference `{reference}` is not a valid path: {error}"
251 )]
252 InvalidFoldJoinReference {
253 node: String,
255 reference: String,
257 error: String,
259 },
260
261 #[error(
266 "fold node `{node}`: `stop_when` reads `{path}`, which body node `{body}`'s declared output schema does not describe"
267 )]
268 FoldStopPathNotInBodySchema {
269 node: String,
271 path: String,
273 body: String,
275 },
276
277 #[error(
282 "fold node `{node}`: the `best_by` join reference `{reference}` is not described by body node `{body}`'s declared output schema"
283 )]
284 FoldJoinReferenceNotInBodySchema {
285 node: String,
287 reference: String,
289 body: String,
291 },
292
293 #[error("node `{id}`: `name` is {len} characters, over the {max}-character cap")]
295 NodeNameTooLong {
296 id: String,
298 len: usize,
300 max: usize,
302 },
303
304 #[error("node `{id}`: `name`, if set, must not be empty or all whitespace")]
306 BlankNodeName {
307 id: String,
309 },
310}
311
312fn suggest(suggestion: &Option<String>) -> String {
314 match suggestion {
315 Some(name) => format!(" (did you mean `{name}`?)"),
316 None => String::new(),
317 }
318}
319
320#[derive(Clone, Debug, PartialEq, Eq)]
325pub struct GraphSummary {
326 pub node_count: usize,
328 pub edge_count: usize,
330 pub entry_nodes: Vec<String>,
332 pub terminal_nodes: Vec<String>,
334}
335
336pub fn validate(graph: &Graph) -> Result<GraphSummary, Vec<GraphError>> {
347 let mut errors = Vec::new();
348
349 check_schema_version(graph, &mut errors);
350 check_unique_node_ids(graph, &mut errors);
351 check_referential_integrity(graph, &mut errors);
352 check_node_fields(graph, &mut errors);
353 check_node_names(graph, &mut errors);
354 check_branch_expressions(graph, &mut errors);
355 check_branch_case_edges(graph, &mut errors);
356 check_branch_edge_labels(graph, &mut errors);
357 check_fold_expressions(graph, &mut errors);
358 check_fold_reference_shapes(graph, &mut errors);
359 check_acyclic(graph, &mut errors);
360 check_edge_type_compat(graph, &mut errors);
361
362 if errors.is_empty() {
363 Ok(summarize(graph))
364 } else {
365 Err(errors)
366 }
367}
368
369fn check_schema_version(graph: &Graph, errors: &mut Vec<GraphError>) {
373 if graph.schema_version == 0 || graph.schema_version > SCHEMA_VERSION {
374 errors.push(GraphError::UnsupportedSchemaVersion {
375 found: graph.schema_version,
376 supported: SCHEMA_VERSION,
377 });
378 }
379}
380
381fn check_unique_node_ids(graph: &Graph, errors: &mut Vec<GraphError>) {
383 let mut seen = HashSet::new();
384 for node in &graph.nodes {
385 if !seen.insert(node.id()) {
386 errors.push(GraphError::DuplicateNodeId {
387 id: node.id().to_owned(),
388 });
389 }
390 }
391}
392
393fn check_referential_integrity(graph: &Graph, errors: &mut Vec<GraphError>) {
398 let ids: BTreeSet<&str> = graph.nodes.iter().map(Node::id).collect();
399
400 for edge in &graph.edges {
401 if !ids.contains(edge.from.as_str()) {
402 errors.push(GraphError::DanglingEdge {
403 from: edge.from.clone(),
404 to: edge.to.clone(),
405 missing: edge.from.clone(),
406 suggestion: nearest(&edge.from, &ids),
407 });
408 }
409 if !ids.contains(edge.to.as_str()) {
410 errors.push(GraphError::DanglingEdge {
411 from: edge.from.clone(),
412 to: edge.to.clone(),
413 missing: edge.to.clone(),
414 suggestion: nearest(&edge.to, &ids),
415 });
416 }
417 }
418
419 for node in &graph.nodes {
420 if let Node::Map(map) = node
421 && let MapBody::Node(target) = &map.body
422 && !ids.contains(target.as_str())
423 {
424 errors.push(GraphError::DanglingMapBody {
425 id: map.id.clone(),
426 missing: target.clone(),
427 suggestion: nearest(target, &ids),
428 });
429 }
430 if let Node::Fold(fold) = node
431 && let FoldBody::Node(target) = &fold.body
432 && !ids.contains(target.as_str())
433 {
434 errors.push(GraphError::DanglingFoldBody {
435 id: fold.id.clone(),
436 missing: target.clone(),
437 suggestion: nearest(target, &ids),
438 });
439 }
440 }
441}
442
443fn check_node_fields(graph: &Graph, errors: &mut Vec<GraphError>) {
448 for node in &graph.nodes {
449 match node {
450 Node::Agent(agent) => {
451 if !is_well_formed_agent_hash(&agent.agent_hash) {
452 errors.push(GraphError::MalformedAgentHash {
453 id: agent.id.clone(),
454 hash: agent.agent_hash.clone(),
455 });
456 }
457 }
458 Node::Map(map) => {
459 if map.concurrency < 1 {
460 errors.push(GraphError::NonPositiveConcurrency {
461 id: map.id.clone(),
462 found: map.concurrency,
463 });
464 }
465 }
466 Node::Gate(gate) => {
467 if !gate.approval_schema.is_object() {
468 errors.push(GraphError::ApprovalSchemaNotObject {
469 id: gate.id.clone(),
470 });
471 }
472 }
473 Node::Fold(fold) => {
474 if fold.max_iterations < 1 {
475 errors.push(GraphError::NonPositiveMaxIterations {
476 id: fold.id.clone(),
477 found: fold.max_iterations,
478 });
479 }
480 }
481 Node::Delay(delay) => {
482 if delay.seconds < 1 {
483 errors.push(GraphError::NonPositiveDelay {
484 id: delay.id.clone(),
485 found: delay.seconds,
486 });
487 }
488 }
489 Node::Tool(_) | Node::Branch(_) => {}
491 }
492 }
493}
494
495fn check_node_names(graph: &Graph, errors: &mut Vec<GraphError>) {
501 for node in &graph.nodes {
502 let Some(name) = node.name() else {
503 continue;
504 };
505 if name.trim().is_empty() {
506 errors.push(GraphError::BlankNodeName {
507 id: node.id().to_owned(),
508 });
509 continue;
510 }
511 let len = name.chars().count();
512 if len > MAX_NODE_NAME_LEN {
513 errors.push(GraphError::NodeNameTooLong {
514 id: node.id().to_owned(),
515 len,
516 max: MAX_NODE_NAME_LEN,
517 });
518 }
519 }
520}
521
522fn check_branch_expressions(graph: &Graph, errors: &mut Vec<GraphError>) {
538 for node in &graph.nodes {
539 let Node::Branch(branch) = node else {
540 continue;
541 };
542 if let Some(hash) = &branch.agent_hash
543 && !is_well_formed_agent_hash(hash)
544 {
545 errors.push(GraphError::MalformedAgentHash {
546 id: branch.id.clone(),
547 hash: hash.clone(),
548 });
549 }
550 for case in &branch.cases {
551 match &case.when {
552 BranchCondition::Expression(source) => {
553 if let Err(error) = expr::parse(source) {
554 errors.push(GraphError::InvalidBranchExpression {
555 node: branch.id.clone(),
556 case: case.name.clone(),
557 error: error.to_string(),
558 });
559 }
560 }
561 BranchCondition::ModelDecision => {
562 if branch.agent_hash.is_none() {
563 errors.push(GraphError::ModelDecisionWithoutAgent {
564 node: branch.id.clone(),
565 case: case.name.clone(),
566 });
567 }
568 }
569 }
570 }
571 }
572}
573
574fn check_branch_case_edges(graph: &Graph, errors: &mut Vec<GraphError>) {
593 for node in &graph.nodes {
594 let Node::Branch(branch) = node else {
595 continue;
596 };
597 let labels: HashSet<&str> = graph
598 .edges
599 .iter()
600 .filter(|edge| edge.from == branch.id)
601 .filter_map(|edge| edge.label.as_deref())
602 .collect();
603 for case in &branch.cases {
604 if !labels.contains(case.name.as_str()) {
605 errors.push(GraphError::BranchCaseWithoutEdge {
606 node: branch.id.clone(),
607 case: case.name.clone(),
608 });
609 }
610 }
611 }
612}
613
614fn check_branch_edge_labels(graph: &Graph, errors: &mut Vec<GraphError>) {
641 for node in &graph.nodes {
642 let Node::Branch(branch) = node else {
643 continue;
644 };
645 let case_names: HashSet<&str> =
646 branch.cases.iter().map(|case| case.name.as_str()).collect();
647 for edge in &graph.edges {
648 if edge.from != branch.id {
649 continue;
650 }
651 match &edge.label {
652 Some(label) if !case_names.contains(label.as_str()) => {
653 errors.push(GraphError::BranchEdgeWithoutCase {
654 node: branch.id.clone(),
655 label: label.clone(),
656 });
657 }
658 None => {
659 errors.push(GraphError::BranchEdgeWithoutLabel {
660 node: branch.id.clone(),
661 to: edge.to.clone(),
662 });
663 }
664 Some(_) => {}
665 }
666 }
667 }
668}
669
670fn check_fold_expressions(graph: &Graph, errors: &mut Vec<GraphError>) {
683 for node in &graph.nodes {
684 let Node::Fold(fold) = node else {
685 continue;
686 };
687 if let Err(error) = expr::parse(&fold.stop_when) {
688 errors.push(GraphError::InvalidFoldStopExpression {
689 node: fold.id.clone(),
690 error: error.to_string(),
691 });
692 }
693 if let FoldJoin::BestBy(reference) = &fold.join
694 && let Err(error) = expr::parse_reference(reference)
695 {
696 errors.push(GraphError::InvalidFoldJoinReference {
697 node: fold.id.clone(),
698 reference: reference.clone(),
699 error: error.to_string(),
700 });
701 }
702 }
703}
704
705fn check_fold_reference_shapes(graph: &Graph, errors: &mut Vec<GraphError>) {
737 let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
738
739 for node in &graph.nodes {
740 let Node::Fold(fold) = node else {
741 continue;
742 };
743 let FoldBody::Node(body_id) = &fold.body else {
744 continue;
745 };
746 let Some(schema) = by_id
747 .get(body_id.as_str())
748 .and_then(|body| body.output_schema())
749 else {
750 continue;
751 };
752
753 if let Ok(predicate) = expr::parse(&fold.stop_when) {
754 for path in predicate.paths() {
755 if !schema_describes(schema, path) {
756 errors.push(GraphError::FoldStopPathNotInBodySchema {
757 node: fold.id.clone(),
758 path: render_path(path),
759 body: body_id.clone(),
760 });
761 }
762 }
763 }
764
765 if let FoldJoin::BestBy(reference) = &fold.join
766 && let Ok(parsed) = expr::parse_reference(reference)
767 && !schema_describes(schema, parsed.segments())
768 {
769 errors.push(GraphError::FoldJoinReferenceNotInBodySchema {
770 node: fold.id.clone(),
771 reference: reference.clone(),
772 body: body_id.clone(),
773 });
774 }
775 }
776}
777
778fn schema_describes(schema: &Value, path: &[expr::Segment]) -> bool {
782 let mut here = schema;
783 for segment in path {
784 match step_into(here, segment) {
785 Step::Into(next) => here = next,
786 Step::Unjudged => return true,
787 Step::Absent => return false,
788 }
789 }
790 true
791}
792
793enum Step<'a> {
795 Into(&'a Value),
797 Unjudged,
799 Absent,
801}
802
803fn step_into<'a>(schema: &'a Value, segment: &expr::Segment) -> Step<'a> {
806 let Some(object) = schema.as_object() else {
807 return Step::Unjudged;
808 };
809 if ["$ref", "anyOf", "oneOf", "allOf", "not"]
811 .iter()
812 .any(|keyword| object.contains_key(*keyword))
813 {
814 return Step::Unjudged;
815 }
816
817 match segment {
818 expr::Segment::Key(key) => {
819 if !admits_type(object, "object") {
820 return Step::Unjudged;
821 }
822 let Some(properties) = object.get("properties").and_then(Value::as_object) else {
823 return Step::Unjudged;
824 };
825 if let Some(property) = properties.get(key) {
826 return Step::Into(property);
827 }
828 if admits_extra_keys(object) {
829 Step::Unjudged
830 } else {
831 Step::Absent
832 }
833 }
834 expr::Segment::Index(index) => {
835 if !admits_type(object, "array") {
836 return Step::Unjudged;
837 }
838 match object.get("items") {
839 Some(items) if items.is_object() => Step::Into(items),
840 Some(Value::Array(entries)) => {
843 entries.get(*index).map_or(Step::Unjudged, Step::Into)
844 }
845 _ => Step::Unjudged,
846 }
847 }
848 }
849}
850
851fn admits_type(object: &serde_json::Map<String, Value>, wanted: &str) -> bool {
854 match object.get("type") {
855 None => true,
856 Some(Value::String(declared)) => declared == wanted,
857 Some(Value::Array(declared)) => declared.iter().any(|one| one.as_str() == Some(wanted)),
858 Some(_) => true,
860 }
861}
862
863fn admits_extra_keys(object: &serde_json::Map<String, Value>) -> bool {
869 if object.contains_key("patternProperties") {
870 return true;
871 }
872 match object.get("additionalProperties") {
873 None | Some(Value::Bool(false)) => false,
874 Some(_) => true,
875 }
876}
877
878fn render_path(path: &[expr::Segment]) -> String {
880 path.iter()
881 .map(|segment| match segment {
882 expr::Segment::Key(key) => key.clone(),
883 expr::Segment::Index(index) => index.to_string(),
884 })
885 .collect::<Vec<_>>()
886 .join(".")
887}
888
889fn is_well_formed_agent_hash(hash: &str) -> bool {
891 let Some(hex) = hash.strip_prefix("sha256:") else {
892 return false;
893 };
894 hex.len() == 64
895 && hex
896 .bytes()
897 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
898}
899
900fn check_acyclic(graph: &Graph, errors: &mut Vec<GraphError>) {
907 let ids: HashSet<&str> = graph.nodes.iter().map(Node::id).collect();
910 let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
911 for edge in &graph.edges {
912 if ids.contains(edge.from.as_str()) && ids.contains(edge.to.as_str()) {
913 adjacency
914 .entry(edge.from.as_str())
915 .or_default()
916 .push(edge.to.as_str());
917 }
918 }
919
920 #[derive(Clone, Copy, PartialEq)]
921 enum Color {
922 White,
923 Gray,
924 Black,
925 }
926 let mut color: HashMap<&str, Color> = ids.iter().map(|id| (*id, Color::White)).collect();
927 let mut stack: Vec<&str> = Vec::new();
928
929 for start in graph.nodes.iter().map(Node::id) {
933 if color[start] != Color::White {
934 continue;
935 }
936 let mut frames: Vec<(&str, usize)> = vec![(start, 0)];
937 color.insert(start, Color::Gray);
938 stack.push(start);
939
940 while let Some(&mut (node, ref mut next)) = frames.last_mut() {
941 let neighbors = adjacency.get(node).map_or(&[][..], Vec::as_slice);
942 if *next < neighbors.len() {
943 let neighbor = neighbors[*next];
944 *next += 1;
945 match color[neighbor] {
946 Color::White => {
947 color.insert(neighbor, Color::Gray);
948 stack.push(neighbor);
949 frames.push((neighbor, 0));
950 }
951 Color::Gray => {
952 let start_at = stack.iter().position(|n| *n == neighbor).unwrap_or(0);
956 let mut path: Vec<&str> = stack[start_at..].to_vec();
957 path.push(neighbor);
958 errors.push(GraphError::Cycle {
959 path: path.join(" -> "),
960 });
961 return;
962 }
963 Color::Black => {}
964 }
965 } else {
966 color.insert(node, Color::Black);
967 stack.pop();
968 frames.pop();
969 }
970 }
971 }
972}
973
974fn check_edge_type_compat(graph: &Graph, errors: &mut Vec<GraphError>) {
991 let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
992
993 for edge in &graph.edges {
994 let (Some(from), Some(to)) = (by_id.get(edge.from.as_str()), by_id.get(edge.to.as_str()))
995 else {
996 continue;
998 };
999 if let (Some(out), Some(inp)) = (from.output_schema(), to.input_schema())
1000 && out != inp
1001 {
1002 errors.push(GraphError::EdgeTypeMismatch {
1003 from: edge.from.clone(),
1004 to: edge.to.clone(),
1005 });
1006 }
1007 }
1008}
1009
1010fn summarize(graph: &Graph) -> GraphSummary {
1013 let has_inbound: HashSet<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
1014 let has_outbound: HashSet<&str> = graph.edges.iter().map(|e| e.from.as_str()).collect();
1015
1016 let mut entry_nodes: Vec<String> = graph
1017 .nodes
1018 .iter()
1019 .map(Node::id)
1020 .filter(|id| !has_inbound.contains(id))
1021 .map(str::to_owned)
1022 .collect();
1023 let mut terminal_nodes: Vec<String> = graph
1024 .nodes
1025 .iter()
1026 .map(Node::id)
1027 .filter(|id| !has_outbound.contains(id))
1028 .map(str::to_owned)
1029 .collect();
1030 entry_nodes.sort();
1031 terminal_nodes.sort();
1032
1033 GraphSummary {
1034 node_count: graph.nodes.len(),
1035 edge_count: graph.edges.len(),
1036 entry_nodes,
1037 terminal_nodes,
1038 }
1039}
1040
1041fn nearest(missing: &str, ids: &BTreeSet<&str>) -> Option<String> {
1045 let mut best: Option<(usize, &str)> = None;
1046 for candidate in ids {
1047 let distance = levenshtein(missing, candidate);
1048 if best.is_none_or(|(d, _)| distance < d) {
1049 best = Some((distance, candidate));
1050 }
1051 }
1052 best.and_then(|(distance, candidate)| {
1053 let threshold = (missing.len().max(candidate.len()) / 3).max(1);
1054 (distance <= threshold).then(|| candidate.to_owned())
1055 })
1056}
1057
1058fn levenshtein(a: &str, b: &str) -> usize {
1061 let a = a.as_bytes();
1062 let b = b.as_bytes();
1063 let mut previous: Vec<usize> = (0..=b.len()).collect();
1064 let mut current = vec![0usize; b.len() + 1];
1065 for (i, &ac) in a.iter().enumerate() {
1066 current[0] = i + 1;
1067 for (j, &bc) in b.iter().enumerate() {
1068 let cost = usize::from(ac != bc);
1069 current[j + 1] = (previous[j + 1] + 1)
1070 .min(current[j] + 1)
1071 .min(previous[j] + cost);
1072 }
1073 std::mem::swap(&mut previous, &mut current);
1074 }
1075 previous[b.len()]
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081 use crate::document::{
1082 AgentNode, BranchCase, BranchCondition, BranchNode, DelayNode, Edge, FoldBody, FoldJoin,
1083 FoldNode, GateNode, MapBody, MapNode, ToolNode,
1084 };
1085 use serde_json::json;
1086 use std::collections::BTreeMap;
1087
1088 fn hash() -> String {
1089 format!("sha256:{}", "a".repeat(64))
1090 }
1091
1092 fn agent(id: &str) -> Node {
1093 Node::Agent(AgentNode {
1094 name: None,
1095 id: id.into(),
1096 agent_hash: hash(),
1097 input_schema: None,
1098 output_schema: None,
1099 })
1100 }
1101
1102 fn gate(id: &str) -> Node {
1103 Node::Gate(GateNode {
1104 name: None,
1105 id: id.into(),
1106 prompt: None,
1107 approval_schema: json!({"type": "object"}),
1108 })
1109 }
1110
1111 fn edge(from: &str, to: &str) -> Edge {
1112 Edge {
1113 from: from.into(),
1114 to: to.into(),
1115 label: None,
1116 }
1117 }
1118
1119 fn labeled_edge(from: &str, to: &str, label: &str) -> Edge {
1122 Edge {
1123 from: from.into(),
1124 to: to.into(),
1125 label: Some(label.into()),
1126 }
1127 }
1128
1129 fn graph(nodes: Vec<Node>, edges: Vec<Edge>) -> Graph {
1130 Graph {
1131 schema_version: SCHEMA_VERSION,
1132 nodes,
1133 edges,
1134 }
1135 }
1136
1137 #[test]
1140 fn valid_linear_graph_summarizes() {
1141 let g = graph(
1142 vec![agent("research"), agent("review"), gate("approve")],
1143 vec![edge("research", "review"), edge("review", "approve")],
1144 );
1145 let summary = validate(&g).expect("valid");
1146 assert_eq!(summary.node_count, 3);
1147 assert_eq!(summary.edge_count, 2);
1148 assert_eq!(summary.entry_nodes, vec!["research"]);
1149 assert_eq!(summary.terminal_nodes, vec!["approve"]);
1150 }
1151
1152 #[test]
1155 fn dangling_edge_is_reported_with_suggestion() {
1156 let g = graph(vec![agent("research")], vec![edge("research", "reviewx")]);
1157 let errors = validate(&g).expect_err("invalid");
1158 assert!(
1159 errors.contains(&GraphError::DanglingEdge {
1160 from: "research".into(),
1161 to: "reviewx".into(),
1162 missing: "reviewx".into(),
1163 suggestion: Some("research".into()),
1164 }) || matches!(
1165 errors.first(),
1166 Some(GraphError::DanglingEdge { missing, .. }) if missing == "reviewx"
1167 )
1168 );
1169 let message = errors[0].to_string();
1170 assert!(
1171 message.contains("reviewx"),
1172 "names the missing id: {message}"
1173 );
1174 }
1175
1176 #[test]
1178 fn malformed_agent_hash_is_reported() {
1179 let g = graph(
1180 vec![Node::Agent(AgentNode {
1181 name: None,
1182 id: "research".into(),
1183 agent_hash: "sha256:not-hex".into(),
1184 input_schema: None,
1185 output_schema: None,
1186 })],
1187 vec![],
1188 );
1189 let errors = validate(&g).expect_err("invalid");
1190 assert_eq!(
1191 errors,
1192 vec![GraphError::MalformedAgentHash {
1193 id: "research".into(),
1194 hash: "sha256:not-hex".into(),
1195 }]
1196 );
1197 }
1198
1199 #[test]
1201 fn non_positive_concurrency_is_reported() {
1202 let g = graph(
1203 vec![
1204 agent("worker"),
1205 Node::Map(MapNode {
1206 name: None,
1207 id: "fanout".into(),
1208 over: "items".into(),
1209 concurrency: 0,
1210 body: MapBody::Node("worker".into()),
1211 output_schema: None,
1212 }),
1213 ],
1214 vec![],
1215 );
1216 let errors = validate(&g).expect_err("invalid");
1217 assert!(errors.contains(&GraphError::NonPositiveConcurrency {
1218 id: "fanout".into(),
1219 found: 0,
1220 }));
1221 }
1222
1223 #[test]
1227 fn non_positive_delay_is_reported() {
1228 let g = graph(
1229 vec![Node::Delay(DelayNode {
1230 id: "cooloff".into(),
1231 name: None,
1232 seconds: 0,
1233 })],
1234 vec![],
1235 );
1236 let errors = validate(&g).expect_err("invalid");
1237 assert!(errors.contains(&GraphError::NonPositiveDelay {
1238 id: "cooloff".into(),
1239 found: 0,
1240 }));
1241
1242 let g = graph(
1243 vec![Node::Delay(DelayNode {
1244 id: "cooloff".into(),
1245 name: None,
1246 seconds: 1,
1247 })],
1248 vec![],
1249 );
1250 validate(&g).expect("a one-second wait is a legal wait");
1251 }
1252
1253 #[test]
1255 fn dangling_map_body_is_reported() {
1256 let g = graph(
1257 vec![Node::Map(MapNode {
1258 name: None,
1259 id: "fanout".into(),
1260 over: "items".into(),
1261 concurrency: 2,
1262 body: MapBody::Node("ghost".into()),
1263 output_schema: None,
1264 })],
1265 vec![],
1266 );
1267 let errors = validate(&g).expect_err("invalid");
1268 assert!(errors.contains(&GraphError::DanglingMapBody {
1269 id: "fanout".into(),
1270 missing: "ghost".into(),
1271 suggestion: None,
1272 }));
1273 }
1274
1275 #[test]
1277 fn cycle_is_reported_with_path() {
1278 let g = graph(
1279 vec![agent("a"), agent("b"), agent("c")],
1280 vec![edge("a", "b"), edge("b", "c"), edge("c", "a")],
1281 );
1282 let errors = validate(&g).expect_err("invalid");
1283 let cycle = errors
1284 .iter()
1285 .find_map(|e| match e {
1286 GraphError::Cycle { path } => Some(path.clone()),
1287 _ => None,
1288 })
1289 .expect("a cycle error");
1290 assert!(cycle.starts_with("a -> "), "path from a: {cycle}");
1291 assert!(cycle.ends_with("-> a"), "path closes on a: {cycle}");
1292 }
1293
1294 #[test]
1297 fn edge_type_mismatch_is_reported() {
1298 let producer = Node::Agent(AgentNode {
1299 name: None,
1300 id: "producer".into(),
1301 agent_hash: hash(),
1302 input_schema: None,
1303 output_schema: Some(json!({"type": "string"})),
1304 });
1305 let consumer = Node::Tool(ToolNode {
1306 name: None,
1307 id: "consumer".into(),
1308 tool: "t".into(),
1309 input: BTreeMap::new(),
1310 input_schema: Some(json!({"type": "number"})),
1311 output_schema: None,
1312 });
1313 let g = graph(vec![producer, consumer], vec![edge("producer", "consumer")]);
1314 let errors = validate(&g).expect_err("invalid");
1315 assert!(errors.contains(&GraphError::EdgeTypeMismatch {
1316 from: "producer".into(),
1317 to: "consumer".into(),
1318 }));
1319 }
1320
1321 #[test]
1323 fn matching_edge_schemas_pass() {
1324 let producer = Node::Agent(AgentNode {
1325 name: None,
1326 id: "producer".into(),
1327 agent_hash: hash(),
1328 input_schema: None,
1329 output_schema: Some(json!({"type": "string"})),
1330 });
1331 let consumer = Node::Tool(ToolNode {
1332 name: None,
1333 id: "consumer".into(),
1334 tool: "t".into(),
1335 input: BTreeMap::new(),
1336 input_schema: Some(json!({"type": "string"})),
1337 output_schema: None,
1338 });
1339 let g = graph(vec![producer, consumer], vec![edge("producer", "consumer")]);
1340 assert!(validate(&g).is_ok());
1341 }
1342
1343 #[test]
1345 fn future_schema_version_is_rejected() {
1346 let mut g = graph(vec![agent("a")], vec![]);
1347 g.schema_version = SCHEMA_VERSION + 1;
1348 let errors = validate(&g).expect_err("invalid");
1349 assert!(errors.contains(&GraphError::UnsupportedSchemaVersion {
1350 found: SCHEMA_VERSION + 1,
1351 supported: SCHEMA_VERSION,
1352 }));
1353 }
1354
1355 #[test]
1358 fn all_errors_are_collected() {
1359 let g = graph(
1360 vec![
1361 Node::Agent(AgentNode {
1362 name: None,
1363 id: "bad".into(),
1364 agent_hash: "nope".into(),
1365 input_schema: None,
1366 output_schema: None,
1367 }),
1368 agent("bad"), ],
1370 vec![edge("bad", "missing")],
1371 );
1372 let errors = validate(&g).expect_err("invalid");
1373 assert!(
1374 errors.len() >= 3,
1375 "duplicate id, malformed hash, and dangling edge: {errors:?}"
1376 );
1377 }
1378
1379 #[test]
1381 fn duplicate_node_id_is_reported() {
1382 let g = graph(vec![agent("dup"), gate("dup")], vec![]);
1383 let errors = validate(&g).expect_err("invalid");
1384 assert!(errors.contains(&GraphError::DuplicateNodeId { id: "dup".into() }));
1385 }
1386
1387 #[test]
1390 fn valid_branch_expression_passes() {
1391 let branch = Node::Branch(BranchNode {
1392 name: None,
1393 id: "route".into(),
1394 on: Some("score".into()),
1395 agent_hash: Some(hash()),
1396 cases: vec![
1397 BranchCase {
1398 name: "high".into(),
1399 when: BranchCondition::Expression("score > 0.8".into()),
1400 },
1401 BranchCase {
1402 name: "review".into(),
1403 when: BranchCondition::ModelDecision,
1404 },
1405 ],
1406 });
1407 let g = graph(
1408 vec![
1409 agent("score"),
1410 branch,
1411 agent("high_target"),
1412 agent("review_target"),
1413 ],
1414 vec![
1415 edge("score", "route"),
1416 labeled_edge("route", "high_target", "high"),
1417 labeled_edge("route", "review_target", "review"),
1418 ],
1419 );
1420 assert!(validate(&g).is_ok(), "{:?}", validate(&g));
1421 }
1422
1423 #[test]
1428 fn invalid_branch_expression_is_reported() {
1429 let branch = Node::Branch(BranchNode {
1430 name: None,
1431 id: "route".into(),
1432 on: None,
1433 agent_hash: Some(hash()),
1434 cases: vec![
1435 BranchCase {
1436 name: "broken".into(),
1437 when: BranchCondition::Expression("score >".into()),
1438 },
1439 BranchCase {
1440 name: "fallback".into(),
1441 when: BranchCondition::ModelDecision,
1442 },
1443 ],
1444 });
1445 let g = graph(
1446 vec![branch, agent("broken_target"), agent("fallback_target")],
1447 vec![
1448 labeled_edge("route", "broken_target", "broken"),
1449 labeled_edge("route", "fallback_target", "fallback"),
1450 ],
1451 );
1452 let errors = validate(&g).expect_err("invalid");
1453 assert!(
1454 matches!(
1455 errors.as_slice(),
1456 [GraphError::InvalidBranchExpression { node, case, .. }]
1457 if node == "route" && case == "broken"
1458 ),
1459 "one node/case-precise expression error: {errors:?}"
1460 );
1461 }
1462
1463 #[test]
1472 fn branch_case_without_edge_is_reported() {
1473 let branch = Node::Branch(BranchNode {
1474 name: None,
1475 id: "route".into(),
1476 on: None,
1477 agent_hash: None,
1478 cases: vec![
1479 BranchCase {
1480 name: "won".into(),
1481 when: BranchCondition::Expression("outcome == \"won\"".into()),
1482 },
1483 BranchCase {
1484 name: "lost".into(),
1485 when: BranchCondition::Expression("outcome == \"lost\"".into()),
1486 },
1487 ],
1488 });
1489 let g = graph(
1490 vec![branch, agent("celebrate")],
1491 vec![
1494 labeled_edge("route", "celebrate", "won"),
1495 labeled_edge("route", "celebrate", "lst"),
1496 ],
1497 );
1498 let errors = validate(&g).expect_err("invalid");
1499 assert_eq!(
1500 errors,
1501 vec![
1502 GraphError::BranchCaseWithoutEdge {
1503 node: "route".into(),
1504 case: "lost".into(),
1505 },
1506 GraphError::BranchEdgeWithoutCase {
1507 node: "route".into(),
1508 label: "lst".into(),
1509 },
1510 ],
1511 "names the unrouted case AND the mislabeled edge that caused it, nothing else: {errors:?}"
1512 );
1513 let message = errors[0].to_string();
1514 assert!(
1515 message.contains("route") && message.contains("lost"),
1516 "{message}"
1517 );
1518 assert!(
1519 message.contains("terminal node"),
1520 "says what to do about a route meant to end the run: {message}"
1521 );
1522 }
1523
1524 #[test]
1530 fn branch_edge_without_case_is_reported() {
1531 let branch = Node::Branch(BranchNode {
1532 name: None,
1533 id: "route".into(),
1534 on: None,
1535 agent_hash: None,
1536 cases: vec![
1537 BranchCase {
1538 name: "lost".into(),
1539 when: BranchCondition::Expression("outcome == \"lost\"".into()),
1540 },
1541 BranchCase {
1542 name: "paid".into(),
1543 when: BranchCondition::Expression("outcome == \"paid\"".into()),
1544 },
1545 ],
1546 });
1547 let g = graph(
1548 vec![branch, agent("celebrate"), agent("close")],
1549 vec![
1552 labeled_edge("route", "close", "lst"),
1553 labeled_edge("route", "celebrate", "paid"),
1554 ],
1555 );
1556 let errors = validate(&g).expect_err("invalid");
1557 assert!(
1558 errors.contains(&GraphError::BranchEdgeWithoutCase {
1559 node: "route".into(),
1560 label: "lst".into(),
1561 }),
1562 "names the node and the offending label: {errors:?}"
1563 );
1564 assert!(
1568 errors.contains(&GraphError::BranchCaseWithoutEdge {
1569 node: "route".into(),
1570 case: "lost".into(),
1571 }),
1572 "the case left unrouted by the typo is also named: {errors:?}"
1573 );
1574 let message = errors
1575 .iter()
1576 .find_map(|e| match e {
1577 GraphError::BranchEdgeWithoutCase { .. } => Some(e.to_string()),
1578 _ => None,
1579 })
1580 .expect("a BranchEdgeWithoutCase error");
1581 assert!(
1582 message.contains("route") && message.contains("lst"),
1583 "{message}"
1584 );
1585 assert!(
1586 message.contains("case"),
1587 "says what to do about the mismatched label: {message}"
1588 );
1589 }
1590
1591 #[test]
1597 fn branch_edge_without_label_is_reported() {
1598 let branch = Node::Branch(BranchNode {
1599 name: None,
1600 id: "route".into(),
1601 on: None,
1602 agent_hash: None,
1603 cases: vec![
1604 BranchCase {
1605 name: "paid".into(),
1606 when: BranchCondition::Expression("outcome == \"paid\"".into()),
1607 },
1608 BranchCase {
1609 name: "lost".into(),
1610 when: BranchCondition::Expression("outcome == \"lost\"".into()),
1611 },
1612 ],
1613 });
1614 let g = graph(
1615 vec![branch, agent("celebrate"), agent("close")],
1616 vec![
1617 labeled_edge("route", "celebrate", "paid"),
1618 edge("route", "close"),
1619 ],
1620 );
1621 let errors = validate(&g).expect_err("invalid");
1622 assert!(
1623 errors.contains(&GraphError::BranchEdgeWithoutLabel {
1624 node: "route".into(),
1625 to: "close".into(),
1626 }),
1627 "names the branch node and the unlabelled edge's target: {errors:?}"
1628 );
1629 let message = errors
1630 .iter()
1631 .find_map(|e| match e {
1632 GraphError::BranchEdgeWithoutLabel { .. } => Some(e.to_string()),
1633 _ => None,
1634 })
1635 .expect("a BranchEdgeWithoutLabel error");
1636 assert!(
1637 message.contains("route") && message.contains("close"),
1638 "{message}"
1639 );
1640 assert!(
1641 message.contains("label"),
1642 "says what to do about the unlabelled edge: {message}"
1643 );
1644 }
1645
1646 #[test]
1650 fn model_decision_without_agent_is_reported() {
1651 let branch = Node::Branch(BranchNode {
1652 name: None,
1653 id: "route".into(),
1654 on: None,
1655 agent_hash: None,
1656 cases: vec![BranchCase {
1657 name: "ask".into(),
1658 when: BranchCondition::ModelDecision,
1659 }],
1660 });
1661 let g = graph(vec![branch], vec![]);
1662 let errors = validate(&g).expect_err("invalid");
1663 assert!(
1664 errors.contains(&GraphError::ModelDecisionWithoutAgent {
1665 node: "route".into(),
1666 case: "ask".into(),
1667 }),
1668 "names the node and case: {errors:?}"
1669 );
1670 }
1671
1672 #[test]
1675 fn malformed_branch_agent_hash_is_reported() {
1676 let branch = Node::Branch(BranchNode {
1677 name: None,
1678 id: "route".into(),
1679 on: None,
1680 agent_hash: Some("sha256:not-hex".into()),
1681 cases: vec![BranchCase {
1682 name: "ask".into(),
1683 when: BranchCondition::ModelDecision,
1684 }],
1685 });
1686 let g = graph(vec![branch], vec![]);
1687 let errors = validate(&g).expect_err("invalid");
1688 assert!(
1689 errors.contains(&GraphError::MalformedAgentHash {
1690 id: "route".into(),
1691 hash: "sha256:not-hex".into(),
1692 }),
1693 "names the branch node and its malformed hash: {errors:?}"
1694 );
1695 }
1696
1697 fn fold(id: &str, body: &str, max_iterations: u32, stop_when: &str, join: FoldJoin) -> Node {
1700 Node::Fold(FoldNode {
1701 id: id.into(),
1702 name: None,
1703 body: FoldBody::Node(body.into()),
1704 max_iterations,
1705 stop_when: stop_when.into(),
1706 join,
1707 on_bound: None,
1708 accumulator_schema: None,
1709 })
1710 }
1711
1712 #[test]
1715 fn valid_fold_node_passes() {
1716 let g = graph(
1717 vec![
1718 agent("tailor"),
1719 fold(
1720 "refine",
1721 "tailor",
1722 3,
1723 "score >= 0.85",
1724 FoldJoin::BestBy("score".into()),
1725 ),
1726 ],
1727 vec![],
1728 );
1729 assert!(validate(&g).is_ok(), "{:?}", validate(&g));
1730 }
1731
1732 #[test]
1735 fn fold_with_unit_joins_passes() {
1736 for join in [FoldJoin::Last, FoldJoin::All] {
1737 let g = graph(
1738 vec![agent("tailor"), fold("refine", "tailor", 2, "done", join)],
1739 vec![],
1740 );
1741 assert!(validate(&g).is_ok());
1742 }
1743 }
1744
1745 #[test]
1747 fn non_positive_max_iterations_is_reported() {
1748 let g = graph(
1749 vec![
1750 agent("tailor"),
1751 fold("refine", "tailor", 0, "done", FoldJoin::Last),
1752 ],
1753 vec![],
1754 );
1755 let errors = validate(&g).expect_err("invalid");
1756 assert!(errors.contains(&GraphError::NonPositiveMaxIterations {
1757 id: "refine".into(),
1758 found: 0,
1759 }));
1760 }
1761
1762 #[test]
1765 fn dangling_fold_body_is_reported() {
1766 let g = graph(
1767 vec![fold("refine", "ghost", 2, "done", FoldJoin::Last)],
1768 vec![],
1769 );
1770 let errors = validate(&g).expect_err("invalid");
1771 assert!(errors.contains(&GraphError::DanglingFoldBody {
1772 id: "refine".into(),
1773 missing: "ghost".into(),
1774 suggestion: None,
1775 }));
1776 }
1777
1778 #[test]
1780 fn invalid_fold_stop_expression_is_reported() {
1781 let g = graph(
1782 vec![
1783 agent("tailor"),
1784 fold("refine", "tailor", 2, "score >", FoldJoin::Last),
1785 ],
1786 vec![],
1787 );
1788 let errors = validate(&g).expect_err("invalid");
1789 assert!(
1790 matches!(
1791 errors.as_slice(),
1792 [GraphError::InvalidFoldStopExpression { node, .. }] if node == "refine"
1793 ),
1794 "one node-precise stop-expression error: {errors:?}"
1795 );
1796 }
1797
1798 #[test]
1801 fn invalid_fold_join_reference_is_reported() {
1802 let g = graph(
1803 vec![
1804 agent("tailor"),
1805 fold("refine", "tailor", 2, "done", FoldJoin::BestBy("42".into())),
1806 ],
1807 vec![],
1808 );
1809 let errors = validate(&g).expect_err("invalid");
1810 assert!(
1811 errors.iter().any(
1812 |e| matches!(e, GraphError::InvalidFoldJoinReference { node, reference, .. }
1813 if node == "refine" && reference == "42")
1814 ),
1815 "names the node and the bad reference: {errors:?}"
1816 );
1817 }
1818
1819 fn scorer(id: &str, output_schema: Value) -> Node {
1823 Node::Agent(AgentNode {
1824 name: None,
1825 id: id.into(),
1826 agent_hash: hash(),
1827 input_schema: None,
1828 output_schema: Some(output_schema),
1829 })
1830 }
1831
1832 fn score_schema() -> Value {
1834 json!({
1835 "type": "object",
1836 "properties": { "score": { "type": "number" } },
1837 "required": ["score"]
1838 })
1839 }
1840
1841 #[test]
1844 fn fold_references_inside_the_body_schema_pass() {
1845 let schema = json!({
1846 "type": "object",
1847 "properties": {
1848 "score": { "type": "number" },
1849 "review": {
1850 "type": "object",
1851 "properties": {
1852 "overall_score": { "type": "number" },
1853 "notes": {
1854 "type": "array",
1855 "items": { "type": "object", "properties": { "text": { "type": "string" } } }
1856 }
1857 }
1858 }
1859 }
1860 });
1861 let g = graph(
1862 vec![
1863 scorer("tailor", schema),
1864 fold(
1865 "refine",
1866 "tailor",
1867 3,
1868 "score >= 0.85 && review.notes.0.text != \"\"",
1869 FoldJoin::BestBy("review.overall_score".into()),
1870 ),
1871 ],
1872 vec![],
1873 );
1874 assert!(validate(&g).is_ok(), "{:?}", validate(&g));
1875 }
1876
1877 #[test]
1881 fn fold_stop_path_outside_the_body_schema_is_reported() {
1882 let g = graph(
1883 vec![
1884 scorer("tailor", score_schema()),
1885 fold(
1886 "refine",
1887 "tailor",
1888 3,
1889 "scoer >= 0.85",
1890 FoldJoin::BestBy("score".into()),
1891 ),
1892 ],
1893 vec![],
1894 );
1895 let errors = validate(&g).expect_err("invalid");
1896 assert_eq!(
1897 errors,
1898 vec![GraphError::FoldStopPathNotInBodySchema {
1899 node: "refine".into(),
1900 path: "scoer".into(),
1901 body: "tailor".into(),
1902 }]
1903 );
1904 let message = errors[0].to_string();
1905 assert!(
1906 message.contains("scoer") && message.contains("tailor"),
1907 "names the path and the body node: {message}"
1908 );
1909 }
1910
1911 #[test]
1915 fn fold_nested_stop_path_outside_the_body_schema_is_reported() {
1916 let schema = json!({
1917 "type": "object",
1918 "properties": {
1919 "review": { "type": "object", "properties": { "score": { "type": "number" } } }
1920 }
1921 });
1922 let g = graph(
1923 vec![
1924 scorer("tailor", schema),
1925 fold("refine", "tailor", 3, "review.rating > 3", FoldJoin::Last),
1926 ],
1927 vec![],
1928 );
1929 let errors = validate(&g).expect_err("invalid");
1930 assert!(
1931 errors.contains(&GraphError::FoldStopPathNotInBodySchema {
1932 node: "refine".into(),
1933 path: "review.rating".into(),
1934 body: "tailor".into(),
1935 }),
1936 "{errors:?}"
1937 );
1938 }
1939
1940 #[test]
1943 fn fold_join_reference_outside_the_body_schema_is_reported() {
1944 let g = graph(
1945 vec![
1946 scorer("tailor", score_schema()),
1947 fold(
1948 "refine",
1949 "tailor",
1950 3,
1951 "score >= 0.85",
1952 FoldJoin::BestBy("review.overall_score".into()),
1953 ),
1954 ],
1955 vec![],
1956 );
1957 let errors = validate(&g).expect_err("invalid");
1958 assert_eq!(
1959 errors,
1960 vec![GraphError::FoldJoinReferenceNotInBodySchema {
1961 node: "refine".into(),
1962 reference: "review.overall_score".into(),
1963 body: "tailor".into(),
1964 }]
1965 );
1966 }
1967
1968 #[test]
1971 fn every_fold_reference_fault_is_collected() {
1972 let g = graph(
1973 vec![
1974 scorer("tailor", score_schema()),
1975 fold(
1976 "refine",
1977 "tailor",
1978 3,
1979 "scoer >= 0.85 || rating > 3",
1980 FoldJoin::BestBy("overall".into()),
1981 ),
1982 ],
1983 vec![],
1984 );
1985 let errors = validate(&g).expect_err("invalid");
1986 assert_eq!(errors.len(), 3, "{errors:?}");
1987 }
1988
1989 #[test]
1994 fn fold_references_go_unjudged_where_the_schema_says_nothing() {
1995 let quiet: Vec<Option<Value>> = vec![
1996 None,
1997 Some(json!({ "type": "object" })),
1998 Some(json!({ "type": "string" })),
1999 Some(json!({
2000 "type": "object",
2001 "properties": { "score": { "type": "number" } },
2002 "additionalProperties": true
2003 })),
2004 Some(json!({
2005 "type": "object",
2006 "properties": { "score": { "type": "number" } },
2007 "patternProperties": { "^x_": { "type": "string" } }
2008 })),
2009 Some(json!({ "$ref": "#/$defs/pass" })),
2010 Some(json!({
2011 "anyOf": [{ "type": "object", "properties": { "score": { "type": "number" } } }]
2012 })),
2013 ];
2014 for schema in quiet {
2015 let body = match schema {
2016 Some(schema) => scorer("tailor", schema),
2017 None => agent("tailor"),
2018 };
2019 let g = graph(
2020 vec![
2021 body,
2022 fold(
2023 "refine",
2024 "tailor",
2025 3,
2026 "anything.at.all >= 0.85",
2027 FoldJoin::BestBy("nothing.declared".into()),
2028 ),
2029 ],
2030 vec![],
2031 );
2032 assert!(validate(&g).is_ok(), "{:?}", validate(&g));
2033 }
2034
2035 let subgraph = Node::Fold(FoldNode {
2036 name: None,
2037 id: "refine".into(),
2038 body: FoldBody::Subgraph(Box::new(graph(
2039 vec![scorer("tailor", score_schema())],
2040 vec![],
2041 ))),
2042 max_iterations: 3,
2043 stop_when: "anything.at.all >= 0.85".into(),
2044 join: FoldJoin::BestBy("nothing.declared".into()),
2045 on_bound: None,
2046 accumulator_schema: None,
2047 });
2048 assert!(validate(&graph(vec![subgraph], vec![])).is_ok());
2049 }
2050
2051 #[test]
2055 fn a_closed_body_schema_reports_the_same_missing_path() {
2056 let schema = json!({
2057 "type": "object",
2058 "properties": { "score": { "type": "number" } },
2059 "additionalProperties": false
2060 });
2061 let g = graph(
2062 vec![
2063 scorer("tailor", schema),
2064 fold("refine", "tailor", 3, "scoer >= 0.85", FoldJoin::Last),
2065 ],
2066 vec![],
2067 );
2068 let errors = validate(&g).expect_err("invalid");
2069 assert!(errors.contains(&GraphError::FoldStopPathNotInBodySchema {
2070 node: "refine".into(),
2071 path: "scoer".into(),
2072 body: "tailor".into(),
2073 }));
2074 }
2075
2076 #[test]
2079 fn an_unparseable_stop_predicate_is_not_also_a_shape_error() {
2080 let g = graph(
2081 vec![
2082 scorer("tailor", score_schema()),
2083 fold("refine", "tailor", 3, "score >", FoldJoin::Last),
2084 ],
2085 vec![],
2086 );
2087 let errors = validate(&g).expect_err("invalid");
2088 assert!(
2089 matches!(
2090 errors.as_slice(),
2091 [GraphError::InvalidFoldStopExpression { node, .. }] if node == "refine"
2092 ),
2093 "only the parse error: {errors:?}"
2094 );
2095 }
2096
2097 #[test]
2100 fn node_name_at_the_cap_is_valid() {
2101 let mut named = agent("research");
2102 if let Node::Agent(a) = &mut named {
2103 a.name = Some("a".repeat(MAX_NODE_NAME_LEN));
2104 }
2105 let g = graph(
2106 vec![named, agent("review")],
2107 vec![edge("research", "review")],
2108 );
2109 assert!(validate(&g).is_ok());
2110 }
2111
2112 #[test]
2116 fn node_name_too_long_is_reported() {
2117 let mut named = agent("research");
2118 let long_name = "é".repeat(MAX_NODE_NAME_LEN + 1);
2119 if let Node::Agent(a) = &mut named {
2120 a.name = Some(long_name.clone());
2121 }
2122 let g = graph(vec![named], vec![]);
2123 let errors = validate(&g).expect_err("invalid");
2124 assert!(
2125 errors.contains(&GraphError::NodeNameTooLong {
2126 id: "research".into(),
2127 len: MAX_NODE_NAME_LEN + 1,
2128 max: MAX_NODE_NAME_LEN,
2129 }),
2130 "names the node and the character count, not the byte count: {errors:?}"
2131 );
2132 }
2133
2134 #[test]
2137 fn blank_node_name_is_reported() {
2138 for blank in ["", " ", "\t\n"] {
2139 let mut named = gate("approve");
2140 if let Node::Gate(g) = &mut named {
2141 g.name = Some(blank.to_owned());
2142 }
2143 let g = graph(vec![named], vec![]);
2144 let errors = validate(&g).expect_err("invalid");
2145 assert!(
2146 errors.contains(&GraphError::BlankNodeName {
2147 id: "approve".into(),
2148 }),
2149 "blank name {blank:?} should be reported: {errors:?}"
2150 );
2151 }
2152 }
2153
2154 #[test]
2157 fn multiple_node_name_errors_are_all_collected() {
2158 let mut blank = agent("research");
2159 if let Node::Agent(a) = &mut blank {
2160 a.name = Some(" ".into());
2161 }
2162 let mut long = gate("approve");
2163 if let Node::Gate(g) = &mut long {
2164 g.name = Some("x".repeat(MAX_NODE_NAME_LEN + 5));
2165 }
2166 let g = graph(vec![blank, long], vec![]);
2167 let errors = validate(&g).expect_err("invalid");
2168 assert!(
2169 errors.contains(&GraphError::BlankNodeName {
2170 id: "research".into(),
2171 }),
2172 "{errors:?}"
2173 );
2174 assert!(
2175 errors.contains(&GraphError::NodeNameTooLong {
2176 id: "approve".into(),
2177 len: MAX_NODE_NAME_LEN + 5,
2178 max: MAX_NODE_NAME_LEN,
2179 }),
2180 "{errors:?}"
2181 );
2182 }
2183}