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 /// The built fold flow passes semantic validation.
684 #[test]
685 fn fold_document_validates() {
686 let summary = crate::validate(&fold_flow()).expect("fold flow is valid");
687 assert_eq!(summary.node_count, 2);
688 assert_eq!(summary.edge_count, 0);
689 }
690
691 /// The built canonical flow passes semantic validation.
692 #[test]
693 fn canonical_document_validates() {
694 let summary = crate::validate(&canonical_flow()).expect("canonical flow is valid");
695 assert_eq!(summary.node_count, 4);
696 assert_eq!(summary.edge_count, 3);
697 assert_eq!(summary.entry_nodes, vec!["research"]);
698 assert_eq!(summary.terminal_nodes, vec!["publish"]);
699 }
700
701 /// The branch and map specs build the shapes the model expects: an unset
702 /// optional field stays off the wire, and a labeled edge realizes a case.
703 #[test]
704 fn branch_and_map_specs_build_expected_shapes() {
705 let graph = GraphBuilder::new()
706 .agent(AgentSpec::new(
707 "score",
708 format!("sha256:{}", "a".repeat(64)),
709 ))
710 .branch(
711 BranchSpec::new("route")
712 .on("score.value")
713 .case("high", BranchCondition::Expression("score > 0.8".into()))
714 .case("ask", BranchCondition::ModelDecision),
715 )
716 .agent(AgentSpec::new(
717 "worker",
718 format!("sha256:{}", "b".repeat(64)),
719 ))
720 .map(MapSpec::new(
721 "fanout",
722 "route.items",
723 4,
724 MapBody::Node("worker".into()),
725 ))
726 .edge("score", "route")
727 .labeled_edge("route", "fanout", "high")
728 .build();
729
730 // The map node serializes with no output_schema key, because the spec
731 // never set one.
732 let value = serde_json::to_value(&graph).expect("serialize");
733 let map_payload = value["nodes"][3]["payload"].clone();
734 assert!(
735 map_payload.get("output_schema").is_none(),
736 "unset optional field must stay off the wire: {map_payload}"
737 );
738 assert_eq!(value["edges"][1]["label"], json!("high"));
739 }
740
741 /// The fold spec builds the shape the model expects: the body, bound, stop
742 /// predicate, and join land in the payload, an unset `accumulator_schema`
743 /// stays off the wire, and the document validates.
744 #[test]
745 fn fold_spec_builds_expected_shape() {
746 use crate::document::{FoldBody, FoldJoin};
747
748 let graph = GraphBuilder::new()
749 .agent(AgentSpec::new(
750 "tailor",
751 format!("sha256:{}", "a".repeat(64)),
752 ))
753 .fold(
754 FoldSpec::new(
755 "refine",
756 FoldBody::Node("tailor".into()),
757 3,
758 "score >= 0.85",
759 FoldJoin::BestBy("score".into()),
760 )
761 .name("Refine to threshold"),
762 )
763 .build();
764
765 let summary = crate::validate(&graph).expect("the fold flow is valid");
766 assert_eq!(summary.node_count, 2);
767
768 let value = serde_json::to_value(&graph).expect("serialize");
769 let payload = &value["nodes"][1]["payload"];
770 assert_eq!(value["nodes"][1]["kind"], json!("fold"));
771 assert_eq!(payload["max_iterations"], json!(3));
772 assert_eq!(payload["stop_when"], json!("score >= 0.85"));
773 assert_eq!(
774 payload["join"],
775 json!({"kind": "best_by", "value": "score"})
776 );
777 assert_eq!(payload["body"], json!({"kind": "node", "value": "tailor"}));
778 assert_eq!(payload["name"], json!("Refine to threshold"));
779 assert!(
780 payload.get("accumulator_schema").is_none(),
781 "unset optional field must stay off the wire: {payload}"
782 );
783 }
784
785 /// `.name(...)` is available on every node kind's spec, puts the name on
786 /// the wire when set, and validates clean; a sibling node with no `.name`
787 /// call carries none, proving the two coexist in one document.
788 #[test]
789 fn every_spec_kind_accepts_a_display_name() {
790 let graph = GraphBuilder::new()
791 .agent(
792 AgentSpec::new("research", format!("sha256:{}", "1".repeat(64)))
793 .name("Research the topic"),
794 )
795 .tool(ToolSpec::new("publish", "http_post").name("Publish the draft"))
796 .gate(GateSpec::new("approve", json!({"type": "object"})).name("Approve the draft"))
797 .branch(
798 BranchSpec::new("route")
799 .name("Route on confidence")
800 .case("high", BranchCondition::Expression("score > 0.8".into())),
801 )
802 .map(
803 MapSpec::new("fanout", "route.items", 2, MapBody::Node("research".into()))
804 .name("Notify each watcher"),
805 )
806 .edge("research", "publish")
807 .build();
808
809 let summary = crate::validate(&graph).expect("named nodes still validate");
810 assert_eq!(summary.node_count, 5);
811
812 let value = serde_json::to_value(&graph).expect("serialize");
813 for (index, expected) in [
814 (0, "Research the topic"),
815 (1, "Publish the draft"),
816 (2, "Approve the draft"),
817 (3, "Route on confidence"),
818 (4, "Notify each watcher"),
819 ] {
820 assert_eq!(
821 value["nodes"][index]["payload"]["name"],
822 json!(expected),
823 "node {index} carries its display name on the wire"
824 );
825 }
826 }
827}