Files
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

331 lines
12 KiB
Rust

//! Measure how many agents a swarm task graph would spawn.
//!
//! This drives the *real* task-DAG engine (`jcode_plan::dag`) with scripted mock
//! workers and counts the things that map to live runtime behaviour:
//!
//! * **nodes**: total nodes in the final graph, including auto-inserted gates.
//! * **dispatches**: worker turns. Under `run_plan`'s default (`prefer_spawn=true`),
//! each dispatch is a *fresh* spawned agent, so this is the agent-spawn count.
//! * **gates**: critique/verify gates auto-inserted in deep mode.
//! * **peak concurrency**: max nodes runnable at once (the natural parallelism),
//! which is what `run_plan`'s `concurrency_limit` (default 3) would clamp.
//!
//! Run with: `cargo run -p jcode-plan --example swarm_agent_count`
//!
//! The point is to replace hand-waving ("it spawns a lot") with reproducible
//! numbers for several representative task shapes, and to show how the deep-mode
//! gate machinery and gap injection inflate the agent count versus light mode.
use std::collections::{HashMap, HashSet};
use jcode_plan::dag::sim::deep_artifact;
use jcode_plan::dag::{
HandoffArtifact, Mode, NodeKind, NodeSpec, TaskGraph, complete_node, dispatch, expand_node,
fail_node, inject_from_gate, ready_nodes, seed,
};
/// What a scripted worker decides to do with a dispatched node.
#[derive(Clone)]
enum Act {
Complete,
Expand(Vec<NodeSpec>),
InjectGap(Vec<NodeSpec>),
#[allow(dead_code)]
Fail,
}
/// A scenario is a mode, a seed, and a per-node behaviour script (by node id).
struct Scenario {
name: &'static str,
mode: Mode,
seed: Vec<NodeSpec>,
/// Returns the action for a node the *first* time it is dispatched. Composite
/// nodes are dispatched twice (expand, then synthesis re-wake); the synthesis
/// re-wake always just completes.
script: HashMap<String, Act>,
}
/// Result of running a scenario through the engine.
#[derive(Default)]
struct Measured {
nodes_final: usize,
gates: usize,
dispatches: usize,
expansions: usize,
gaps_injected: usize,
peak_concurrency_unbounded: usize,
steps: usize,
stalled: bool,
}
fn spec(id: &str, kind: NodeKind) -> NodeSpec {
NodeSpec::new(id, format!("task {id}"), kind)
}
/// Drive a scenario to completion with an *unbounded* worker pool so we can read
/// the natural peak concurrency, while still counting every dispatch (= agent).
fn measure(scn: &Scenario) -> Measured {
let mut g = TaskGraph::new(scn.mode);
if let Err(err) = seed(&mut g, scn.seed.clone()) {
eprintln!("scenario seed failed to validate: {err}");
return Measured {
stalled: true,
..Measured::default()
};
}
let mut script = scn.script.clone();
let mut done_once: HashSet<String> = HashSet::new();
let mut m = Measured::default();
let max_steps = 10_000;
loop {
if g.all_terminal() {
break;
}
if m.steps >= max_steps {
m.stalled = true;
break;
}
let ready: Vec<(String, NodeKind)> = ready_nodes(&g)
.into_iter()
.map(|n| (n.id.clone(), n.kind))
.collect();
if ready.is_empty() {
m.stalled = true;
break;
}
// Natural parallelism this step (unbounded pool dispatches all ready).
m.peak_concurrency_unbounded = m.peak_concurrency_unbounded.max(ready.len());
for (idx, (id, _kind)) in ready.into_iter().enumerate() {
let worker = format!("w{idx}");
if !dispatch(&mut g, &id, &worker) {
continue;
}
m.dispatches += 1;
// A node already expanded that re-wakes for synthesis just completes.
let already = done_once.contains(&id);
let act = if already {
Act::Complete
} else {
script.remove(&id).unwrap_or(Act::Complete)
};
done_once.insert(id.clone());
let step = match act {
Act::Complete => {
let art = if scn.mode.requires_gates() {
deep_artifact(&format!("did {id}"))
} else {
HandoffArtifact::brief(format!("did {id}"))
};
complete_node(&mut g, &id, &worker, art)
}
Act::Expand(children) => {
m.expansions += 1;
expand_node(&mut g, &id, &worker, children).map(|_| ())
}
Act::InjectGap(nodes) => {
m.gaps_injected += nodes.len();
inject_from_gate(&mut g, &id, &worker, nodes).map(|_| ())
}
Act::Fail => fail_node(&mut g, &id, &worker),
};
if let Err(err) = step {
eprintln!("scenario step on '{id}' failed: {err}");
m.stalled = true;
break;
}
m.steps += 1;
}
}
m.nodes_final = g.nodes().len();
m.gates = g.nodes().iter().filter(|n| n.is_gate).count();
m
}
/// Scenario 1: light flat fan-out. N independent implement tasks + 1 merge.
fn light_fanout(n: usize) -> Scenario {
let mut seed_nodes = Vec::new();
let mut deps = Vec::new();
for i in 0..n {
let id = format!("t{i}");
seed_nodes.push(spec(&id, NodeKind::Implement));
deps.push(id);
}
seed_nodes.push(spec("merge", NodeKind::Synthesize).depends_on(deps));
Scenario {
name: "light: flat fan-out (N impl + merge)",
mode: Mode::Light,
seed: seed_nodes,
script: HashMap::new(),
}
}
/// Scenario 2: deep shallow. One root explore decomposed into K facets. Deep mode
/// adds a critique gate + a synthesis re-wake. No gaps found.
fn deep_shallow(k: usize) -> Scenario {
let mut script = HashMap::new();
let children: Vec<NodeSpec> = (0..k)
.map(|i| spec(&format!("root.{i}"), NodeKind::Explore))
.collect();
script.insert("root".to_string(), Act::Expand(children));
Scenario {
name: "deep: 1 root -> K facets (gate, no gaps)",
mode: Mode::Deep,
seed: vec![spec("root", NodeKind::Explore)],
script,
}
}
/// Scenario 3: deep shallow but the critique gate finds one gap, spawning an extra
/// node and re-running the gate (the comprehensiveness loop).
fn deep_with_gap(k: usize) -> Scenario {
let mut script = HashMap::new();
let children: Vec<NodeSpec> = (0..k)
.map(|i| spec(&format!("root.{i}"), NodeKind::Explore))
.collect();
script.insert("root".to_string(), Act::Expand(children));
// The auto gate id is "root::gate"; first dispatch injects a gap.
script.insert(
"root::gate".to_string(),
Act::InjectGap(vec![spec("root.gap", NodeKind::Explore)]),
);
Scenario {
name: "deep: 1 root -> K facets, gate finds 1 gap",
mode: Mode::Deep,
seed: vec![spec("root", NodeKind::Explore)],
script,
}
}
/// Scenario 4: deep nested. Root -> K facets; one facet itself decomposes into M
/// sub-facets (a second composite + gate). Models real recursive decomposition.
fn deep_nested(k: usize, m: usize) -> Scenario {
let mut script = HashMap::new();
let children: Vec<NodeSpec> = (0..k)
.map(|i| spec(&format!("root.{i}"), NodeKind::Explore))
.collect();
script.insert("root".to_string(), Act::Expand(children));
let sub: Vec<NodeSpec> = (0..m)
.map(|i| spec(&format!("root.0.{i}"), NodeKind::Explore))
.collect();
script.insert("root.0".to_string(), Act::Expand(sub));
Scenario {
name: "deep: nested (root->K, facet0->M), 2 gates",
mode: Mode::Deep,
seed: vec![spec("root", NodeKind::Explore)],
script,
}
}
/// Scenario 5: a realistic "explore then implement then verify" deep graph.
fn deep_explore_implement_verify() -> Scenario {
let mut script = HashMap::new();
// explore decomposes into 3 facets of investigation.
script.insert(
"explore".to_string(),
Act::Expand(vec![
spec("explore.api", NodeKind::Explore),
spec("explore.data", NodeKind::Explore),
spec("explore.ui", NodeKind::Explore),
]),
);
// implement decomposes into 2 code changes.
script.insert(
"implement".to_string(),
Act::Expand(vec![
spec("impl.core", NodeKind::Implement),
spec("impl.glue", NodeKind::Implement),
]),
);
Scenario {
name: "deep: explore(3) -> implement(2) -> verify",
mode: Mode::Deep,
seed: vec![
spec("explore", NodeKind::Explore),
spec("implement", NodeKind::Implement).depends_on(["explore"]),
spec("verify", NodeKind::Verify).depends_on(["implement"]),
],
script,
}
}
fn print_row(scn: &Scenario, m: &Measured) {
// Deep mode now fans out to the full ready set (bounded only by the member
// cap / the configurable swarm_max_concurrent_agents, default 32). Light mode
// keeps a small default (4). So the effective peak parallelism is the natural
// ready-set width clamped by the mode's default ceiling.
let mode_ceiling = match scn.mode {
Mode::Deep => 32,
Mode::Light => 4,
};
let effective_peak = m.peak_concurrency_unbounded.min(mode_ceiling);
println!("{}", scn.name);
println!(
" mode={:<5} nodes(final)={:<3} gates={:<2} gaps_injected={:<2} expansions={}",
match scn.mode {
Mode::Deep => "deep",
Mode::Light => "light",
},
m.nodes_final,
m.gates,
m.gaps_injected,
m.expansions,
);
println!(
" dispatches(=agents spawned, fresh-per-node)={:<3} peak_parallel(natural)={:<2} peak_parallel(mode default cap)={}",
m.dispatches, m.peak_concurrency_unbounded, effective_peak,
);
if m.stalled {
println!(" !! STALLED (engine could not drive to terminal)");
}
println!();
}
fn main() {
println!("=== Swarm agent-count measurement (real dag engine) ===\n");
println!(
"dispatches == worker turns. run_plan defaults to a fresh spawned agent per node\n\
(prefer_spawn=true), so dispatches is the number of agents spawned. Composite\n\
nodes are dispatched twice (decompose, then synthesis re-wake) so they cost 2.\n\
peak_parallel(natural) is how many nodes are unblocked at once. run_plan clamps\n\
that to a mode default: deep => agents.swarm_max_concurrent_agents (default 32,\n\
0 = unbounded up to the 1000 member cap); light => 4. The total agents spawned\n\
over the run is unaffected by the cap; only how many run simultaneously is.\n"
);
let scenarios = vec![
light_fanout(4),
light_fanout(16),
deep_shallow(3),
deep_shallow(6),
deep_with_gap(3),
deep_nested(3, 3),
deep_explore_implement_verify(),
];
for scn in &scenarios {
let m = measure(scn);
print_row(scn, &m);
}
// A compact growth table for deep shallow decomposition.
println!("--- deep shallow: agents spawned as facet count K grows ---");
println!(" K facets | final nodes | gates | dispatches(agents)");
for k in [1usize, 2, 3, 4, 6, 8, 12, 16] {
let m = measure(&deep_shallow(k));
println!(
" {k:>8} | {:>11} | {:>5} | {:>17}",
m.nodes_final, m.gates, m.dispatches
);
}
println!(
"\nFormula (deep, 1 level, no gaps): nodes = K + 2 (root + gate), \
agents = K + 3\n (K facet dispatches + 1 root-expand + 1 gate + 1 root-synthesis re-wake)."
);
}