Skip to main content

consortium_cli/
cascade.rs

1//! `cast cascade` — visualize cascade event streams, and verify they relayed.
2//!
3//! Subcommands:
4//! - `tree <TRACE_FILE>`  replay a JSONL trace file and render it
5//! - `verify <TRACE_FILE>` assert a trace is a real peer-to-peer relay
6//! - `live`               run a fresh scenario and render it
7//!
8//! Renderers + sinks come from [`crate::event_render`]; the relay assertion
9//! comes from [`consortium_nix::cascade_tree`], next to the `CascadeEvent`
10//! type that produces the stream. This module is the library half of the old
11//! `cascade-viz` binary, so it stays testable without spawning a process.
12
13use std::fs::File;
14use std::io::{self, BufRead, BufReader};
15
16use anyhow::{Context, Result};
17use clap::{Args, Subcommand};
18use is_terminal::IsTerminal;
19
20use crate::event_render::{
21    render_events, DelayingExecutor, EventCollector, JsonlWriter, LiveTreeRenderer,
22};
23use crate::tree::OutputFormat;
24use consortium_fanout_sim::fixtures::{
25    rng_from_seed, BandwidthDistribution, FailureSchedule, SeedDistribution, UplinkDistribution,
26};
27use consortium_nix::cascade::{
28    Cascade, CascadeNode, FanoutOrder, NetworkProfile, NodeIdAlloc, OrderedFanout,
29};
30use consortium_nix::cascade_events::{CascadeEvent, EventSink};
31use consortium_nix::cascade_strategies::{
32    find_strategy, parse_strategy, strategy_catalog, strategy_takes_traversal_order,
33};
34use consortium_nix::cascade_tree::{assert_relay_shape, parse_cascade_events};
35
36// ============================================================================
37// CLI definition
38// ============================================================================
39
40/// Flags shared by the subcommands that render a tree.
41#[derive(Debug, Clone, Args)]
42pub struct RenderArgs {
43    /// Output format: tree (default), json, yaml, toml, jsonl
44    #[arg(short = 'f', long = "format", default_value = "tree")]
45    pub format: String,
46
47    /// Limit tree depth (tree format only)
48    #[arg(short = 'L', long = "max-depth")]
49    pub max_depth: Option<usize>,
50
51    /// Disable ANSI colors
52    #[arg(long = "no-color")]
53    pub no_color: bool,
54}
55
56#[derive(Debug, Args)]
57pub struct TreeArgs {
58    /// Path to a JSONL trace file (one CascadeEvent per line)
59    pub trace_file: String,
60
61    #[command(flatten)]
62    pub render: RenderArgs,
63}
64
65#[derive(Debug, Args)]
66pub struct VerifyArgs {
67    /// Path to a JSONL trace file (one CascadeEvent per line)
68    pub trace_file: String,
69
70    /// Fanout the cascade was run with. A fleet the seed could serve in one
71    /// round is exempt from the relay check.
72    #[arg(long)]
73    pub fanout: u32,
74
75    /// Expected fleet size; fails if the trace's started event disagrees.
76    #[arg(long)]
77    pub nodes: Option<u32>,
78}
79
80#[derive(Debug, Clone, Args)]
81pub struct LiveArgs {
82    /// Number of nodes (default 32)
83    #[arg(short = 'n', long = "nodes", default_value_t = 32)]
84    pub nodes: u32,
85
86    /// Strategy (default `level-tree`). Resolved through the registry:
87    /// every strategy has a canonical name plus short aliases — see
88    /// them all with `cast cascade strategies`.
89    #[arg(short = 's', long = "strategy", default_value = "level-tree")]
90    pub strategy: String,
91
92    /// Fanout for the level-tree strategy (children per node).
93    /// 2 → balanced binary tree, 3 → ternary, etc.
94    #[arg(long = "fanout", default_value_t = 2)]
95    pub fanout: u32,
96
97    /// Number of pre-seeded nodes (multiple build hosts). Round 0
98    /// will have this many parallel deploys instead of just one.
99    #[arg(long = "seeds", default_value_t = 1)]
100    pub seeds: u32,
101
102    /// Fraction of nodes pre-seeded (default 0.0). Overrides --seeds
103    /// when > 0.0.
104    #[arg(long = "seed-fraction", default_value_t = 0.0)]
105    pub seed_fraction: f64,
106
107    /// Closure size in MB (default 50)
108    #[arg(long = "closure-mb", default_value_t = 50)]
109    pub closure_mb: u64,
110
111    /// Bandwidth style: uniform | bimodal (default uniform)
112    #[arg(long = "bandwidth", default_value = "uniform")]
113    pub bandwidth: String,
114
115    /// Per-node uplink in bytes/sec (default unset = no contention)
116    #[arg(long = "uplinks")]
117    pub uplinks: Option<u64>,
118
119    /// RNG seed (default 0)
120    #[arg(long = "seed", default_value_t = 0)]
121    pub seed: u64,
122
123    /// Random per-edge failure rate (0.0 to 1.0), reproducible from
124    /// --failure-seed. Use to demo orphan re-routing.
125    #[arg(long = "failure-rate", default_value_t = 0.0)]
126    pub failure_rate: f64,
127
128    /// Seed for the random failure RNG. Same seed + same scenario
129    /// reproduces the exact same fail/succeed sequence.
130    #[arg(long = "failure-seed", default_value_t = 0)]
131    pub failure_seed: u64,
132
133    /// Disable live re-rendering (collect all events first, render once
134    /// at end).
135    #[arg(long = "no-watch")]
136    pub no_watch: bool,
137
138    /// Inject artificial wall-time delay between rounds, in milliseconds.
139    /// Demos / visual debugging only; does not affect reported durations.
140    #[arg(long = "per-round-delay")]
141    pub per_round_delay_ms: Option<u64>,
142}
143
144/// The `cast cascade` subcommands.
145#[derive(Debug, Subcommand)]
146pub enum CascadeCommands {
147    /// Replay a JSONL trace file and render
148    Tree(TreeArgs),
149
150    /// Verify a trace relayed peer-to-peer: tree shape, depth, and rounds
151    /// must come from a relay, not a host push. Prints the topology summary
152    /// as JSON on success; exits non-zero with a specific diagnostic on
153    /// failure.
154    Verify(VerifyArgs),
155
156    /// Run a fresh scenario and render
157    Live {
158        #[command(flatten)]
159        live: LiveArgs,
160
161        #[command(flatten)]
162        render: RenderArgs,
163    },
164}
165
166// ============================================================================
167// Dispatch
168// ============================================================================
169
170/// Run one `cast cascade` subcommand. Render output goes to stdout; errors
171/// bubble up so the caller can print `error: …` and exit non-zero.
172pub fn dispatch(command: CascadeCommands) -> Result<()> {
173    match command {
174        CascadeCommands::Tree(args) => {
175            print!("{}", render_trace_file(&args.trace_file, &args.render)?);
176        }
177        CascadeCommands::Verify(args) => {
178            println!(
179                "{}",
180                verify_trace_file(&args.trace_file, args.fanout, args.nodes)?
181            );
182        }
183        CascadeCommands::Live { live, render } => run_live(&live, &render)?,
184    }
185    Ok(())
186}
187
188// ============================================================================
189// Format resolution + rendering
190// ============================================================================
191
192fn resolve_format(render: &RenderArgs) -> Result<OutputFormat> {
193    let mut fmt = OutputFormat::parse(&render.format).map_err(|e| anyhow::anyhow!(e))?;
194    if let OutputFormat::Tree {
195        color,
196        max_depth: fmt_max_depth,
197    } = &mut fmt
198    {
199        let is_tty = io::stdout().is_terminal();
200        *color = is_tty && !render.no_color;
201        if let Some(d) = render.max_depth {
202            *fmt_max_depth = Some(d);
203        }
204    }
205    Ok(fmt)
206}
207
208/// Replay a JSONL trace file and render it. The `jsonl` format streams the
209/// events back one per line; every other format delegates to
210/// [`render_events`].
211pub fn render_trace_file(path: &str, render: &RenderArgs) -> Result<String> {
212    let file = File::open(path).with_context(|| format!("failed to open trace file: {path}"))?;
213    let mut events: Vec<CascadeEvent> = Vec::new();
214    for (i, line) in BufReader::new(file).lines().enumerate() {
215        let line = line.with_context(|| format!("failed to read line {}", i + 1))?;
216        if line.trim().is_empty() {
217            continue;
218        }
219        let ev: CascadeEvent = serde_json::from_str(&line)
220            .with_context(|| format!("invalid event JSON on line {}: {line}", i + 1))?;
221        events.push(ev);
222    }
223    if render.format == "jsonl" {
224        let mut out = String::new();
225        for ev in &events {
226            out.push_str(&serde_json::to_string(ev)?);
227            out.push('\n');
228        }
229        return Ok(out);
230    }
231    let fmt = resolve_format(render)?;
232    Ok(render_events(&events, &fmt))
233}
234
235// ============================================================================
236// Verification
237// ============================================================================
238
239/// Verify a trace relayed peer-to-peer and return the topology summary as
240/// JSON — the same `{"nodes", "rounds", "relay_depth", "relayed_nodes"}` the
241/// harness's `verify_cascade_relay` produced. Every failure — unreadable
242/// file, malformed line, truncated stream, star-shaped tree, wrong fleet
243/// size — exits non-zero with a specific diagnostic.
244pub fn verify_trace_file(path: &str, fanout: u32, nodes: Option<u32>) -> Result<String> {
245    if fanout < 1 {
246        anyhow::bail!("fanout must be at least 1, got {fanout}");
247    }
248    let stream = std::fs::read_to_string(path)
249        .with_context(|| format!("failed to open trace file: {path}"))?;
250    let topology = parse_cascade_events(&stream)
251        .map_err(|e| anyhow::anyhow!("cascade relay check failed: {e}"))?;
252    let strategy = find_strategy(&topology.strategy).ok_or_else(|| {
253        anyhow::anyhow!(
254            "unknown strategy '{}' in started event; available: {}",
255            topology.strategy,
256            strategy_catalog()
257        )
258    })?;
259    // The strategy's own round rule — its inherent expected_rounds via the
260    // registry — is what this trace is measured against.
261    let expected_rounds =
262        (strategy.rounds_rule)(topology.n_nodes as usize, topology.seeded.len(), fanout);
263    assert_relay_shape(&topology, strategy.canonical, expected_rounds, fanout)
264        .map_err(|e| anyhow::anyhow!("cascade relay check failed: {e}"))?;
265    // The relay assertion passes on any fleet size; the harness additionally
266    // pins how many nodes the run covered.
267    if let Some(count) = nodes {
268        if topology.n_nodes != count {
269            anyhow::bail!(
270                "cascade covered {} nodes, expected {}",
271                topology.n_nodes,
272                count
273            );
274        }
275    }
276    Ok(serde_json::json!({
277        "nodes": topology.n_nodes,
278        "rounds": topology.rounds,
279        "relay_depth": topology.depth(),
280        "relayed_nodes": topology.parent.len(),
281    })
282    .to_string())
283}
284
285// ============================================================================
286// Live scenarios
287// ============================================================================
288
289/// Run a fresh scenario and render it. Live tree re-rendering is the default
290/// when the format is `tree`, stdout is a TTY, and --no-watch wasn't passed;
291/// everything else batches.
292pub fn run_live(args: &LiveArgs, render: &RenderArgs) -> Result<()> {
293    run_live_with_order(args, render, FanoutOrder::Default)
294}
295
296/// [`run_live`] under an explicit fanout traversal order. Crate-internal
297/// routing for the binary's `--order` flag: the flag cannot live on the
298/// public [`LiveArgs`] — that struct is exhaustively constructible
299/// downstream, so a new field is a semver-major break — so the private
300/// CLI enum carries it and hands the parsed value here.
301pub(crate) fn run_live_with_order(
302    args: &LiveArgs,
303    render: &RenderArgs,
304    order: FanoutOrder,
305) -> Result<()> {
306    let bandwidth = match args.bandwidth.as_str() {
307        "bimodal" => BandwidthDistribution::Bimodal {
308            slow: 10 * 1024 * 1024,
309            fast: 1024 * 1024 * 1024,
310            fast_fraction: 0.3,
311        },
312        _ => BandwidthDistribution::Uniform(100 * 1024 * 1024),
313    };
314    let uplinks = args.uplinks.map(UplinkDistribution::Uniform);
315    let closure_bytes = args.closure_mb * 1024 * 1024;
316
317    // jsonl: stream live to stdout via JsonlWriter sink — events appear
318    // as they're emitted, no buffering through a Vec.
319    if render.format == "jsonl" {
320        let sink = JsonlWriter::new(Box::new(io::stdout()));
321        run_scenario(args, closure_bytes, bandwidth, uplinks, &sink, None, order)?;
322        return Ok(());
323    }
324
325    // Live tree re-render is the default when:
326    // - format is `tree` (the only format that has a tree to redraw)
327    // - stdout is a TTY (ANSI escapes need a real terminal)
328    // - --no-watch wasn't passed
329    // Otherwise fall through to batch: collect all events, render once.
330    let live_eligible = render.format == "tree" && io::stdout().is_terminal() && !args.no_watch;
331    if live_eligible {
332        let color = !render.no_color;
333        // Compose nom-style header lines. Identity always shows; scenario
334        // knobs only when non-default; failure / timing config only when
335        // explicitly enabled.
336        let mut header_lines: Vec<String> = Vec::new();
337
338        header_lines.push(format!(
339            "Strategy: {} || Nodes: {}",
340            args.strategy, args.nodes
341        ));
342
343        let mut scenario: Vec<String> = Vec::new();
344        if args.fanout != 2 {
345            scenario.push(format!("Fanout: {}", args.fanout));
346        }
347        if args.seeds != 1 {
348            scenario.push(format!("Seeds: {}", args.seeds));
349        }
350        if args.closure_mb != 50 {
351            scenario.push(format!("Closure: {}MB", args.closure_mb));
352        }
353        if args.bandwidth != "uniform" {
354            scenario.push(format!("Bandwidth: {}", args.bandwidth));
355        }
356        if let Some(uplink) = args.uplinks {
357            scenario.push(format!("Uplinks: {}B/s", uplink));
358        }
359        if !scenario.is_empty() {
360            header_lines.push(scenario.join(" || "));
361        }
362
363        let mut runtime: Vec<String> = Vec::new();
364        if args.failure_rate > 0.0 {
365            runtime.push(format!(
366                "Failures: {:.0}% (seed={})",
367                args.failure_rate * 100.0,
368                args.failure_seed
369            ));
370        }
371        if let Some(delay_ms) = args.per_round_delay_ms {
372            runtime.push(format!("Delay: {}ms/round", delay_ms));
373        }
374        if !runtime.is_empty() {
375            header_lines.push(runtime.join(" || "));
376        }
377
378        let renderer =
379            LiveTreeRenderer::new(color, render.max_depth).with_header_lines(header_lines);
380        let delay = args
381            .per_round_delay_ms
382            .map(std::time::Duration::from_millis);
383        run_scenario(
384            args,
385            closure_bytes,
386            bandwidth,
387            uplinks,
388            &renderer,
389            delay,
390            order,
391        )?;
392        // The renderer prints the final frame on `Finished`; nothing more
393        // for us to flush.
394        return Ok(());
395    }
396
397    // Batch path: accumulate, then delegate to render_events.
398    let collector = EventCollector::new();
399    run_scenario(
400        args,
401        closure_bytes,
402        bandwidth,
403        uplinks,
404        &collector,
405        None,
406        order,
407    )?;
408    let events = collector.events();
409    let fmt = resolve_format(render)?;
410    print!("{}", render_events(&events, &fmt));
411    Ok(())
412}
413
414/// Build a `Cascade` from `args` + run it through the given `EventSink`.
415/// All scenario wiring (nodes, seeded set, network, executor, strategy)
416/// lives here; the sink is the only consumer-specific bit.
417fn run_scenario<S: EventSink>(
418    args: &LiveArgs,
419    closure_bytes: u64,
420    bandwidth: BandwidthDistribution,
421    uplinks: Option<UplinkDistribution>,
422    sink: &S,
423    per_round_delay: Option<std::time::Duration>,
424    order: FanoutOrder,
425) -> Result<()> {
426    let n_nodes = args.nodes;
427
428    let mut alloc = NodeIdAlloc::new();
429    let nodes: Vec<CascadeNode> = (0..n_nodes)
430        .map(|_| {
431            let id = alloc.alloc();
432            CascadeNode::new(id, format!("user@host-{}", id.0))
433        })
434        .collect();
435
436    let seeded: std::collections::HashSet<consortium_nix::cascade::NodeId> =
437        if args.seed_fraction > 0.0 {
438            let mut rng = rng_from_seed(args.seed);
439            SeedDistribution::Random {
440                fraction: args.seed_fraction,
441            }
442            .sample(&mut rng, n_nodes)
443        } else {
444            // Multi-seed: NodeIds 0..seeds. Round 0 has `seeds` parallel
445            // deploys. Capped to n_nodes, minimum 1.
446            let count = args.seeds.min(n_nodes).max(1);
447            (0..count).map(consortium_nix::cascade::NodeId).collect()
448        };
449
450    let net: NetworkProfile = {
451        let mut rng = rng_from_seed(args.seed);
452        let mut profile = NetworkProfile::default();
453        bandwidth.populate(&mut rng, &mut profile, n_nodes);
454        if let Some(up) = &uplinks {
455            up.populate(&mut rng, &mut profile, n_nodes);
456        }
457        profile
458    };
459
460    let failures = if args.failure_rate > 0.0 {
461        FailureSchedule::Random {
462            fraction: args.failure_rate,
463            seed: args.failure_seed,
464        }
465    } else {
466        FailureSchedule::None
467    };
468    let base_exec = consortium_fanout_sim::DeterministicExecutor::new(closure_bytes, failures);
469
470    // If --per-round-delay set, wrap the deterministic executor in a
471    // DelayingExecutor so each dispatch() sleeps for the configured
472    // duration, making the spinner frame visible.
473    let delayed_exec = per_round_delay.map(|delay| DelayingExecutor {
474        inner: &base_exec as &dyn consortium_nix::cascade::RoundExecutor,
475        delay,
476    });
477    let exec: &dyn consortium_nix::cascade::RoundExecutor = delayed_exec
478        .as_ref()
479        .map(|d| d as &dyn consortium_nix::cascade::RoundExecutor)
480        .unwrap_or(&base_exec);
481
482    // One strategy, resolved through the registry — the same table the
483    // copy binary and the verifier read. Unknown names are errors that
484    // list what IS available; there is no silent fallback.
485    let mut strategy = parse_strategy(&args.strategy, args.fanout)?;
486    // bfs/dfs parameterize the fanout pairing itself: only the log2
487    // fanout consumes them. Any other combination is an error, never a
488    // silent no-op.
489    if order != FanoutOrder::Default {
490        if !strategy_takes_traversal_order(&args.strategy) {
491            let order_name = consortium_nix::cascade::FANOUT_ORDERS
492                .iter()
493                .find(|spec| spec.order == order)
494                .map(|spec| spec.canonical)
495                .unwrap_or("non-default");
496            anyhow::bail!(
497                "traversal order '{}' parameterizes the log2 fanout pairing; \
498                 strategy '{}' does not consume it",
499                order_name,
500                args.strategy
501            );
502        }
503        strategy = Box::new(OrderedFanout::new(order));
504    }
505    Cascade::new()
506        .nodes(nodes)
507        .seeded(seeded)
508        .network(net)
509        .strategy(strategy.as_ref())
510        .executor(exec)
511        .events(sink)
512        .run();
513    Ok(())
514}
515
516// ============================================================================
517// Tests
518// ============================================================================
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use clap::Parser;
524    use serde_json::json;
525
526    /// Minimal parser harness so the subcommand's own surface is testable
527    /// without constructing all of `cast`'s `Args`.
528    #[derive(Parser)]
529    struct TestCli {
530        #[command(subcommand)]
531        cmd: CascadeCommands,
532    }
533
534    fn parse(args: &[&str]) -> CascadeCommands {
535        let cli = TestCli::parse_from(args);
536        cli.cmd
537    }
538
539    /// The 15-node level-tree relay fixture (see consortium-nix
540    /// `cascade_tree::tests`), as JSONL text.
541    const RELAY_15: &str = concat!(
542        r#"{"kind":"started","n_nodes":15,"seeded":[0],"strategy":"level-tree","at":0}"#,
543        "\n",
544        r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":100000000}"#,
545        "\n",
546        r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":100000000}"#,
547        "\n",
548        r#"{"kind":"edge_completed","round":1,"src":1,"tgt":3,"duration":100000000}"#,
549        "\n",
550        r#"{"kind":"edge_completed","round":1,"src":1,"tgt":4,"duration":100000000}"#,
551        "\n",
552        r#"{"kind":"edge_completed","round":1,"src":2,"tgt":5,"duration":100000000}"#,
553        "\n",
554        r#"{"kind":"edge_completed","round":1,"src":2,"tgt":6,"duration":100000000}"#,
555        "\n",
556        r#"{"kind":"edge_completed","round":2,"src":3,"tgt":7,"duration":100000000}"#,
557        "\n",
558        r#"{"kind":"edge_completed","round":2,"src":3,"tgt":8,"duration":100000000}"#,
559        "\n",
560        r#"{"kind":"edge_completed","round":2,"src":4,"tgt":9,"duration":100000000}"#,
561        "\n",
562        r#"{"kind":"edge_completed","round":2,"src":4,"tgt":10,"duration":100000000}"#,
563        "\n",
564        r#"{"kind":"edge_completed","round":2,"src":5,"tgt":11,"duration":100000000}"#,
565        "\n",
566        r#"{"kind":"edge_completed","round":2,"src":5,"tgt":12,"duration":100000000}"#,
567        "\n",
568        r#"{"kind":"edge_completed","round":2,"src":6,"tgt":13,"duration":100000000}"#,
569        "\n",
570        r#"{"kind":"edge_completed","round":2,"src":6,"tgt":14,"duration":100000000}"#,
571        "\n",
572        r#"{"kind":"finished","converged":15,"failed":0,"rounds":3}"#,
573        "\n",
574    );
575
576    /// A 2-node trace in the documented wire shape.
577    const PAIR: &str = concat!(
578        r#"{"kind":"started","n_nodes":2,"seeded":[0],"strategy":"log2-fanout","at":0}"#,
579        "\n",
580        r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":100000000}"#,
581        "\n",
582        r#"{"kind":"finished","converged":2,"failed":0,"rounds":1}"#,
583        "\n",
584    );
585
586    fn write_trace(dir: &tempfile::TempDir, name: &str, contents: &str) -> String {
587        let path = dir.path().join(name);
588        std::fs::write(&path, contents).unwrap();
589        path.to_str().unwrap().to_string()
590    }
591
592    fn render_args(format: &str) -> RenderArgs {
593        RenderArgs {
594            format: format.to_string(),
595            max_depth: None,
596            no_color: true,
597        }
598    }
599
600    // ─── tree (replay) ──────────────────────────────────────────────────────
601
602    #[test]
603    fn tree_names_a_missing_file() {
604        let err = render_trace_file("/nonexistent/trace.jsonl", &render_args("tree")).unwrap_err();
605        assert!(
606            err.to_string().contains("failed to open trace file"),
607            "got: {err}"
608        );
609    }
610
611    #[test]
612    fn tree_renders_a_trace() {
613        let dir = tempfile::tempdir().unwrap();
614        let path = write_trace(&dir, "trace.jsonl", PAIR);
615        let out = render_trace_file(&path, &render_args("tree")).unwrap();
616        assert!(out.contains("n0"), "expected n0 in: {out}");
617        assert!(out.contains("✔"), "expected converged glyph in: {out}");
618    }
619
620    #[test]
621    fn tree_json_output_is_an_event_array() {
622        let dir = tempfile::tempdir().unwrap();
623        let path = write_trace(&dir, "trace.jsonl", PAIR);
624        let out = render_trace_file(&path, &render_args("json")).unwrap();
625        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
626        let arr = parsed.as_array().expect("JSON array of events");
627        assert!(arr.len() >= 2, "expected at least Started + Finished");
628        assert_eq!(arr[0]["kind"], "started");
629        assert_eq!(arr.last().unwrap()["kind"], "finished");
630    }
631
632    #[test]
633    fn tree_jsonl_passes_events_through() {
634        let dir = tempfile::tempdir().unwrap();
635        let path = write_trace(&dir, "trace.jsonl", PAIR);
636        let out = render_trace_file(&path, &render_args("jsonl")).unwrap();
637        let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
638        assert_eq!(lines.len(), 3, "one line per event: {out}");
639        for line in lines {
640            serde_json::from_str::<serde_json::Value>(line)
641                .unwrap_or_else(|e| panic!("invalid JSON line: {e}\n  {line}"));
642        }
643    }
644
645    #[test]
646    fn tree_names_invalid_event_json_by_line() {
647        let dir = tempfile::tempdir().unwrap();
648        let path = write_trace(&dir, "trace.jsonl", "{\"kind\":\"started\",\"n_nodes\":2,\"seeded\":[0],\"strategy\":\"log2-fanout\",\"at\":0}\ngarbage\n");
649        let err = render_trace_file(&path, &render_args("tree")).unwrap_err();
650        assert!(
651            err.to_string().contains("invalid event JSON on line 2"),
652            "got: {err}"
653        );
654    }
655
656    // ─── verify ─────────────────────────────────────────────────────────────
657
658    #[test]
659    fn verify_accepts_a_relayed_trace() {
660        let dir = tempfile::tempdir().unwrap();
661        let path = write_trace(&dir, "relay.jsonl", RELAY_15);
662        let out = verify_trace_file(&path, 2, None).unwrap();
663        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
664        assert_eq!(
665            parsed,
666            json!({
667                "nodes": 15,
668                "rounds": 3,
669                "relay_depth": 3,
670                "relayed_nodes": 14,
671            })
672        );
673    }
674
675    #[test]
676    fn verify_rejects_a_round_count_the_strategy_could_not_produce() {
677        let dir = tempfile::tempdir().unwrap();
678        let path = write_trace(
679            &dir,
680            "too-fast.jsonl",
681            &RELAY_15.replace(r#""rounds":3"#, r#""rounds":2"#),
682        );
683        let err = verify_trace_file(&path, 2, None).unwrap_err();
684        assert!(
685            err.to_string().contains("2 rounds"),
686            "the diagnostic must name the offending round count, got: {err}"
687        );
688    }
689
690    #[test]
691    fn verify_rejects_a_pull_only_trace() {
692        let stream = concat!(
693            r#"{"kind":"started","n_nodes":5,"seeded":[0],"strategy":"log2-fanout","at":0}"#,
694            "\n",
695            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":1}"#,
696            "\n",
697            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":1}"#,
698            "\n",
699            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":3,"duration":1}"#,
700            "\n",
701            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":4,"duration":1}"#,
702            "\n",
703            r#"{"kind":"finished","converged":5,"failed":0,"rounds":1}"#,
704            "\n",
705        );
706        let dir = tempfile::tempdir().unwrap();
707        let path = write_trace(&dir, "pull.jsonl", stream);
708        let err = verify_trace_file(&path, 2, None).unwrap_err();
709        assert!(
710            err.to_string()
711                .starts_with("cascade relay check failed: the payload was not relayed:"),
712            "got: {err}"
713        );
714    }
715
716    /// A swarm trace: legal, converged, tree-shaped — and a star. The
717    /// negative control: verify must reject it by the star rule, not
718    /// with "unknown strategy".
719    #[test]
720    fn verify_rejects_a_swarm_trace_by_the_star_rule() {
721        let stream = concat!(
722            r#"{"kind":"started","n_nodes":8,"seeded":[0],"strategy":"swarm","at":0}"#,
723            "\n",
724            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":1}"#,
725            "\n",
726            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":1}"#,
727            "\n",
728            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":3,"duration":1}"#,
729            "\n",
730            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":4,"duration":1}"#,
731            "\n",
732            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":5,"duration":1}"#,
733            "\n",
734            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":6,"duration":1}"#,
735            "\n",
736            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":7,"duration":1}"#,
737            "\n",
738            r#"{"kind":"finished","converged":8,"failed":0,"rounds":1}"#,
739            "\n",
740        );
741        let dir = tempfile::tempdir().unwrap();
742        let path = write_trace(&dir, "swarm.jsonl", stream);
743        let err = verify_trace_file(&path, 2, None).unwrap_err();
744        assert!(
745            err.to_string()
746                .starts_with("cascade relay check failed: the payload was not relayed:"),
747            "swarm must trip the star rule, got: {err}"
748        );
749    }
750
751    /// The live path resolves strategies through the same registry as
752    /// copy and verify: an unknown name is an error naming what IS
753    /// available, never a silent fallback.
754    #[test]
755    fn live_rejects_an_unknown_strategy() {
756        let args = LiveArgs {
757            nodes: 4,
758            strategy: "warp-drive".into(),
759            fanout: 2,
760            seeds: 1,
761            seed_fraction: 0.0,
762            closure_mb: 1,
763            bandwidth: "uniform".into(),
764            uplinks: None,
765            seed: 0,
766            failure_rate: 0.0,
767            failure_seed: 0,
768            no_watch: true,
769            per_round_delay_ms: None,
770        };
771        let err = run_live(&args, &render_args("json")).unwrap_err();
772        assert!(
773            err.to_string().contains("unknown strategy 'warp-drive'"),
774            "got: {err}"
775        );
776        assert!(
777            err.to_string().contains("level-tree"),
778            "the error must list what is available, got: {err}"
779        );
780    }
781
782    #[test]
783    fn verify_names_a_malformed_line() {
784        let dir = tempfile::tempdir().unwrap();
785        let path = write_trace(&dir, "bad.jsonl", "not json\n");
786        let err = verify_trace_file(&path, 2, None).unwrap_err();
787        assert!(
788            err.to_string()
789                .starts_with("cascade relay check failed: line 1 is not JSON:"),
790            "got: {err}"
791        );
792    }
793
794    #[test]
795    fn verify_requires_a_started_event() {
796        let stream = concat!(
797            r#"{"kind":"finished","converged":2,"failed":0,"rounds":1}"#,
798            "\n",
799        );
800        let dir = tempfile::tempdir().unwrap();
801        let path = write_trace(&dir, "nostart.jsonl", stream);
802        let err = verify_trace_file(&path, 2, None).unwrap_err();
803        assert!(
804            err.to_string()
805                .starts_with("cascade relay check failed: event stream has no started event"),
806            "got: {err}"
807        );
808    }
809
810    #[test]
811    fn verify_checks_the_requested_node_count() {
812        let dir = tempfile::tempdir().unwrap();
813        let path = write_trace(&dir, "relay.jsonl", RELAY_15);
814        let err = verify_trace_file(&path, 2, Some(64)).unwrap_err();
815        assert_eq!(err.to_string(), "cascade covered 15 nodes, expected 64");
816    }
817
818    #[test]
819    fn verify_rejects_a_zero_fanout() {
820        let dir = tempfile::tempdir().unwrap();
821        let path = write_trace(&dir, "relay.jsonl", RELAY_15);
822        let err = verify_trace_file(&path, 0, None).unwrap_err();
823        assert_eq!(err.to_string(), "fanout must be at least 1, got 0");
824    }
825
826    #[test]
827    fn verify_rejects_an_unknown_strategy() {
828        let stream = concat!(
829            r#"{"kind":"started","n_nodes":4,"seeded":[0],"strategy":"warp-drive","at":0}"#,
830            "\n",
831            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":1}"#,
832            "\n",
833            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":1}"#,
834            "\n",
835            r#"{"kind":"edge_completed","round":0,"src":0,"tgt":3,"duration":1}"#,
836            "\n",
837            r#"{"kind":"finished","converged":4,"failed":0,"rounds":1}"#,
838            "\n",
839        );
840        let dir = tempfile::tempdir().unwrap();
841        let path = write_trace(&dir, "warp.jsonl", stream);
842        let err = verify_trace_file(&path, 2, None).unwrap_err();
843        assert!(
844            err.to_string()
845                .contains("unknown strategy 'warp-drive' in started event"),
846            "got: {err}"
847        );
848    }
849
850    // ─── CLI surface ────────────────────────────────────────────────────────
851
852    #[test]
853    fn tree_parses_the_contract_flags() {
854        let CascadeCommands::Tree(args) = parse(&[
855            "cast",
856            "tree",
857            "t.jsonl",
858            "--format",
859            "json",
860            "--max-depth",
861            "3",
862            "--no-color",
863        ]) else {
864            panic!("expected Tree");
865        };
866        assert_eq!(args.trace_file, "t.jsonl");
867        assert_eq!(args.render.format, "json");
868        assert_eq!(args.render.max_depth, Some(3));
869        assert!(args.render.no_color);
870    }
871
872    #[test]
873    fn verify_requires_a_fanout() {
874        let missing = TestCli::try_parse_from(["cast", "verify", "t.jsonl"]);
875        assert!(missing.is_err(), "--fanout is required");
876        let CascadeCommands::Verify(args) = parse(&[
877            "cast", "verify", "t.jsonl", "--fanout", "2", "--nodes", "64",
878        ]) else {
879            panic!("expected Verify");
880        };
881        assert_eq!(args.fanout, 2);
882        assert_eq!(args.nodes, Some(64));
883        assert_eq!(args.trace_file, "t.jsonl");
884    }
885
886    #[test]
887    fn live_preserves_the_viz_flags() {
888        let CascadeCommands::Live { live: args, render } = parse(&[
889            "cast",
890            "live",
891            "-n",
892            "8",
893            "-s",
894            "steiner",
895            "--fanout",
896            "3",
897            "--seed",
898            "7",
899            "--no-watch",
900            "--format",
901            "jsonl",
902        ]) else {
903            panic!("expected Live");
904        };
905        assert_eq!(args.nodes, 8);
906        assert_eq!(args.strategy, "steiner");
907        assert_eq!(args.fanout, 3);
908        assert_eq!(args.seed, 7);
909        assert!(args.no_watch);
910        assert_eq!(render.format, "jsonl");
911    }
912}