use std::sync::{Arc, Mutex};
use serde_json::json;
use tinyagents::{
ClosureStateReducer, GraphBuilder, NodeContext, NodeResult, fanout_node, run_recorded,
};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct Squares {
values: Vec<i64>,
}
type ForkLog = Arc<Mutex<Vec<(Option<usize>, i64)>>>;
fn fanout_graph(
parallel: bool,
max_concurrency: Option<usize>,
forks: ForkLog,
) -> tinyagents::CompiledGraph<Squares, i64> {
let mut builder = GraphBuilder::<Squares, i64>::new()
.set_reducer(ClosureStateReducer::new(|mut s: Squares, u: i64| {
s.values.push(u);
Ok(s)
}))
.with_parallel(parallel);
if let Some(cap) = max_concurrency {
builder = builder.with_max_concurrency(cap);
}
builder
.add_node(
"spread",
fanout_node("square", [json!(1), json!(2), json!(3), json!(4)]),
)
.add_node("square", move |_s: Squares, ctx: NodeContext| {
let forks = forks.clone();
async move {
let arg = ctx
.send_arg
.as_ref()
.and_then(|v| v.as_i64())
.expect("each worker carries a send arg");
let branch = ctx.fork.as_ref().map(|f| f.branch_index);
forks.lock().unwrap().push((branch, arg));
Ok(NodeResult::Update(arg * arg))
}
})
.add_node("collect", tinyagents::noop_node())
.set_entry("spread")
.add_edge("square", "collect")
.set_finish("collect")
.compile()
.expect("fan-out graph compiles")
}
#[tokio::test]
async fn send_fanout_folds_each_branch_update_through_the_reducer() {
let forks: ForkLog = Arc::new(Mutex::new(Vec::new()));
let run = run_recorded(
&fanout_graph(false, None, forks.clone()),
None,
Squares::default(),
)
.await
.expect("run succeeds");
assert_eq!(run.execution.state.values, vec![1, 4, 9, 16]);
let square_visits = run
.execution
.visited
.iter()
.filter(|n| n.as_str() == "square")
.count();
assert_eq!(square_visits, 4);
let mut args: Vec<i64> = forks.lock().unwrap().iter().map(|(_, a)| *a).collect();
args.sort_unstable();
assert_eq!(args, vec![1, 2, 3, 4]);
}
#[tokio::test]
async fn each_parallel_branch_receives_a_distinct_fork_identity() {
let forks: ForkLog = Arc::new(Mutex::new(Vec::new()));
let _ = run_recorded(
&fanout_graph(true, None, forks.clone()),
None,
Squares::default(),
)
.await
.expect("run succeeds");
let mut recorded = forks.lock().unwrap().clone();
recorded.sort_by_key(|(branch, _)| branch.unwrap_or(usize::MAX));
assert_eq!(
recorded,
vec![(Some(0), 1), (Some(1), 2), (Some(2), 3), (Some(3), 4)]
);
}
#[tokio::test]
async fn parallel_fanout_is_deterministic_under_a_bounded_concurrency_cap() {
let forks: ForkLog = Arc::new(Mutex::new(Vec::new()));
let graph = fanout_graph(true, Some(2), forks.clone());
let run = run_recorded(&graph, None, Squares::default())
.await
.expect("parallel run succeeds");
assert_eq!(run.execution.state.values, vec![1, 4, 9, 16]);
let collect_visits = run
.execution
.visited
.iter()
.filter(|n| n.as_str() == "collect")
.count();
assert_eq!(collect_visits, 1);
}