Skip to main content

salvor_graph/
builder.rs

1//! A fluent, typed builder for a graph [`Graph`] document.
2//!
3//! The document model in [`crate::document`] is the wire format: strict,
4//! adjacently tagged, and easy to get wrong by hand (a stray key, a payload
5//! nested under the wrong tag, an edge that names a field instead of a node).
6//! This builder is the author-facing front door. It never invents a new format;
7//! it constructs the same [`Graph`] the model already defines, so whatever this
8//! builder emits parses and validates exactly as a hand-written document would.
9//!
10//! # What the types buy you
11//!
12//! Each node kind has its own spec type ([`AgentSpec`], [`ToolSpec`],
13//! [`GateSpec`], [`BranchSpec`], [`MapSpec`], [`FoldSpec`]) whose constructor demands the
14//! fields that kind cannot do without: an agent needs its hash, a tool needs its
15//! name, a gate needs its approval schema. A field that belongs to one kind is
16//! not reachable on another, so "a gate with an agent_hash" is not a runtime
17//! error, it is a shape you cannot write. The optional fields are chained
18//! methods, present only where the model allows them. The result is that a
19//! STRUCTURALLY malformed document is hard to express.
20//!
21//! # Where the builder stops
22//!
23//! Typed construction stops at structure. It does NOT check that an agent hash
24//! is 64 hex digits, that a map's concurrency is positive, that edges name real
25//! nodes, or that the graph is acyclic. Those are SEMANTIC rules, and they stay
26//! with [`crate::validate`], which runs over the built `Graph` the same way it
27//! runs over a parsed one. Build to get a well-shaped document; validate to
28//! learn whether it is a legal one.
29//!
30//! # Authoring the canonical flow
31//!
32//! ```
33//! use salvor_graph::{AgentSpec, GateSpec, GraphBuilder, ToolSpec};
34//! use serde_json::json;
35//!
36//! let draft = json!({
37//!     "type": "object",
38//!     "properties": { "draft": { "type": "string" } },
39//!     "required": ["draft"]
40//! });
41//!
42//! let graph = GraphBuilder::new()
43//!     .agent(
44//!         AgentSpec::new("research", format!("sha256:{}", "1".repeat(64)))
45//!             .output_schema(draft.clone()),
46//!     )
47//!     .agent(
48//!         AgentSpec::new("review", format!("sha256:{}", "2".repeat(64)))
49//!             .input_schema(draft.clone())
50//!             .output_schema(draft),
51//!     )
52//!     .gate(
53//!         GateSpec::new(
54//!             "approve",
55//!             json!({
56//!                 "type": "object",
57//!                 "properties": { "approved": { "type": "boolean" } },
58//!                 "required": ["approved"]
59//!             }),
60//!         )
61//!         .prompt("Approve this draft for publication?"),
62//!     )
63//!     .tool(
64//!         ToolSpec::new("publish", "http_post")
65//!             .input("body", "approve.draft")
66//!             .input("url", "config.publish_url"),
67//!     )
68//!     .edge("research", "review")
69//!     .edge("review", "approve")
70//!     .edge("approve", "publish")
71//!     .build();
72//!
73//! // Structure is done; semantics are a separate pass.
74//! let summary = salvor_graph::validate(&graph).expect("the canonical flow is valid");
75//! assert_eq!(summary.entry_nodes, ["research"]);
76//! assert_eq!(summary.terminal_nodes, ["publish"]);
77//! ```
78
79use serde_json::Value;
80
81use crate::document::{
82    AgentNode, BranchCase, BranchCondition, BranchNode, Edge, FoldBody, FoldJoin, FoldNode,
83    GateNode, Graph, MapBody, MapNode, Node, SCHEMA_VERSION, ToolNode,
84};
85
86/// Accumulates nodes and edges, then freezes them into a [`Graph`].
87///
88/// Every `node`-adding method takes a per-kind spec and returns `self`, so a
89/// whole document reads as one chain ending in [`build`](GraphBuilder::build).
90/// The builder stamps [`SCHEMA_VERSION`] onto the document, so an author never
91/// writes the version by hand.
92#[derive(Clone, Debug, Default)]
93pub struct GraphBuilder {
94    nodes: Vec<Node>,
95    edges: Vec<Edge>,
96}
97
98impl GraphBuilder {
99    /// Starts an empty builder.
100    #[must_use]
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Adds an `agent` node from its [`AgentSpec`].
106    #[must_use]
107    pub fn agent(mut self, spec: AgentSpec) -> Self {
108        self.nodes.push(spec.into_node());
109        self
110    }
111
112    /// Adds a `tool` node from its [`ToolSpec`].
113    #[must_use]
114    pub fn tool(mut self, spec: ToolSpec) -> Self {
115        self.nodes.push(spec.into_node());
116        self
117    }
118
119    /// Adds a `gate` node from its [`GateSpec`].
120    #[must_use]
121    pub fn gate(mut self, spec: GateSpec) -> Self {
122        self.nodes.push(spec.into_node());
123        self
124    }
125
126    /// Adds a `branch` node from its [`BranchSpec`].
127    #[must_use]
128    pub fn branch(mut self, spec: BranchSpec) -> Self {
129        self.nodes.push(spec.into_node());
130        self
131    }
132
133    /// Adds a `map` node from its [`MapSpec`].
134    #[must_use]
135    pub fn map(mut self, spec: MapSpec) -> Self {
136        self.nodes.push(spec.into_node());
137        self
138    }
139
140    /// Adds a `fold` node from its [`FoldSpec`].
141    #[must_use]
142    pub fn fold(mut self, spec: FoldSpec) -> Self {
143        self.nodes.push(spec.into_node());
144        self
145    }
146
147    /// Adds a plain edge from one node id to another.
148    #[must_use]
149    pub fn edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
150        self.edges.push(Edge {
151            from: from.into(),
152            to: to.into(),
153            label: None,
154        });
155        self
156    }
157
158    /// Adds a labeled edge. The label names the [`BranchCase`] this edge
159    /// realizes when the source is a `branch`.
160    #[must_use]
161    pub fn labeled_edge(
162        mut self,
163        from: impl Into<String>,
164        to: impl Into<String>,
165        label: impl Into<String>,
166    ) -> Self {
167        self.edges.push(Edge {
168            from: from.into(),
169            to: to.into(),
170            label: Some(label.into()),
171        });
172        self
173    }
174
175    /// Freezes the accumulated nodes and edges into a [`Graph`], stamping the
176    /// current [`SCHEMA_VERSION`].
177    ///
178    /// Structure only: run [`crate::validate`] on the result to check the
179    /// semantic rules (hash shape, referential integrity, acyclicity, and the
180    /// rest).
181    #[must_use]
182    pub fn build(self) -> Graph {
183        Graph {
184            schema_version: SCHEMA_VERSION,
185            nodes: self.nodes,
186            edges: self.edges,
187        }
188    }
189}
190
191/// The spec for an `agent` node: a full agent loop referenced by content hash.
192#[derive(Clone, Debug)]
193pub struct AgentSpec {
194    id: String,
195    agent_hash: String,
196    name: Option<String>,
197    input_schema: Option<Value>,
198    output_schema: Option<Value>,
199}
200
201impl AgentSpec {
202    /// Starts an agent spec with its two required fields: the node id and the
203    /// `sha256:<64 hex>` agent hash. The hash form is checked by
204    /// [`crate::validate`], not here.
205    pub fn new(id: impl Into<String>, agent_hash: impl Into<String>) -> Self {
206        Self {
207            id: id.into(),
208            agent_hash: agent_hash.into(),
209            name: None,
210            input_schema: None,
211            output_schema: None,
212        }
213    }
214
215    /// Sets a short display label for this node. Bounds (a 64-character cap,
216    /// not empty or all whitespace) are checked by [`crate::validate`], not
217    /// here; see [`crate::document`]'s "The optional node display name"
218    /// section for why this field, unlike an agent's own `name`, is part of
219    /// the graph's content hash.
220    #[must_use]
221    pub fn name(mut self, name: impl Into<String>) -> Self {
222        self.name = Some(name.into());
223        self
224    }
225
226    /// Declares the JSON Schema for the payload this agent consumes.
227    #[must_use]
228    pub fn input_schema(mut self, schema: Value) -> Self {
229        self.input_schema = Some(schema);
230        self
231    }
232
233    /// Declares the JSON Schema for the payload this agent produces.
234    #[must_use]
235    pub fn output_schema(mut self, schema: Value) -> Self {
236        self.output_schema = Some(schema);
237        self
238    }
239
240    fn into_node(self) -> Node {
241        Node::Agent(AgentNode {
242            id: self.id,
243            agent_hash: self.agent_hash,
244            name: self.name,
245            input_schema: self.input_schema,
246            output_schema: self.output_schema,
247        })
248    }
249}
250
251/// The spec for a `tool` node: one direct tool invocation.
252#[derive(Clone, Debug)]
253pub struct ToolSpec {
254    id: String,
255    tool: String,
256    name: Option<String>,
257    input: std::collections::BTreeMap<String, String>,
258    input_schema: Option<Value>,
259    output_schema: Option<Value>,
260}
261
262impl ToolSpec {
263    /// Starts a tool spec with its required fields: the node id and the
264    /// registered tool name.
265    pub fn new(id: impl Into<String>, tool: impl Into<String>) -> Self {
266        Self {
267            id: id.into(),
268            tool: tool.into(),
269            name: None,
270            input: std::collections::BTreeMap::new(),
271            input_schema: None,
272            output_schema: None,
273        }
274    }
275
276    /// Sets a short display label for this node. Bounds (a 64-character cap,
277    /// not empty or all whitespace) are checked by [`crate::validate`], not
278    /// here; see [`crate::document`]'s "The optional node display name"
279    /// section for why this field, unlike an agent's own `name`, is part of
280    /// the graph's content hash.
281    #[must_use]
282    pub fn name(mut self, name: impl Into<String>) -> Self {
283        self.name = Some(name.into());
284        self
285    }
286
287    /// Adds one input mapping: a tool input field name to an opaque source
288    /// reference. Recorded as data; not resolved by this crate.
289    #[must_use]
290    pub fn input(mut self, field: impl Into<String>, source: impl Into<String>) -> Self {
291        self.input.insert(field.into(), source.into());
292        self
293    }
294
295    /// Declares the JSON Schema for the payload this tool consumes.
296    #[must_use]
297    pub fn input_schema(mut self, schema: Value) -> Self {
298        self.input_schema = Some(schema);
299        self
300    }
301
302    /// Declares the JSON Schema for the payload this tool produces.
303    #[must_use]
304    pub fn output_schema(mut self, schema: Value) -> Self {
305        self.output_schema = Some(schema);
306        self
307    }
308
309    fn into_node(self) -> Node {
310        Node::Tool(ToolNode {
311            id: self.id,
312            tool: self.tool,
313            name: self.name,
314            input: self.input,
315            input_schema: self.input_schema,
316            output_schema: self.output_schema,
317        })
318    }
319}
320
321/// The spec for a `gate` node: human approval that suspends the run.
322#[derive(Clone, Debug)]
323pub struct GateSpec {
324    id: String,
325    name: Option<String>,
326    prompt: Option<String>,
327    approval_schema: Value,
328}
329
330impl GateSpec {
331    /// Starts a gate spec with its required fields: the node id and the JSON
332    /// Schema the approval input must satisfy. A gate with no declared approval
333    /// shape is meaningless, so the schema is not optional.
334    pub fn new(id: impl Into<String>, approval_schema: Value) -> Self {
335        Self {
336            id: id.into(),
337            name: None,
338            prompt: None,
339            approval_schema,
340        }
341    }
342
343    /// Sets a short display label for this node. Bounds (a 64-character cap,
344    /// not empty or all whitespace) are checked by [`crate::validate`], not
345    /// here; see [`crate::document`]'s "The optional node display name"
346    /// section for why this field, unlike an agent's own `name`, is part of
347    /// the graph's content hash.
348    #[must_use]
349    pub fn name(mut self, name: impl Into<String>) -> Self {
350        self.name = Some(name.into());
351        self
352    }
353
354    /// Sets the human-readable prompt shown in the approval inbox.
355    #[must_use]
356    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
357        self.prompt = Some(prompt.into());
358        self
359    }
360
361    fn into_node(self) -> Node {
362        Node::Gate(GateNode {
363            id: self.id,
364            name: self.name,
365            prompt: self.prompt,
366            approval_schema: self.approval_schema,
367        })
368    }
369}
370
371/// The spec for a `branch` node: routes on a typed output.
372#[derive(Clone, Debug)]
373pub struct BranchSpec {
374    id: String,
375    name: Option<String>,
376    on: Option<String>,
377    agent_hash: Option<String>,
378    cases: Vec<BranchCase>,
379}
380
381impl BranchSpec {
382    /// Starts a branch spec with its required node id. Cases are added with
383    /// [`case`](BranchSpec::case).
384    pub fn new(id: impl Into<String>) -> Self {
385        Self {
386            id: id.into(),
387            name: None,
388            on: None,
389            agent_hash: None,
390            cases: Vec::new(),
391        }
392    }
393
394    /// Sets a short display label for this node. Bounds (a 64-character cap,
395    /// not empty or all whitespace) are checked by [`crate::validate`], not
396    /// here; see [`crate::document`]'s "The optional node display name"
397    /// section for why this field, unlike an agent's own `name`, is part of
398    /// the graph's content hash.
399    #[must_use]
400    pub fn name(mut self, name: impl Into<String>) -> Self {
401        self.name = Some(name.into());
402        self
403    }
404
405    /// Sets the opaque reference to the typed value the branch routes on.
406    #[must_use]
407    pub fn on(mut self, on: impl Into<String>) -> Self {
408        self.on = Some(on.into());
409        self
410    }
411
412    /// Sets the `sha256:<64 hex>` hash of the agent that decides a
413    /// [`BranchCondition::ModelDecision`] case. Required by [`crate::validate`]
414    /// on any branch that carries a model-decision case; the hash form is
415    /// checked there, not here.
416    #[must_use]
417    pub fn agent_hash(mut self, agent_hash: impl Into<String>) -> Self {
418        self.agent_hash = Some(agent_hash.into());
419        self
420    }
421
422    /// Adds a named case selected by the given condition. The route it realizes
423    /// is a [`labeled_edge`](GraphBuilder::labeled_edge) whose label matches the
424    /// case name.
425    #[must_use]
426    pub fn case(mut self, name: impl Into<String>, when: BranchCondition) -> Self {
427        self.cases.push(BranchCase {
428            name: name.into(),
429            when,
430        });
431        self
432    }
433
434    fn into_node(self) -> Node {
435        Node::Branch(BranchNode {
436            id: self.id,
437            name: self.name,
438            on: self.on,
439            agent_hash: self.agent_hash,
440            cases: self.cases,
441        })
442    }
443}
444
445/// The spec for a `map` node: fan-out a sub-run per element of a typed list,
446/// with a concurrency cap.
447#[derive(Clone, Debug)]
448pub struct MapSpec {
449    id: String,
450    name: Option<String>,
451    over: String,
452    concurrency: u32,
453    body: MapBody,
454    output_schema: Option<Value>,
455}
456
457impl MapSpec {
458    /// Starts a map spec with its required fields: the node id, the opaque
459    /// reference to the list it fans out over, the concurrency cap, and the
460    /// [`MapBody`] each element is mapped through.
461    pub fn new(
462        id: impl Into<String>,
463        over: impl Into<String>,
464        concurrency: u32,
465        body: MapBody,
466    ) -> Self {
467        Self {
468            id: id.into(),
469            name: None,
470            over: over.into(),
471            concurrency,
472            body,
473            output_schema: None,
474        }
475    }
476
477    /// Sets a short display label for this node. Bounds (a 64-character cap,
478    /// not empty or all whitespace) are checked by [`crate::validate`], not
479    /// here; see [`crate::document`]'s "The optional node display name"
480    /// section for why this field, unlike an agent's own `name`, is part of
481    /// the graph's content hash.
482    #[must_use]
483    pub fn name(mut self, name: impl Into<String>) -> Self {
484        self.name = Some(name.into());
485        self
486    }
487
488    /// Declares the JSON Schema for the joined list this node produces.
489    #[must_use]
490    pub fn output_schema(mut self, schema: Value) -> Self {
491        self.output_schema = Some(schema);
492        self
493    }
494
495    fn into_node(self) -> Node {
496        Node::Map(MapNode {
497            id: self.id,
498            name: self.name,
499            over: self.over,
500            concurrency: self.concurrency,
501            body: self.body,
502            output_schema: self.output_schema,
503        })
504    }
505}
506
507/// The spec for a `fold` node: bounded iteration that accumulates across passes.
508#[derive(Clone, Debug)]
509pub struct FoldSpec {
510    id: String,
511    name: Option<String>,
512    body: FoldBody,
513    max_iterations: u32,
514    stop_when: String,
515    join: FoldJoin,
516    accumulator_schema: Option<Value>,
517}
518
519impl FoldSpec {
520    /// Starts a fold spec with its required fields: the node id, the
521    /// [`FoldBody`] each pass runs, the iteration bound, the `stop_when`
522    /// predicate, and the [`FoldJoin`] rule. The bound's positivity, the
523    /// predicate's parse, and a `best_by` reference's shape are checked by
524    /// [`crate::validate`], not here.
525    pub fn new(
526        id: impl Into<String>,
527        body: FoldBody,
528        max_iterations: u32,
529        stop_when: impl Into<String>,
530        join: FoldJoin,
531    ) -> Self {
532        Self {
533            id: id.into(),
534            name: None,
535            body,
536            max_iterations,
537            stop_when: stop_when.into(),
538            join,
539            accumulator_schema: None,
540        }
541    }
542
543    /// Sets a short display label for this node. Bounds (a 64-character cap,
544    /// not empty or all whitespace) are checked by [`crate::validate`], not
545    /// here; see [`crate::document`]'s "The optional node display name"
546    /// section for why this field, unlike an agent's own `name`, is part of
547    /// the graph's content hash.
548    #[must_use]
549    pub fn name(mut self, name: impl Into<String>) -> Self {
550        self.name = Some(name.into());
551        self
552    }
553
554    /// Declares the JSON Schema for the accumulated value the loop carries and
555    /// produces. Data only, like an agent's `output_schema`.
556    #[must_use]
557    pub fn accumulator_schema(mut self, schema: Value) -> Self {
558        self.accumulator_schema = Some(schema);
559        self
560    }
561
562    fn into_node(self) -> Node {
563        Node::Fold(FoldNode {
564            id: self.id,
565            name: self.name,
566            body: self.body,
567            max_iterations: self.max_iterations,
568            stop_when: self.stop_when,
569            join: self.join,
570            accumulator_schema: self.accumulator_schema,
571        })
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use serde_json::json;
579
580    /// A schema shape reused by several nodes in the canonical flow.
581    fn draft_schema() -> Value {
582        json!({
583            "type": "object",
584            "properties": { "draft": { "type": "string" } },
585            "required": ["draft"]
586        })
587    }
588
589    /// Builds the exact research -> review -> approve -> publish flow the
590    /// canonical fixture records.
591    fn canonical_flow() -> Graph {
592        GraphBuilder::new()
593            .agent(
594                AgentSpec::new("research", format!("sha256:{}", "1".repeat(64)))
595                    .output_schema(draft_schema()),
596            )
597            .agent(
598                AgentSpec::new("review", format!("sha256:{}", "2".repeat(64)))
599                    .input_schema(draft_schema())
600                    .output_schema(draft_schema()),
601            )
602            .gate(
603                GateSpec::new(
604                    "approve",
605                    json!({
606                        "type": "object",
607                        "properties": { "approved": { "type": "boolean" } },
608                        "required": ["approved"]
609                    }),
610                )
611                .prompt("Approve this draft for publication?"),
612            )
613            .tool(
614                ToolSpec::new("publish", "http_post")
615                    .input("body", "approve.draft")
616                    .input("url", "config.publish_url"),
617            )
618            .edge("research", "review")
619            .edge("review", "approve")
620            .edge("approve", "publish")
621            .build()
622    }
623
624    /// The builder emits a document structurally equal to the canonical fixture
625    /// that keeps all three language builders honest.
626    #[test]
627    fn builds_the_canonical_document() {
628        let built = serde_json::to_value(canonical_flow()).expect("serialize built graph");
629        let canonical: Value = serde_json::from_str(include_str!(
630            "../../../examples/graphs/research-review-publish.json"
631        ))
632        .expect("parse canonical fixture");
633        assert_eq!(
634            built, canonical,
635            "builder output must match the canonical fixture exactly"
636        );
637    }
638
639    /// Builds the exact fold-refine flow the cross-language `fold-refine`
640    /// fixture records, so the Rust, TypeScript, and Python fold builders all
641    /// reduce to one canonical document.
642    fn fold_flow() -> Graph {
643        use crate::document::{FoldBody, FoldJoin};
644
645        let score_schema = json!({
646            "type": "object",
647            "properties": { "score": { "type": "number" } },
648            "required": ["score"]
649        });
650        GraphBuilder::new()
651            .agent(
652                AgentSpec::new("tailor", format!("sha256:{}", "3".repeat(64)))
653                    .output_schema(score_schema.clone()),
654            )
655            .fold(
656                FoldSpec::new(
657                    "refine",
658                    FoldBody::Node("tailor".into()),
659                    3,
660                    "score >= 0.85",
661                    FoldJoin::BestBy("score".into()),
662                )
663                .name("Refine to threshold")
664                .accumulator_schema(score_schema),
665            )
666            .build()
667    }
668
669    /// The builder emits a fold document structurally equal to the shared
670    /// fold-refine fixture that keeps all three language builders honest.
671    #[test]
672    fn builds_the_fold_document() {
673        let built = serde_json::to_value(fold_flow()).expect("serialize built graph");
674        let canonical: Value =
675            serde_json::from_str(include_str!("../../../examples/graphs/fold-refine.json"))
676                .expect("parse fold fixture");
677        assert_eq!(
678            built, canonical,
679            "builder output must match the fold fixture exactly"
680        );
681    }
682
683    /// Builds the exact research -> assess -> route -> {approve -> publish,
684    /// escalate} flow the shared `branch-model-decision` fixture records: a
685    /// branch carrying both an expression case and a `model_decision` case,
686    /// with the `agent_hash` the latter requires.
687    fn branch_model_decision_flow() -> Graph {
688        GraphBuilder::new()
689            .agent(AgentSpec::new(
690                "research",
691                format!("sha256:{}", "1".repeat(64)),
692            ))
693            .tool(ToolSpec::new("assess", "assess"))
694            .branch(
695                BranchSpec::new("route")
696                    .on("assess.score")
697                    .agent_hash(format!("sha256:{}", "4".repeat(64)))
698                    .case("high", BranchCondition::Expression("score >= 0.8".into()))
699                    .case("review", BranchCondition::ModelDecision),
700            )
701            .gate(
702                GateSpec::new(
703                    "approve",
704                    json!({
705                        "type": "object",
706                        "properties": { "approved": { "type": "boolean" } },
707                        "required": ["approved"]
708                    }),
709                )
710                .prompt("Approve this high-scoring draft for publication?"),
711            )
712            .tool(ToolSpec::new("publish", "http_post"))
713            .tool(ToolSpec::new("escalate", "notify"))
714            .edge("research", "assess")
715            .edge("assess", "route")
716            .labeled_edge("route", "approve", "high")
717            .edge("approve", "publish")
718            .labeled_edge("route", "escalate", "review")
719            .build()
720    }
721
722    /// The builder emits a document structurally equal to the shared
723    /// branch-model-decision fixture that keeps all three language builders
724    /// honest about the branch `agent_hash` field.
725    #[test]
726    fn builds_the_branch_model_decision_document() {
727        let built =
728            serde_json::to_value(branch_model_decision_flow()).expect("serialize built graph");
729        let canonical: Value = serde_json::from_str(include_str!(
730            "../../../examples/graphs/branch-model-decision.json"
731        ))
732        .expect("parse branch-model-decision fixture");
733        assert_eq!(
734            built, canonical,
735            "builder output must match the branch-model-decision fixture exactly"
736        );
737    }
738
739    /// The built fold flow passes semantic validation.
740    #[test]
741    fn fold_document_validates() {
742        let summary = crate::validate(&fold_flow()).expect("fold flow is valid");
743        assert_eq!(summary.node_count, 2);
744        assert_eq!(summary.edge_count, 0);
745    }
746
747    /// The built canonical flow passes semantic validation.
748    #[test]
749    fn canonical_document_validates() {
750        let summary = crate::validate(&canonical_flow()).expect("canonical flow is valid");
751        assert_eq!(summary.node_count, 4);
752        assert_eq!(summary.edge_count, 3);
753        assert_eq!(summary.entry_nodes, vec!["research"]);
754        assert_eq!(summary.terminal_nodes, vec!["publish"]);
755    }
756
757    /// The branch and map specs build the shapes the model expects: an unset
758    /// optional field stays off the wire, and a labeled edge realizes a case.
759    #[test]
760    fn branch_and_map_specs_build_expected_shapes() {
761        let graph = GraphBuilder::new()
762            .agent(AgentSpec::new(
763                "score",
764                format!("sha256:{}", "a".repeat(64)),
765            ))
766            .branch(
767                BranchSpec::new("route")
768                    .on("score.value")
769                    .case("high", BranchCondition::Expression("score > 0.8".into()))
770                    .case("ask", BranchCondition::ModelDecision),
771            )
772            .agent(AgentSpec::new(
773                "worker",
774                format!("sha256:{}", "b".repeat(64)),
775            ))
776            .map(MapSpec::new(
777                "fanout",
778                "route.items",
779                4,
780                MapBody::Node("worker".into()),
781            ))
782            .edge("score", "route")
783            .labeled_edge("route", "fanout", "high")
784            .build();
785
786        // The map node serializes with no output_schema key, because the spec
787        // never set one.
788        let value = serde_json::to_value(&graph).expect("serialize");
789        let map_payload = value["nodes"][3]["payload"].clone();
790        assert!(
791            map_payload.get("output_schema").is_none(),
792            "unset optional field must stay off the wire: {map_payload}"
793        );
794        assert_eq!(value["edges"][1]["label"], json!("high"));
795    }
796
797    /// The fold spec builds the shape the model expects: the body, bound, stop
798    /// predicate, and join land in the payload, an unset `accumulator_schema`
799    /// stays off the wire, and the document validates.
800    #[test]
801    fn fold_spec_builds_expected_shape() {
802        use crate::document::{FoldBody, FoldJoin};
803
804        let graph = GraphBuilder::new()
805            .agent(AgentSpec::new(
806                "tailor",
807                format!("sha256:{}", "a".repeat(64)),
808            ))
809            .fold(
810                FoldSpec::new(
811                    "refine",
812                    FoldBody::Node("tailor".into()),
813                    3,
814                    "score >= 0.85",
815                    FoldJoin::BestBy("score".into()),
816                )
817                .name("Refine to threshold"),
818            )
819            .build();
820
821        let summary = crate::validate(&graph).expect("the fold flow is valid");
822        assert_eq!(summary.node_count, 2);
823
824        let value = serde_json::to_value(&graph).expect("serialize");
825        let payload = &value["nodes"][1]["payload"];
826        assert_eq!(value["nodes"][1]["kind"], json!("fold"));
827        assert_eq!(payload["max_iterations"], json!(3));
828        assert_eq!(payload["stop_when"], json!("score >= 0.85"));
829        assert_eq!(
830            payload["join"],
831            json!({"kind": "best_by", "value": "score"})
832        );
833        assert_eq!(payload["body"], json!({"kind": "node", "value": "tailor"}));
834        assert_eq!(payload["name"], json!("Refine to threshold"));
835        assert!(
836            payload.get("accumulator_schema").is_none(),
837            "unset optional field must stay off the wire: {payload}"
838        );
839    }
840
841    /// `.name(...)` is available on every node kind's spec, puts the name on
842    /// the wire when set, and validates clean; a sibling node with no `.name`
843    /// call carries none, proving the two coexist in one document.
844    #[test]
845    fn every_spec_kind_accepts_a_display_name() {
846        let graph = GraphBuilder::new()
847            .agent(
848                AgentSpec::new("research", format!("sha256:{}", "1".repeat(64)))
849                    .name("Research the topic"),
850            )
851            .tool(ToolSpec::new("publish", "http_post").name("Publish the draft"))
852            .gate(GateSpec::new("approve", json!({"type": "object"})).name("Approve the draft"))
853            .branch(
854                BranchSpec::new("route")
855                    .name("Route on confidence")
856                    .case("high", BranchCondition::Expression("score > 0.8".into())),
857            )
858            .map(
859                MapSpec::new("fanout", "route.items", 2, MapBody::Node("research".into()))
860                    .name("Notify each watcher"),
861            )
862            .edge("research", "publish")
863            .build();
864
865        let summary = crate::validate(&graph).expect("named nodes still validate");
866        assert_eq!(summary.node_count, 5);
867
868        let value = serde_json::to_value(&graph).expect("serialize");
869        for (index, expected) in [
870            (0, "Research the topic"),
871            (1, "Publish the draft"),
872            (2, "Approve the draft"),
873            (3, "Route on confidence"),
874            (4, "Notify each watcher"),
875        ] {
876            assert_eq!(
877                value["nodes"][index]["payload"]["name"],
878                json!(expected),
879                "node {index} carries its display name on the wire"
880            );
881        }
882    }
883}