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