Skip to main content

salvor_graph/
validate.rs

1//! Strict, versioned validation of a graph document.
2//!
3//! [`validate`] runs a set of INDEPENDENT checks and collects EVERY error
4//! rather than stopping at the first, so an author sees the whole picture in one
5//! pass. Each check is its own function that reads the graph and pushes any
6//! failures onto a shared list, so a check can be added, relaxed, or removed
7//! without touching the others. The acyclic check in particular is isolated on
8//! purpose: the current design leans acyclic, and a future change that admits
9//! some cycles can drop that one function and leave the rest untouched.
10//!
11//! The errors are structured ([`GraphError`]): each names the offending node or
12//! edge and carries a clear message, so the CLI can print node/edge-level
13//! diagnostics rather than a bare "invalid".
14
15use std::collections::{BTreeSet, HashMap, HashSet};
16
17use crate::document::{BranchCondition, FoldBody, FoldJoin, Graph, MapBody, Node, SCHEMA_VERSION};
18use crate::expr;
19
20/// The longest an optional node `name` may be, in characters. Mirrors the
21/// agent definition's own name bound
22/// (`salvor_cli::agent_config::MAX_NAME_LEN`); see [`crate::document`]'s "The
23/// optional node display name" section for why the two fields, though bounded
24/// alike, differ in whether they hash.
25pub const MAX_NODE_NAME_LEN: usize = 64;
26
27/// A single validation failure, naming the node or edge at fault.
28///
29/// `PartialEq` is derived so tests can assert on the exact error value. Each
30/// variant's `Display` (via `thiserror`) is the human message the CLI prints.
31#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
32pub enum GraphError {
33    /// The document declares a `schema_version` this build cannot understand
34    /// (greater than [`SCHEMA_VERSION`], or zero).
35    #[error("unsupported schema_version {found}: this build understands versions 1..={supported}")]
36    UnsupportedSchemaVersion {
37        /// The version the document declared.
38        found: u32,
39        /// The newest version this build understands.
40        supported: u32,
41    },
42
43    /// Two nodes share an id. Node ids must be unique within a document.
44    #[error("duplicate node id `{id}`")]
45    DuplicateNodeId {
46        /// The repeated id.
47        id: String,
48    },
49
50    /// An edge names a node id that does not exist.
51    #[error("edge `{from}` -> `{to}` references unknown node id `{missing}`{}", suggest(.suggestion))]
52    DanglingEdge {
53        /// The edge's declared source.
54        from: String,
55        /// The edge's declared destination.
56        to: String,
57        /// The endpoint id that does not exist (either `from` or `to`).
58        missing: String,
59        /// The nearest existing id, when one is close enough to suggest.
60        suggestion: Option<String>,
61    },
62
63    /// A `map` node's `node` body references a node id that does not exist.
64    #[error("map node `{id}` maps unknown node id `{missing}`{}", suggest(.suggestion))]
65    DanglingMapBody {
66        /// The map node's id.
67        id: String,
68        /// The referenced id that does not exist.
69        missing: String,
70        /// The nearest existing id, when one is close enough to suggest.
71        suggestion: Option<String>,
72    },
73
74    /// A `fold` node's `node` body references a node id that does not exist.
75    #[error("fold node `{id}` folds unknown node id `{missing}`{}", suggest(.suggestion))]
76    DanglingFoldBody {
77        /// The fold node's id.
78        id: String,
79        /// The referenced id that does not exist.
80        missing: String,
81        /// The nearest existing id, when one is close enough to suggest.
82        suggestion: Option<String>,
83    },
84
85    /// An `agent` node's hash is not a well-formed `sha256:<64 lowercase hex>`
86    /// string.
87    #[error("agent node `{id}`: `{hash}` is not a well-formed `sha256:<64 hex>` agent hash")]
88    MalformedAgentHash {
89        /// The agent node's id.
90        id: String,
91        /// The malformed hash string.
92        hash: String,
93    },
94
95    /// A `map` node's concurrency cap is not positive.
96    #[error("map node `{id}`: concurrency cap must be at least 1, found {found}")]
97    NonPositiveConcurrency {
98        /// The map node's id.
99        id: String,
100        /// The declared cap.
101        found: u32,
102    },
103
104    /// A `fold` node's iteration bound is not positive.
105    #[error("fold node `{id}`: max_iterations must be at least 1, found {found}")]
106    NonPositiveMaxIterations {
107        /// The fold node's id.
108        id: String,
109        /// The declared bound.
110        found: u32,
111    },
112
113    /// A `gate` node's approval schema is not a JSON object.
114    #[error("gate node `{id}`: approval_schema must be a JSON object")]
115    ApprovalSchemaNotObject {
116        /// The gate node's id.
117        id: String,
118    },
119
120    /// The edge list contains a cycle. `path` renders it as `a -> b -> ... -> a`.
121    #[error("cycle detected: {path}")]
122    Cycle {
123        /// The cycle rendered as a node-id path, closing back on its start.
124        path: String,
125    },
126
127    /// An edge connects two nodes whose declared schemas do not match. See
128    /// [`check_edge_type_compat`] for the deliberately conservative rule.
129    #[error(
130        "edge `{from}` -> `{to}`: the output schema of `{from}` does not match the input schema of `{to}`"
131    )]
132    EdgeTypeMismatch {
133        /// The source node id (its output schema).
134        from: String,
135        /// The destination node id (its input schema).
136        to: String,
137    },
138
139    /// A `branch` node case carries an expression condition that does not parse
140    /// in the [`crate::expr`] condition language. Caught at submit so a bad
141    /// expression is never a run-time failure.
142    #[error("branch node `{node}`: case `{case}` has an invalid condition expression: {error}")]
143    InvalidBranchExpression {
144        /// The branch node's id.
145        node: String,
146        /// The name of the offending case.
147        case: String,
148        /// The parse error's message.
149        error: String,
150    },
151
152    /// A `branch` node carries a `model_decision` case but declares no
153    /// `agent_hash`, so the engine would have no agent to make the decision.
154    /// Caught at submit, node- and case-precise.
155    #[error(
156        "branch node `{node}`: case `{case}` is a model decision but the branch declares no `agent_hash`"
157    )]
158    ModelDecisionWithoutAgent {
159        /// The branch node's id.
160        node: String,
161        /// The name of the model-decision case with no agent.
162        case: String,
163    },
164
165    /// A `fold` node's `stop_when` predicate does not parse in the
166    /// [`crate::expr`] condition language. Caught at submit so a bad predicate is
167    /// never a run-time failure, exactly like a branch case's expression.
168    #[error("fold node `{node}`: `stop_when` is not a valid condition expression: {error}")]
169    InvalidFoldStopExpression {
170        /// The fold node's id.
171        node: String,
172        /// The parse error's message.
173        error: String,
174    },
175
176    /// A `fold` node's `best_by` join reference is not a well-formed path in the
177    /// [`crate::expr`] language (a bare literal, or a malformed path). Caught at
178    /// submit, node-precise.
179    #[error(
180        "fold node `{node}`: the `best_by` join reference `{reference}` is not a valid path: {error}"
181    )]
182    InvalidFoldJoinReference {
183        /// The fold node's id.
184        node: String,
185        /// The malformed reference.
186        reference: String,
187        /// The parse error's message.
188        error: String,
189    },
190
191    /// A node's optional `name` is over [`MAX_NODE_NAME_LEN`] characters.
192    #[error("node `{id}`: `name` is {len} characters, over the {max}-character cap")]
193    NodeNameTooLong {
194        /// The node's id.
195        id: String,
196        /// The name's length, in characters (`chars().count()`, not bytes).
197        len: usize,
198        /// [`MAX_NODE_NAME_LEN`], repeated here so the error is self-contained.
199        max: usize,
200    },
201
202    /// A node's optional `name` is set but empty or all whitespace.
203    #[error("node `{id}`: `name`, if set, must not be empty or all whitespace")]
204    BlankNodeName {
205        /// The node's id.
206        id: String,
207    },
208}
209
210/// Formats an optional nearest-name suggestion as a trailing clause, or empty.
211fn suggest(suggestion: &Option<String>) -> String {
212    match suggestion {
213        Some(name) => format!(" (did you mean `{name}`?)"),
214        None => String::new(),
215    }
216}
217
218/// A successful validation's summary of the graph's shape.
219///
220/// Entry nodes have no inbound edge; terminal nodes have no outbound edge. Both
221/// lists are sorted, so the CLI output is deterministic.
222#[derive(Clone, Debug, PartialEq, Eq)]
223pub struct GraphSummary {
224    /// Number of nodes in the document.
225    pub node_count: usize,
226    /// Number of edges in the document.
227    pub edge_count: usize,
228    /// Ids of nodes with no inbound edge, sorted.
229    pub entry_nodes: Vec<String>,
230    /// Ids of nodes with no outbound edge, sorted.
231    pub terminal_nodes: Vec<String>,
232}
233
234/// Validates a graph document, returning a summary on success or EVERY error on
235/// failure.
236///
237/// The checks are independent and all run: the returned `Vec` holds a failure
238/// from each check that found one, so an author fixes everything at once. The
239/// order of checks below is the order errors appear in.
240///
241/// # Errors
242///
243/// Returns the collected [`GraphError`]s when any check fails.
244pub 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
264/// Rejects a `schema_version` from the future (or zero). This is the strict-in
265/// half of the version discipline; the additive-out half is that an
266/// older-or-equal version is accepted unchanged. See [`SCHEMA_VERSION`].
267fn 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
276/// Rejects a document where two nodes share an id.
277fn 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
288/// Every id an edge endpoint or a `map` body names must be a real node id.
289///
290/// When a named id is missing, the nearest existing id (by edit distance) is
291/// suggested if it is close enough to be a plausible typo.
292fn 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
338/// Per-node required-field checks: an agent hash is well-formed, a map cap is
339/// positive, a gate's approval schema is an object. Each rule is a small,
340/// independent block so a rule can be relaxed on its own.
341fn 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            // Tool and branch carry no field rule beyond the strict parse.
376            Node::Tool(_) | Node::Branch(_) => {}
377        }
378    }
379}
380
381/// A node's optional `name`, when set, must not be empty or all whitespace,
382/// and must be at most [`MAX_NODE_NAME_LEN`] characters
383/// (`chars().count()`, not bytes). Applies uniformly across all six node
384/// kinds through [`Node::name`], mirroring the agent definition's own name
385/// rule.
386fn 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
408/// Every `branch` case is checked for the rule its condition kind implies.
409///
410/// This is where the opaque case conditions earn their meaning, AT SUBMIT, so a
411/// malformed branch is a node-precise error the author sees now, never a
412/// run-time failure inside a durable, replayed run:
413///
414/// - an `expression` condition must parse in the [`crate::expr`] condition
415///   language;
416/// - a `model_decision` condition requires the branch to declare an
417///   `agent_hash`, because the engine drives that agent to make the decision;
418/// - a declared `agent_hash` must be a well-formed `sha256:<64 hex>` string,
419///   exactly like an agent node's hash.
420///
421/// Each fault is one collected error naming the node (and, for a case fault, the
422/// case).
423fn 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
460/// Every `fold` node's expression fields are checked AT SUBMIT, exactly like a
461/// branch's, so a malformed predicate or join reference is a node-precise error
462/// now rather than a run-time failure inside a durable, replayed run:
463///
464/// - the `stop_when` predicate must parse in the [`crate::expr`] condition
465///   language (the same one a branch case's expression uses);
466/// - a [`FoldJoin::BestBy`] reference must parse as an [`crate::expr`] path (a
467///   location in the accumulated value, never a bare literal).
468///
469/// The `last` and `all` join rules carry no expression to check. The iteration
470/// bound is checked in [`check_node_fields`], the body reference in
471/// [`check_referential_integrity`], keeping each rule independent.
472fn 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
495/// An agent hash is `sha256:` followed by exactly 64 lowercase hex digits.
496fn 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
506/// Reports the first cycle found in the edge topology as a node-id path.
507///
508/// A depth-first walk colors nodes white (unseen), gray (on the current stack),
509/// or black (finished). Reaching a gray node closes a cycle, which is rebuilt
510/// from the current stack. This is the single isolated check that encodes the
511/// acyclic lean; a later change that admits cycles removes only this function.
512fn check_acyclic(graph: &Graph, errors: &mut Vec<GraphError>) {
513    // Adjacency by node id. Edges to unknown ids are skipped: referential
514    // integrity already reports those, and skipping keeps this walk in-bounds.
515    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    // An explicit work stack instead of recursion, so a deep graph cannot blow
536    // the call stack. Each frame is a node and the index of the next neighbor
537    // to visit.
538    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                        // A back edge: the neighbor is on the current stack, so
559                        // the path from it to here, closed by this edge, is a
560                        // cycle.
561                        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
580/// Where both endpoints of an edge declare a schema, they must match.
581///
582/// # The rule, and its deliberate limitation
583///
584/// The check is exact structural equality of the two declared JSON Schema
585/// documents (`source.output_schema == target.input_schema`, a deep value
586/// comparison). Where either endpoint omits its schema, the edge passes
587/// unchecked.
588///
589/// This deliberately does NOT implement JSON Schema subtyping. Two schemas that
590/// are compatible but not identical (a subset/superset relationship, the same
591/// shape spelled differently, an added optional property) are reported as a
592/// mismatch, and the fix is to make the declared schemas equal. The trade is
593/// intentional: exact equality is pure, cheap, and easy to reason about, and it
594/// never claims a compatibility it cannot verify. A later change can relax
595/// equality to real schema compatibility by changing only this function.
596fn 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            // A dangling edge; referential integrity already reported it.
603            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
616/// Builds the success summary: node and edge counts, plus entry (no inbound)
617/// and terminal (no outbound) node ids, sorted.
618fn 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
647/// The nearest existing id to a missing one, if close enough to be a plausible
648/// typo. Cheap: Levenshtein distance, suggested only when the distance is at
649/// most a third of the longer id's length (and always the single closest).
650fn 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
664/// Classic Levenshtein edit distance over bytes, two-row rolling table. Ids are
665/// short, so this stays trivially cheap.
666fn 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    /// A linear research -> review -> gate flow validates clean, with the right
734    /// counts and entry/terminal nodes.
735    #[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    /// A dangling edge names the offending edge and the missing id, and
749    /// suggests the near miss.
750    #[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    /// A malformed agent hash names the node.
773    #[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    /// A zero concurrency cap on a map names the node.
796    #[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    /// A map body that names a missing node is reported.
820    #[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    /// A cycle is reported with a path that closes on itself.
842    #[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    /// Edge type-compat: matching schemas pass, mismatched ones fail naming the
861    /// edge.
862    #[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    /// Identical declared schemas are compatible.
888    #[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    /// A future schema version is rejected; an equal one is accepted.
910    #[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    /// Every check runs: a document with several independent faults returns all
922    /// of them, not just the first.
923    #[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"), // duplicate id
935            ],
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    /// A duplicate node id is reported.
946    #[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    /// A branch node whose expression condition is well-formed validates clean;
954    /// a `model_decision` case carries no expression to check.
955    #[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    /// A branch case whose expression does not parse is a node-precise error
978    /// naming the node and the case; a sibling `model_decision` case is skipped.
979    #[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    /// A `model_decision` case on a branch that declares no `agent_hash` is a
1010    /// node/case-precise error: the engine would have no agent to make the
1011    /// decision.
1012    #[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    /// A branch that declares an `agent_hash` must spell it `sha256:<64 hex>`,
1036    /// exactly like an agent node's hash.
1037    #[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    /// Builds a fold node over an existing body node, with the given bound,
1061    /// stop predicate, and join, for the fold validator tests.
1062    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    /// A well-formed fold (positive bound, existing body, parseable predicate,
1075    /// valid `best_by` path) validates clean.
1076    #[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    /// The `last` and `all` joins carry no reference to check, so a fold using
1095    /// them validates without a `best_by` path.
1096    #[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    /// A zero iteration bound on a fold names the node.
1108    #[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    /// A fold body that names a missing node is reported, distinct from a map
1125    /// body.
1126    #[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    /// A fold whose `stop_when` does not parse is a node-precise error.
1141    #[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    /// A `best_by` join whose reference is a bare literal (not a path) is a
1161    /// node-precise error naming the reference.
1162    #[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    /// A node `name` at exactly the character cap is valid; a node with no
1182    /// `name` set is unaffected by the check.
1183    #[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    /// A node `name` over the character cap is a node-precise error, counting
1197    /// characters rather than bytes (a multi-byte character over the cap is
1198    /// still one character over, not several).
1199    #[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    /// An empty or all-whitespace `name` is rejected, node-precise, across
1219    /// every node kind.
1220    #[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    /// Every check runs together: a document with both a blank name on one
1239    /// node and an oversized name on another reports both, collect-all style.
1240    #[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}