use std::collections::HashSet;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use is_terminal::IsTerminal;
use consortium_cli::event_render::{
render_events, DelayingExecutor, EventCollector, JsonlWriter, LiveTreeRenderer,
};
use consortium_cli::tree::OutputFormat;
use consortium_fanout_sim::fixtures::{
rng_from_seed, BandwidthDistribution, FailureSchedule, SeedDistribution, UplinkDistribution,
};
use consortium_nix::cascade::{
Cascade, CascadeNode, Log2FanOut, NetworkProfile, NodeId, NodeIdAlloc,
};
use consortium_nix::cascade_events::{CascadeEvent, EventSink};
use consortium_nix::cascade_strategies::{MaxBottleneckSpanning, SteinerGreedy};
#[derive(Debug, Parser)]
#[command(
name = "cascade-viz",
about = "Visualize cascade event streams (replay JSONL traces or run live scenarios)"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short = 'f', long = "format", global = true, default_value = "tree")]
format: String,
#[arg(short = 'L', long = "max-depth", global = true)]
max_depth: Option<usize>,
#[arg(long = "no-color", global = true)]
no_color: bool,
}
#[derive(Debug, Subcommand)]
enum Commands {
Replay(ReplayArgs),
Live(LiveArgs),
}
#[derive(Debug, Args)]
struct ReplayArgs {
trace_file: String,
}
#[derive(Debug, Clone, Args)]
struct LiveArgs {
#[arg(short = 'n', long = "nodes", default_value_t = 32)]
nodes: u32,
#[arg(short = 's', long = "strategy", default_value = "level-tree")]
strategy: String,
#[arg(long = "fanout", default_value_t = 2)]
fanout: u32,
#[arg(long = "seeds", default_value_t = 1)]
seeds: u32,
#[arg(long = "seed-fraction", default_value_t = 0.0)]
seed_fraction: f64,
#[arg(long = "closure-mb", default_value_t = 50)]
closure_mb: u64,
#[arg(long = "bandwidth", default_value = "uniform")]
bandwidth: String,
#[arg(long = "uplinks")]
uplinks: Option<u64>,
#[arg(long = "seed", default_value_t = 0)]
seed: u64,
#[arg(long = "failure-rate", default_value_t = 0.0)]
failure_rate: f64,
#[arg(long = "failure-seed", default_value_t = 0)]
failure_seed: u64,
#[arg(long = "no-watch")]
no_watch: bool,
#[arg(long = "per-round-delay")]
per_round_delay_ms: Option<u64>,
}
fn resolve_format(
format_str: &str,
no_color: bool,
max_depth: Option<usize>,
) -> Result<OutputFormat> {
let mut fmt = OutputFormat::parse(format_str).map_err(|e| anyhow::anyhow!(e))?;
if let OutputFormat::Tree {
ref mut color,
max_depth: ref mut fmt_max_depth,
} = fmt
{
let is_tty = io::stdout().is_terminal();
*color = is_tty && !no_color;
if let Some(d) = max_depth {
*fmt_max_depth = Some(d);
}
}
Ok(fmt)
}
fn print_events(events: &[CascadeEvent], cli: &Cli) -> Result<()> {
if cli.format == "jsonl" {
for ev in events {
println!("{}", serde_json::to_string(ev)?);
}
return Ok(());
}
let fmt = resolve_format(&cli.format, cli.no_color, cli.max_depth)?;
print!("{}", render_events(events, &fmt));
Ok(())
}
fn run_replay(args: &ReplayArgs, cli: &Cli) -> Result<()> {
let file = File::open(&args.trace_file)
.with_context(|| format!("failed to open trace file: {}", args.trace_file))?;
let reader = BufReader::new(file);
let mut events: Vec<CascadeEvent> = Vec::new();
for (i, line) in reader.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);
}
print_events(&events, cli)
}
fn run_live(args: &LiveArgs, cli: &Cli) -> 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 cli.format == "jsonl" {
let sink = JsonlWriter::new(Box::new(io::stdout()));
run_scenario(args, closure_bytes, bandwidth, uplinks, &sink, None);
return Ok(());
}
let live_eligible = cli.format == "tree" && io::stdout().is_terminal() && !args.no_watch;
if live_eligible {
let color = !cli.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, cli.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);
return Ok(());
}
let collector = EventCollector::new();
run_scenario(args, closure_bytes, bandwidth, uplinks, &collector, None);
let events = collector.events();
print_events(&events, cli)
}
fn run_scenario<S: EventSink>(
args: &LiveArgs,
closure_bytes: u64,
bandwidth: BandwidthDistribution,
uplinks: Option<UplinkDistribution>,
sink: &S,
per_round_delay: Option<std::time::Duration>,
) {
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: HashSet<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(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(ref 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 level_tree = consortium_nix::cascade_strategies::LevelTreeFanOut::new(args.fanout.max(1));
match args.strategy.as_str() {
"level-tree" | "level" | "tree" => {
Cascade::new()
.nodes(nodes)
.seeded(seeded)
.network(net)
.strategy(&level_tree)
.executor(exec)
.events(sink)
.run();
}
"max-bottleneck" | "max-bottleneck-spanning" => {
Cascade::new()
.nodes(nodes)
.seeded(seeded)
.network(net)
.strategy(&MaxBottleneckSpanning)
.executor(exec)
.events(sink)
.run();
}
"steiner" | "steiner-greedy" => {
Cascade::new()
.nodes(nodes)
.seeded(seeded)
.network(net)
.strategy(&SteinerGreedy)
.executor(exec)
.events(sink)
.run();
}
_ => {
Cascade::new()
.nodes(nodes)
.seeded(seeded)
.network(net)
.strategy(&Log2FanOut)
.executor(exec)
.events(sink)
.run();
}
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
match &cli.command {
Commands::Replay(args) => run_replay(args, &cli),
Commands::Live(args) => run_live(args, &cli),
}
}