use std::fs::File;
use std::io::{self, BufRead, BufReader};
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use is_terminal::IsTerminal;
use crate::event_render::{
render_events, DelayingExecutor, EventCollector, JsonlWriter, LiveTreeRenderer,
};
use crate::tree::OutputFormat;
use consortium_fanout_sim::fixtures::{
rng_from_seed, BandwidthDistribution, FailureSchedule, SeedDistribution, UplinkDistribution,
};
use consortium_nix::cascade::{
Cascade, CascadeNode, FanoutOrder, NetworkProfile, NodeIdAlloc, OrderedFanout,
};
use consortium_nix::cascade_events::{CascadeEvent, EventSink};
use consortium_nix::cascade_strategies::{
find_strategy, parse_strategy, strategy_catalog, strategy_takes_traversal_order,
};
use consortium_nix::cascade_tree::{assert_relay_shape, parse_cascade_events};
#[derive(Debug, Clone, Args)]
pub struct RenderArgs {
#[arg(short = 'f', long = "format", default_value = "tree")]
pub format: String,
#[arg(short = 'L', long = "max-depth")]
pub max_depth: Option<usize>,
#[arg(long = "no-color")]
pub no_color: bool,
}
#[derive(Debug, Args)]
pub struct TreeArgs {
pub trace_file: String,
#[command(flatten)]
pub render: RenderArgs,
}
#[derive(Debug, Args)]
pub struct VerifyArgs {
pub trace_file: String,
#[arg(long)]
pub fanout: u32,
#[arg(long)]
pub nodes: Option<u32>,
}
#[derive(Debug, Clone, Args)]
pub struct LiveArgs {
#[arg(short = 'n', long = "nodes", default_value_t = 32)]
pub nodes: u32,
#[arg(short = 's', long = "strategy", default_value = "level-tree")]
pub strategy: String,
#[arg(long = "fanout", default_value_t = 2)]
pub fanout: u32,
#[arg(long = "seeds", default_value_t = 1)]
pub seeds: u32,
#[arg(long = "seed-fraction", default_value_t = 0.0)]
pub seed_fraction: f64,
#[arg(long = "closure-mb", default_value_t = 50)]
pub closure_mb: u64,
#[arg(long = "bandwidth", default_value = "uniform")]
pub bandwidth: String,
#[arg(long = "uplinks")]
pub uplinks: Option<u64>,
#[arg(long = "seed", default_value_t = 0)]
pub seed: u64,
#[arg(long = "failure-rate", default_value_t = 0.0)]
pub failure_rate: f64,
#[arg(long = "failure-seed", default_value_t = 0)]
pub failure_seed: u64,
#[arg(long = "no-watch")]
pub no_watch: bool,
#[arg(long = "per-round-delay")]
pub per_round_delay_ms: Option<u64>,
}
#[derive(Debug, Subcommand)]
pub enum CascadeCommands {
Tree(TreeArgs),
Verify(VerifyArgs),
Live {
#[command(flatten)]
live: LiveArgs,
#[command(flatten)]
render: RenderArgs,
},
}
pub fn dispatch(command: CascadeCommands) -> Result<()> {
match command {
CascadeCommands::Tree(args) => {
print!("{}", render_trace_file(&args.trace_file, &args.render)?);
}
CascadeCommands::Verify(args) => {
println!(
"{}",
verify_trace_file(&args.trace_file, args.fanout, args.nodes)?
);
}
CascadeCommands::Live { live, render } => run_live(&live, &render)?,
}
Ok(())
}
fn resolve_format(render: &RenderArgs) -> Result<OutputFormat> {
let mut fmt = OutputFormat::parse(&render.format).map_err(|e| anyhow::anyhow!(e))?;
if let OutputFormat::Tree {
color,
max_depth: fmt_max_depth,
} = &mut fmt
{
let is_tty = io::stdout().is_terminal();
*color = is_tty && !render.no_color;
if let Some(d) = render.max_depth {
*fmt_max_depth = Some(d);
}
}
Ok(fmt)
}
pub fn render_trace_file(path: &str, render: &RenderArgs) -> Result<String> {
let file = File::open(path).with_context(|| format!("failed to open trace file: {path}"))?;
let mut events: Vec<CascadeEvent> = Vec::new();
for (i, line) in BufReader::new(file).lines().enumerate() {
let line = line.with_context(|| format!("failed to read line {}", i + 1))?;
if line.trim().is_empty() {
continue;
}
let ev: CascadeEvent = serde_json::from_str(&line)
.with_context(|| format!("invalid event JSON on line {}: {line}", i + 1))?;
events.push(ev);
}
if render.format == "jsonl" {
let mut out = String::new();
for ev in &events {
out.push_str(&serde_json::to_string(ev)?);
out.push('\n');
}
return Ok(out);
}
let fmt = resolve_format(render)?;
Ok(render_events(&events, &fmt))
}
pub fn verify_trace_file(path: &str, fanout: u32, nodes: Option<u32>) -> Result<String> {
if fanout < 1 {
anyhow::bail!("fanout must be at least 1, got {fanout}");
}
let stream = std::fs::read_to_string(path)
.with_context(|| format!("failed to open trace file: {path}"))?;
let topology = parse_cascade_events(&stream)
.map_err(|e| anyhow::anyhow!("cascade relay check failed: {e}"))?;
let strategy = find_strategy(&topology.strategy).ok_or_else(|| {
anyhow::anyhow!(
"unknown strategy '{}' in started event; available: {}",
topology.strategy,
strategy_catalog()
)
})?;
let expected_rounds =
(strategy.rounds_rule)(topology.n_nodes as usize, topology.seeded.len(), fanout);
assert_relay_shape(&topology, strategy.canonical, expected_rounds, fanout)
.map_err(|e| anyhow::anyhow!("cascade relay check failed: {e}"))?;
if let Some(count) = nodes {
if topology.n_nodes != count {
anyhow::bail!(
"cascade covered {} nodes, expected {}",
topology.n_nodes,
count
);
}
}
Ok(serde_json::json!({
"nodes": topology.n_nodes,
"rounds": topology.rounds,
"relay_depth": topology.depth(),
"relayed_nodes": topology.parent.len(),
})
.to_string())
}
pub fn run_live(args: &LiveArgs, render: &RenderArgs) -> Result<()> {
run_live_with_order(args, render, FanoutOrder::Default)
}
pub(crate) fn run_live_with_order(
args: &LiveArgs,
render: &RenderArgs,
order: FanoutOrder,
) -> Result<()> {
let bandwidth = match args.bandwidth.as_str() {
"bimodal" => BandwidthDistribution::Bimodal {
slow: 10 * 1024 * 1024,
fast: 1024 * 1024 * 1024,
fast_fraction: 0.3,
},
_ => BandwidthDistribution::Uniform(100 * 1024 * 1024),
};
let uplinks = args.uplinks.map(UplinkDistribution::Uniform);
let closure_bytes = args.closure_mb * 1024 * 1024;
if render.format == "jsonl" {
let sink = JsonlWriter::new(Box::new(io::stdout()));
run_scenario(args, closure_bytes, bandwidth, uplinks, &sink, None, order)?;
return Ok(());
}
let live_eligible = render.format == "tree" && io::stdout().is_terminal() && !args.no_watch;
if live_eligible {
let color = !render.no_color;
let mut header_lines: Vec<String> = Vec::new();
header_lines.push(format!(
"Strategy: {} || Nodes: {}",
args.strategy, args.nodes
));
let mut scenario: Vec<String> = Vec::new();
if args.fanout != 2 {
scenario.push(format!("Fanout: {}", args.fanout));
}
if args.seeds != 1 {
scenario.push(format!("Seeds: {}", args.seeds));
}
if args.closure_mb != 50 {
scenario.push(format!("Closure: {}MB", args.closure_mb));
}
if args.bandwidth != "uniform" {
scenario.push(format!("Bandwidth: {}", args.bandwidth));
}
if let Some(uplink) = args.uplinks {
scenario.push(format!("Uplinks: {}B/s", uplink));
}
if !scenario.is_empty() {
header_lines.push(scenario.join(" || "));
}
let mut runtime: Vec<String> = Vec::new();
if args.failure_rate > 0.0 {
runtime.push(format!(
"Failures: {:.0}% (seed={})",
args.failure_rate * 100.0,
args.failure_seed
));
}
if let Some(delay_ms) = args.per_round_delay_ms {
runtime.push(format!("Delay: {}ms/round", delay_ms));
}
if !runtime.is_empty() {
header_lines.push(runtime.join(" || "));
}
let renderer =
LiveTreeRenderer::new(color, render.max_depth).with_header_lines(header_lines);
let delay = args
.per_round_delay_ms
.map(std::time::Duration::from_millis);
run_scenario(
args,
closure_bytes,
bandwidth,
uplinks,
&renderer,
delay,
order,
)?;
return Ok(());
}
let collector = EventCollector::new();
run_scenario(
args,
closure_bytes,
bandwidth,
uplinks,
&collector,
None,
order,
)?;
let events = collector.events();
let fmt = resolve_format(render)?;
print!("{}", render_events(&events, &fmt));
Ok(())
}
fn run_scenario<S: EventSink>(
args: &LiveArgs,
closure_bytes: u64,
bandwidth: BandwidthDistribution,
uplinks: Option<UplinkDistribution>,
sink: &S,
per_round_delay: Option<std::time::Duration>,
order: FanoutOrder,
) -> Result<()> {
let n_nodes = args.nodes;
let mut alloc = NodeIdAlloc::new();
let nodes: Vec<CascadeNode> = (0..n_nodes)
.map(|_| {
let id = alloc.alloc();
CascadeNode::new(id, format!("user@host-{}", id.0))
})
.collect();
let seeded: std::collections::HashSet<consortium_nix::cascade::NodeId> =
if args.seed_fraction > 0.0 {
let mut rng = rng_from_seed(args.seed);
SeedDistribution::Random {
fraction: args.seed_fraction,
}
.sample(&mut rng, n_nodes)
} else {
let count = args.seeds.min(n_nodes).max(1);
(0..count).map(consortium_nix::cascade::NodeId).collect()
};
let net: NetworkProfile = {
let mut rng = rng_from_seed(args.seed);
let mut profile = NetworkProfile::default();
bandwidth.populate(&mut rng, &mut profile, n_nodes);
if let Some(up) = &uplinks {
up.populate(&mut rng, &mut profile, n_nodes);
}
profile
};
let failures = if args.failure_rate > 0.0 {
FailureSchedule::Random {
fraction: args.failure_rate,
seed: args.failure_seed,
}
} else {
FailureSchedule::None
};
let base_exec = consortium_fanout_sim::DeterministicExecutor::new(closure_bytes, failures);
let delayed_exec = per_round_delay.map(|delay| DelayingExecutor {
inner: &base_exec as &dyn consortium_nix::cascade::RoundExecutor,
delay,
});
let exec: &dyn consortium_nix::cascade::RoundExecutor = delayed_exec
.as_ref()
.map(|d| d as &dyn consortium_nix::cascade::RoundExecutor)
.unwrap_or(&base_exec);
let mut strategy = parse_strategy(&args.strategy, args.fanout)?;
if order != FanoutOrder::Default {
if !strategy_takes_traversal_order(&args.strategy) {
let order_name = consortium_nix::cascade::FANOUT_ORDERS
.iter()
.find(|spec| spec.order == order)
.map(|spec| spec.canonical)
.unwrap_or("non-default");
anyhow::bail!(
"traversal order '{}' parameterizes the log2 fanout pairing; \
strategy '{}' does not consume it",
order_name,
args.strategy
);
}
strategy = Box::new(OrderedFanout::new(order));
}
Cascade::new()
.nodes(nodes)
.seeded(seeded)
.network(net)
.strategy(strategy.as_ref())
.executor(exec)
.events(sink)
.run();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use serde_json::json;
#[derive(Parser)]
struct TestCli {
#[command(subcommand)]
cmd: CascadeCommands,
}
fn parse(args: &[&str]) -> CascadeCommands {
let cli = TestCli::parse_from(args);
cli.cmd
}
const RELAY_15: &str = concat!(
r#"{"kind":"started","n_nodes":15,"seeded":[0],"strategy":"level-tree","at":0}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":1,"src":1,"tgt":3,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":1,"src":1,"tgt":4,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":1,"src":2,"tgt":5,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":1,"src":2,"tgt":6,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":2,"src":3,"tgt":7,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":2,"src":3,"tgt":8,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":2,"src":4,"tgt":9,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":2,"src":4,"tgt":10,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":2,"src":5,"tgt":11,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":2,"src":5,"tgt":12,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":2,"src":6,"tgt":13,"duration":100000000}"#,
"\n",
r#"{"kind":"edge_completed","round":2,"src":6,"tgt":14,"duration":100000000}"#,
"\n",
r#"{"kind":"finished","converged":15,"failed":0,"rounds":3}"#,
"\n",
);
const PAIR: &str = concat!(
r#"{"kind":"started","n_nodes":2,"seeded":[0],"strategy":"log2-fanout","at":0}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":100000000}"#,
"\n",
r#"{"kind":"finished","converged":2,"failed":0,"rounds":1}"#,
"\n",
);
fn write_trace(dir: &tempfile::TempDir, name: &str, contents: &str) -> String {
let path = dir.path().join(name);
std::fs::write(&path, contents).unwrap();
path.to_str().unwrap().to_string()
}
fn render_args(format: &str) -> RenderArgs {
RenderArgs {
format: format.to_string(),
max_depth: None,
no_color: true,
}
}
#[test]
fn tree_names_a_missing_file() {
let err = render_trace_file("/nonexistent/trace.jsonl", &render_args("tree")).unwrap_err();
assert!(
err.to_string().contains("failed to open trace file"),
"got: {err}"
);
}
#[test]
fn tree_renders_a_trace() {
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "trace.jsonl", PAIR);
let out = render_trace_file(&path, &render_args("tree")).unwrap();
assert!(out.contains("n0"), "expected n0 in: {out}");
assert!(out.contains("✔"), "expected converged glyph in: {out}");
}
#[test]
fn tree_json_output_is_an_event_array() {
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "trace.jsonl", PAIR);
let out = render_trace_file(&path, &render_args("json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().expect("JSON array of events");
assert!(arr.len() >= 2, "expected at least Started + Finished");
assert_eq!(arr[0]["kind"], "started");
assert_eq!(arr.last().unwrap()["kind"], "finished");
}
#[test]
fn tree_jsonl_passes_events_through() {
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "trace.jsonl", PAIR);
let out = render_trace_file(&path, &render_args("jsonl")).unwrap();
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 3, "one line per event: {out}");
for line in lines {
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|e| panic!("invalid JSON line: {e}\n {line}"));
}
}
#[test]
fn tree_names_invalid_event_json_by_line() {
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "trace.jsonl", "{\"kind\":\"started\",\"n_nodes\":2,\"seeded\":[0],\"strategy\":\"log2-fanout\",\"at\":0}\ngarbage\n");
let err = render_trace_file(&path, &render_args("tree")).unwrap_err();
assert!(
err.to_string().contains("invalid event JSON on line 2"),
"got: {err}"
);
}
#[test]
fn verify_accepts_a_relayed_trace() {
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "relay.jsonl", RELAY_15);
let out = verify_trace_file(&path, 2, None).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(
parsed,
json!({
"nodes": 15,
"rounds": 3,
"relay_depth": 3,
"relayed_nodes": 14,
})
);
}
#[test]
fn verify_rejects_a_round_count_the_strategy_could_not_produce() {
let dir = tempfile::tempdir().unwrap();
let path = write_trace(
&dir,
"too-fast.jsonl",
&RELAY_15.replace(r#""rounds":3"#, r#""rounds":2"#),
);
let err = verify_trace_file(&path, 2, None).unwrap_err();
assert!(
err.to_string().contains("2 rounds"),
"the diagnostic must name the offending round count, got: {err}"
);
}
#[test]
fn verify_rejects_a_pull_only_trace() {
let stream = concat!(
r#"{"kind":"started","n_nodes":5,"seeded":[0],"strategy":"log2-fanout","at":0}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":3,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":4,"duration":1}"#,
"\n",
r#"{"kind":"finished","converged":5,"failed":0,"rounds":1}"#,
"\n",
);
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "pull.jsonl", stream);
let err = verify_trace_file(&path, 2, None).unwrap_err();
assert!(
err.to_string()
.starts_with("cascade relay check failed: the payload was not relayed:"),
"got: {err}"
);
}
#[test]
fn verify_rejects_a_swarm_trace_by_the_star_rule() {
let stream = concat!(
r#"{"kind":"started","n_nodes":8,"seeded":[0],"strategy":"swarm","at":0}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":3,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":4,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":5,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":6,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":7,"duration":1}"#,
"\n",
r#"{"kind":"finished","converged":8,"failed":0,"rounds":1}"#,
"\n",
);
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "swarm.jsonl", stream);
let err = verify_trace_file(&path, 2, None).unwrap_err();
assert!(
err.to_string()
.starts_with("cascade relay check failed: the payload was not relayed:"),
"swarm must trip the star rule, got: {err}"
);
}
#[test]
fn live_rejects_an_unknown_strategy() {
let args = LiveArgs {
nodes: 4,
strategy: "warp-drive".into(),
fanout: 2,
seeds: 1,
seed_fraction: 0.0,
closure_mb: 1,
bandwidth: "uniform".into(),
uplinks: None,
seed: 0,
failure_rate: 0.0,
failure_seed: 0,
no_watch: true,
per_round_delay_ms: None,
};
let err = run_live(&args, &render_args("json")).unwrap_err();
assert!(
err.to_string().contains("unknown strategy 'warp-drive'"),
"got: {err}"
);
assert!(
err.to_string().contains("level-tree"),
"the error must list what is available, got: {err}"
);
}
#[test]
fn verify_names_a_malformed_line() {
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "bad.jsonl", "not json\n");
let err = verify_trace_file(&path, 2, None).unwrap_err();
assert!(
err.to_string()
.starts_with("cascade relay check failed: line 1 is not JSON:"),
"got: {err}"
);
}
#[test]
fn verify_requires_a_started_event() {
let stream = concat!(
r#"{"kind":"finished","converged":2,"failed":0,"rounds":1}"#,
"\n",
);
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "nostart.jsonl", stream);
let err = verify_trace_file(&path, 2, None).unwrap_err();
assert!(
err.to_string()
.starts_with("cascade relay check failed: event stream has no started event"),
"got: {err}"
);
}
#[test]
fn verify_checks_the_requested_node_count() {
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "relay.jsonl", RELAY_15);
let err = verify_trace_file(&path, 2, Some(64)).unwrap_err();
assert_eq!(err.to_string(), "cascade covered 15 nodes, expected 64");
}
#[test]
fn verify_rejects_a_zero_fanout() {
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "relay.jsonl", RELAY_15);
let err = verify_trace_file(&path, 0, None).unwrap_err();
assert_eq!(err.to_string(), "fanout must be at least 1, got 0");
}
#[test]
fn verify_rejects_an_unknown_strategy() {
let stream = concat!(
r#"{"kind":"started","n_nodes":4,"seeded":[0],"strategy":"warp-drive","at":0}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":1}"#,
"\n",
r#"{"kind":"edge_completed","round":0,"src":0,"tgt":3,"duration":1}"#,
"\n",
r#"{"kind":"finished","converged":4,"failed":0,"rounds":1}"#,
"\n",
);
let dir = tempfile::tempdir().unwrap();
let path = write_trace(&dir, "warp.jsonl", stream);
let err = verify_trace_file(&path, 2, None).unwrap_err();
assert!(
err.to_string()
.contains("unknown strategy 'warp-drive' in started event"),
"got: {err}"
);
}
#[test]
fn tree_parses_the_contract_flags() {
let CascadeCommands::Tree(args) = parse(&[
"cast",
"tree",
"t.jsonl",
"--format",
"json",
"--max-depth",
"3",
"--no-color",
]) else {
panic!("expected Tree");
};
assert_eq!(args.trace_file, "t.jsonl");
assert_eq!(args.render.format, "json");
assert_eq!(args.render.max_depth, Some(3));
assert!(args.render.no_color);
}
#[test]
fn verify_requires_a_fanout() {
let missing = TestCli::try_parse_from(["cast", "verify", "t.jsonl"]);
assert!(missing.is_err(), "--fanout is required");
let CascadeCommands::Verify(args) = parse(&[
"cast", "verify", "t.jsonl", "--fanout", "2", "--nodes", "64",
]) else {
panic!("expected Verify");
};
assert_eq!(args.fanout, 2);
assert_eq!(args.nodes, Some(64));
assert_eq!(args.trace_file, "t.jsonl");
}
#[test]
fn live_preserves_the_viz_flags() {
let CascadeCommands::Live { live: args, render } = parse(&[
"cast",
"live",
"-n",
"8",
"-s",
"steiner",
"--fanout",
"3",
"--seed",
"7",
"--no-watch",
"--format",
"jsonl",
]) else {
panic!("expected Live");
};
assert_eq!(args.nodes, 8);
assert_eq!(args.strategy, "steiner");
assert_eq!(args.fanout, 3);
assert_eq!(args.seed, 7);
assert!(args.no_watch);
assert_eq!(render.format, "jsonl");
}
}