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}