Skip to main content

cu29_plan/
plan.rs

1use clap::Parser;
2use cu29_runtime::config::{
3    CuConfig, CuGraph, OnError, RT_POOL, SchedulingPolicy, ThreadPoolConfig,
4    read_configuration_with_features, read_multi_configuration_with_features,
5};
6use cu29_runtime::curuntime::{CuExecutionStep, CuExecutionUnit, CuStepPhase, CuTaskType};
7use cu29_runtime::planner::{
8    AssembledPlan, DEFAULT_COPPERLIST_COUNT, PlanEntity, PlanEntityKind, assemble_runtime_plan,
9    assemble_runtime_plan_from_step_keys, mission_graphs, step_key,
10};
11use cu29_traits::{CuError, CuResult};
12use serde::Deserialize;
13use std::collections::{BTreeMap, BTreeSet, HashMap};
14use std::fmt::Write as _;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19const LABEL_WIDTH: f64 = 108.0;
20const MARGIN: f64 = 28.0;
21const SECTION_GAP: f64 = 44.0;
22const SOURCE_COLOR: &str = "#ddefc7";
23const TASK_COLOR: &str = "#fde7c2";
24const SINK_COLOR: &str = "#cce0ff";
25const BRIDGE_COLOR: &str = "#f7d7e4";
26const BACKGROUND_GATEWAY_COLOR: &str = "#eeeafe";
27const WAVE_COLUMNS: usize = 8;
28const WAVE_CELL_WIDTH: f64 = 124.0;
29const WAVE_CELL_HEIGHT: f64 = 70.0;
30const WAVE_CELL_GAP: f64 = 12.0;
31const MAX_VISIBLE_COPPERLISTS: usize = 6;
32const PARALLEL_WIDTH: f64 =
33    LABEL_WIDTH + WAVE_COLUMNS as f64 * (WAVE_CELL_WIDTH + WAVE_CELL_GAP) - WAVE_CELL_GAP;
34
35#[derive(Parser, Debug)]
36#[command(
37    author,
38    version,
39    about = "Render Copper's generated per-CopperList process schedule"
40)]
41struct Args {
42    /// Copper RON configuration file.
43    config: PathBuf,
44    /// Render only this mission; omit to stack every mission.
45    #[arg(long)]
46    mission: Option<String>,
47    /// Comma-separated Cargo features used by conditional config fragments.
48    #[arg(long, value_delimiter = ',')]
49    features: Vec<String>,
50    /// List mission ids and exit.
51    #[arg(long)]
52    list_missions: bool,
53    /// Open the generated SVG in the default viewer.
54    #[arg(long)]
55    open: bool,
56    /// Output SVG path.
57    #[arg(long, default_value = "plan.svg")]
58    output: PathBuf,
59    /// Log-derived statistics produced by an application logreader.
60    #[arg(long)]
61    logstats: Option<PathBuf>,
62}
63
64fn main() {
65    if let Err(error) = run(Args::parse()) {
66        eprintln!("{error}");
67        std::process::exit(1);
68    }
69}
70
71fn run(args: Args) -> CuResult<()> {
72    let feature_refs = args.features.iter().map(String::as_str).collect::<Vec<_>>();
73    let config = load_single_config(&args.config, &feature_refs)?;
74    if args.list_missions {
75        for (mission, _) in mission_graphs(&config) {
76            println!("{mission}");
77        }
78        return Ok(());
79    }
80
81    let logstats = args
82        .logstats
83        .as_deref()
84        .map(load_observed_logstats)
85        .transpose()?;
86    let selected_mission = args
87        .mission
88        .as_deref()
89        .or_else(|| logstats.as_ref().and_then(|stats| stats.mission.as_deref()));
90    let sections = selected_graphs(&config, selected_mission)?;
91    if let Some(logstats) = &logstats {
92        validate_observed_logstats(logstats, &config, selected_mission);
93    }
94    let svg = render_document(&config, &sections, logstats.as_ref())?;
95    fs::write(&args.output, svg).map_err(|error| {
96        CuError::new_with_cause(
97            &format!("Failed to write plan SVG '{}'.", args.output.display()),
98            error,
99        )
100    })?;
101    if args.open {
102        open_svg(&args.output).map_err(|error| {
103            CuError::new_with_cause(
104                &format!("Failed to open plan SVG '{}'.", args.output.display()),
105                error,
106            )
107        })?;
108    }
109    Ok(())
110}
111
112fn load_single_config(path: &Path, features: &[&str]) -> CuResult<CuConfig> {
113    let filename = path.to_str().ok_or_else(|| {
114        CuError::from(format!(
115            "Config path '{}' is not valid UTF-8.",
116            path.display()
117        ))
118    })?;
119    if read_multi_configuration_with_features(filename, features).is_ok() {
120        return Err(CuError::from(format!(
121            "Multi-Copper scheduling plans are not supported yet: '{}'. Render each subsystem's copperconfig.ron separately.",
122            path.display()
123        )));
124    }
125    read_configuration_with_features(filename, features).map_err(|error| {
126        CuError::from(format!(
127            "Failed to read Copper config '{}': {error}",
128            path.display()
129        ))
130    })
131}
132
133fn selected_graphs<'a>(
134    config: &'a CuConfig,
135    requested: Option<&str>,
136) -> CuResult<Vec<(String, &'a CuGraph)>> {
137    let all = mission_graphs(config);
138    let Some(requested) = requested else {
139        return Ok(all);
140    };
141    all.into_iter()
142        .find(|(mission, _)| mission == requested)
143        .map(|section| vec![section])
144        .ok_or_else(|| {
145            let available = mission_graphs(config)
146                .into_iter()
147                .map(|(mission, _)| mission)
148                .collect::<Vec<_>>()
149                .join(", ");
150            CuError::from(format!(
151                "Mission '{requested}' not found. Available missions: {available}"
152            ))
153        })
154}
155
156fn render_document(
157    config: &CuConfig,
158    sections: &[(String, &CuGraph)],
159    logstats: Option<&ObservedLogStats>,
160) -> CuResult<String> {
161    let mut rendered = Vec::new();
162    let mut total_height = MARGIN;
163    for (mission, graph) in sections {
164        let plan = match config.planner_resolved_order(mission) {
165            Some(step_keys) => assemble_runtime_plan_from_step_keys(config, graph, step_keys),
166            None => assemble_runtime_plan(config, graph),
167        }
168        .map_err(|error| {
169            CuError::from(format!(
170                "Could not compute scheduling plan for mission '{mission}': {error}"
171            ))
172        })?;
173        let observed = logstats
174            .filter(|stats| mission_key(stats.mission.as_deref()) == mission_key(Some(mission)))
175            .and_then(|stats| stats.schedule.as_ref());
176        let section = render_mission(config, mission, &plan, observed)?;
177        total_height += section.height + SECTION_GAP;
178        rendered.push(section);
179    }
180    let width = MARGIN * 2.0 + PARALLEL_WIDTH;
181    let mut svg = String::new();
182    writeln!(
183        svg,
184        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{total_height}" viewBox="0 0 {width} {total_height}">"#
185    )
186    .unwrap();
187    svg.push_str(
188        r##"<defs>
189<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
190  <path d="M 0 0 L 10 5 L 0 10 z" fill="#667085"/>
191</marker>
192</defs>
193<style>
194text { font-family: 'Noto Sans', sans-serif; fill: #18212b; }
195.mono { font-family: 'Noto Sans Mono', monospace; }
196.title { font-size: 21px; font-weight: 700; }
197.subtitle { font-size: 14px; font-weight: 700; }
198.meta { font-size: 11px; fill: #4b5563; }
199.ordinal { font-size: 10px; font-weight: 700; fill: #667085; }
200.card-title { font-size: 12px; font-weight: 700; }
201.card-line { font-size: 9px; }
202.lane { font-size: 10px; font-weight: 700; fill: #475467; }
203.grid { stroke: #d7dce2; stroke-width: 1; stroke-dasharray: 3 3; }
204.flow { fill: none; stroke: #667085; stroke-width: 2; marker-end: url(#arrow); }
205.queue { fill: #f2f4f7; stroke: #98a2b3; stroke-width: 1; }
206.system { fill: #f8fafc; stroke: #667085; stroke-width: 1.5; }
207.blocking { font-size: 10px; fill: #344054; }
208.gateway { fill: #eeeafe; stroke: #7f56d9; stroke-width: 2; stroke-dasharray: 4 2; }
209.pool { fill: #f8fafc; stroke: #667085; stroke-width: 1.5; }
210.job { fill: #eef4ff; stroke: #528bcd; stroke-width: 1; }
211.job-flow { fill: none; stroke: #528bcd; stroke-width: 3; stroke-dasharray: 7 4; marker-end: url(#arrow); }
212.bg-trigger { fill: none; stroke: #7f56d9; stroke-width: 2; stroke-dasharray: 4 3; opacity: 0.55; marker-end: url(#arrow); }
213.observed-segment { cursor: help; }
214</style>
215<rect width="100%" height="100%" fill="#ffffff"/>
216"##,
217    );
218    let mut y = MARGIN;
219    for section in rendered {
220        writeln!(svg, r#"<g transform="translate({MARGIN},{y})">"#).unwrap();
221        svg.push_str(&section.svg);
222        svg.push_str("</g>\n");
223        y += section.height + SECTION_GAP;
224    }
225    svg.push_str("</svg>\n");
226    Ok(svg)
227}
228
229struct RenderedSection {
230    svg: String,
231    height: f64,
232}
233
234#[derive(Debug, Deserialize)]
235struct ObservedLogStats {
236    schema_version: u32,
237    config_signature: String,
238    mission: Option<String>,
239    #[serde(default)]
240    schedule: Option<ObservedSchedule>,
241}
242
243#[derive(Debug, Deserialize)]
244struct ObservedSchedule {
245    #[serde(default)]
246    stages: Vec<ObservedStage>,
247    #[serde(default)]
248    traces: Vec<ObservedTrace>,
249    #[serde(default)]
250    residual_before: ObservedDurationStats,
251    #[serde(default)]
252    resource_overlaps: Vec<ObservedResourceOverlap>,
253}
254
255#[derive(Debug, Deserialize)]
256struct ObservedStage {
257    origin: String,
258    samples: u64,
259    durations: ObservedDurationStats,
260}
261
262#[derive(Debug, Default, Deserialize)]
263struct ObservedDurationStats {
264    p50_ns: Option<u64>,
265    p95_ns: Option<u64>,
266    max_ns: Option<u64>,
267}
268
269#[derive(Debug, Deserialize)]
270struct ObservedTrace {
271    kind: String,
272    culist_id: u64,
273    #[serde(default)]
274    wall_span_ns: u64,
275    #[serde(default)]
276    excluded_intervals: u32,
277    residual_before_ns: Option<u64>,
278    intervals: Vec<ObservedInterval>,
279}
280
281#[derive(Debug, Deserialize)]
282struct ObservedInterval {
283    origin: String,
284    start_ns: u64,
285    end_ns: u64,
286}
287
288#[derive(Debug, Deserialize)]
289struct ObservedResourceOverlap {
290    resource: String,
291    left: String,
292    right: String,
293    occurrences: u64,
294}
295
296fn load_observed_logstats(path: &Path) -> CuResult<ObservedLogStats> {
297    let contents = fs::read_to_string(path).map_err(|error| {
298        CuError::new_with_cause(
299            &format!("Failed to read logstats '{}'.", path.display()),
300            error,
301        )
302    })?;
303    let stats: ObservedLogStats = serde_json::from_str(&contents).map_err(|error| {
304        CuError::new_with_cause(
305            &format!("Failed to parse logstats '{}'.", path.display()),
306            error,
307        )
308    })?;
309    if stats.schema_version < 2 {
310        eprintln!(
311            "Warning: logstats schema {} has no observed schedule data; regenerate it with the current logreader.",
312            stats.schema_version
313        );
314    } else if stats.schema_version != 2 {
315        eprintln!(
316            "Warning: logstats schema {} is newer than the supported schema 2.",
317            stats.schema_version
318        );
319    }
320    Ok(stats)
321}
322
323fn validate_observed_logstats(
324    stats: &ObservedLogStats,
325    config: &CuConfig,
326    requested_mission: Option<&str>,
327) {
328    if requested_mission.is_some()
329        && mission_key(requested_mission) != mission_key(stats.mission.as_deref())
330    {
331        eprintln!(
332            "Warning: logstats mission '{}' does not match requested mission '{}'.",
333            stats.mission.as_deref().unwrap_or("default"),
334            requested_mission.unwrap_or("default")
335        );
336    }
337    match build_logstats_signature(config, stats.mission.as_deref()) {
338        Ok(signature) if signature != stats.config_signature => eprintln!(
339            "Warning: logstats signature mismatch (expected {}, got {}).",
340            signature, stats.config_signature
341        ),
342        Err(error) => eprintln!("Warning: unable to validate logstats signature: {error}"),
343        _ => {}
344    }
345}
346
347fn build_logstats_signature(config: &CuConfig, mission: Option<&str>) -> CuResult<String> {
348    let graph = config.get_graph(mission)?;
349    let mut parts = vec![format!("mission={}", mission.unwrap_or("default"))];
350    let mut nodes = graph.get_all_nodes();
351    nodes.sort_by_key(|(_, node)| node.get_id());
352    for (_, node) in nodes {
353        parts.push(format!(
354            "node|{}|{}|{}",
355            node.get_id(),
356            node.get_type(),
357            match node.get_flavor() {
358                cu29_runtime::config::Flavor::Task => "task",
359                cu29_runtime::config::Flavor::Bridge => "bridge",
360            }
361        ));
362    }
363    let mut edges = graph
364        .edges()
365        .map(|connection| {
366            format!(
367                "edge|{}|{}|{}",
368                format_endpoint_for_signature(&connection.src, connection.src_channel.as_deref()),
369                format_endpoint_for_signature(&connection.dst, connection.dst_channel.as_deref()),
370                connection.msg
371            )
372        })
373        .collect::<Vec<_>>();
374    edges.sort();
375    parts.extend(edges);
376    Ok(format!(
377        "fnv1a64:{:016x}",
378        fnv1a64(parts.join("\n").as_bytes())
379    ))
380}
381
382fn format_endpoint_for_signature(node: &str, channel: Option<&str>) -> String {
383    channel.map_or_else(|| node.to_string(), |channel| format!("{node}/{channel}"))
384}
385
386fn fnv1a64(data: &[u8]) -> u64 {
387    let mut hash = 0xcbf29ce484222325u64;
388    for byte in data {
389        hash ^= u64::from(*byte);
390        hash = hash.wrapping_mul(0x100000001b3);
391    }
392    hash
393}
394
395fn mission_key(mission: Option<&str>) -> &str {
396    match mission {
397        Some(value) if value != "default" => value,
398        _ => "default",
399    }
400}
401
402fn render_mission(
403    config: &CuConfig,
404    mission: &str,
405    plan: &AssembledPlan,
406    observed: Option<&ObservedSchedule>,
407) -> CuResult<RenderedSection> {
408    let steps = plan
409        .execution
410        .steps
411        .iter()
412        .map(|unit| match unit {
413            CuExecutionUnit::Step(step) => Ok(step.as_ref()),
414            CuExecutionUnit::Loop(_) => Err(CuError::from(
415                "Nested execution loops are not supported by the plan visualizer.",
416            )),
417        })
418        .collect::<CuResult<Vec<_>>>()?;
419    let stages = steps
420        .iter()
421        .copied()
422        .filter(|step| step.phase != CuStepPhase::AnytimeRefine)
423        .collect::<Vec<_>>();
424    let message_slots = steps
425        .iter()
426        .filter_map(|step| step.output_msg_pack.as_ref())
427        .map(|pack| pack.culist_index as usize + 1)
428        .max()
429        .unwrap_or(0);
430    let in_flight_limit = config
431        .logging
432        .as_ref()
433        .and_then(|logging| logging.copperlist_count)
434        .unwrap_or(DEFAULT_COPPERLIST_COUNT);
435    let refine_totals = refine_totals(&steps);
436
437    let runtime = config.runtime.as_ref();
438    let rate = runtime
439        .and_then(|runtime| runtime.rate_target_hz)
440        .map(|rate| format!("{rate} Hz"))
441        .unwrap_or_else(|| "best effort".to_string());
442    let pools = runtime
443        .map(|runtime| runtime.thread_pools.as_slice())
444        .unwrap_or_default();
445
446    let mut svg = String::new();
447    writeln!(
448        svg,
449        r#"<text class="title" x="0" y="22">Mission: {}</text>"#,
450        xml(mission)
451    )
452    .unwrap();
453    writeln!(
454        svg,
455        r#"<text class="meta" x="0" y="43">{} serial steps · {} parallel stages · {} message slots per CopperList · {} CopperLists max in flight · rate target: {}</text>"#,
456        steps.len(),
457        stages.len(),
458        message_slots,
459        in_flight_limit,
460        xml(&rate)
461    )
462    .unwrap();
463
464    let mut y = 61.0;
465    if pools.is_empty() {
466        svg.push_str(r#"<text class="meta" x="0" y="61">Pools: none configured</text>"#);
467        y += 18.0;
468    } else {
469        for pool in pools {
470            writeln!(
471                svg,
472                r#"<text class="meta mono" x="0" y="{y}">pool {}</text>"#,
473                xml(&format_pool(pool))
474            )
475            .unwrap();
476            y += 16.0;
477        }
478    }
479
480    y += 14.0;
481    writeln!(
482        svg,
483        r#"<text class="subtitle" x="0" y="{y}">Serial projection · main CopperList executor · 1 worker</text>"#
484    )
485    .unwrap();
486    y += 17.0;
487    let serial = render_serial(config, mission, &steps, &plan.entities, &refine_totals, y);
488    svg.push_str(&serial.svg);
489    y += serial.height + 26.0;
490    writeln!(
491        svg,
492        r#"<text class="subtitle" x="0" y="{y}">Parallel projection · generated stage workers + background pools</text>"#
493    )
494    .unwrap();
495    y += 17.0;
496    let parallel = render_parallel(
497        config,
498        &stages,
499        &plan.entities,
500        &refine_totals,
501        in_flight_limit,
502        y,
503    );
504    svg.push_str(&parallel.svg);
505    y += parallel.height;
506
507    if let Some(observed) = observed {
508        y += 28.0;
509        let section = render_observed(config, &stages, &plan.entities, observed, y);
510        svg.push_str(&section.svg);
511        y += section.height;
512    }
513
514    Ok(RenderedSection {
515        svg,
516        height: y + 10.0,
517    })
518}
519
520fn refine_totals(steps: &[&CuExecutionStep]) -> HashMap<u32, u32> {
521    let mut totals = HashMap::new();
522    for step in steps {
523        if step.phase == CuStepPhase::AnytimeRefine {
524            *totals.entry(step.node_id).or_insert(0) += 1;
525        }
526    }
527    totals
528}
529
530fn render_serial(
531    config: &CuConfig,
532    mission: &str,
533    steps: &[&CuExecutionStep],
534    entities: &[PlanEntity],
535    refine_totals: &HashMap<u32, u32>,
536    top: f64,
537) -> RenderedSection {
538    let mut svg = String::new();
539    let background_pools = background_pool_views(config, steps);
540    let cycles = if background_pools.is_empty() { 1 } else { 2 };
541    let column_count = (steps.len() * cycles).max(1);
542    let mut y = top;
543
544    for block_start in (0..column_count).step_by(WAVE_COLUMNS) {
545        let block_end = (block_start + WAVE_COLUMNS).min(column_count);
546        let row_y = y + 19.0;
547        writeln!(
548            svg,
549            r#"<text class="lane mono" x="0" y="{}">MAIN w1</text>"#,
550            row_y + WAVE_CELL_HEIGHT / 2.0 + 4.0
551        )
552        .unwrap();
553
554        for column in block_start..block_end {
555            let local_column = column - block_start;
556            let x = LABEL_WIDTH + local_column as f64 * (WAVE_CELL_WIDTH + WAVE_CELL_GAP);
557            let cycle = column / steps.len().max(1);
558            let step_index = column % steps.len().max(1);
559            let Some(step) = steps.get(step_index).copied() else {
560                continue;
561            };
562            let entity = &entities[step.node_id as usize];
563            let is_background = step.node.is_background();
564            let (_, entity_fill) = entity_style(entity, step);
565            let fill = if is_background {
566                BACKGROUND_GATEWAY_COLOR
567            } else {
568                entity_fill
569            };
570            let cl_label = if cycle == 0 {
571                "CL n".to_string()
572            } else {
573                format!("CL n+{cycle}")
574            };
575            let refine_ordinal = if step.phase == CuStepPhase::AnytimeRefine {
576                Some(
577                    steps[..=step_index]
578                        .iter()
579                        .filter(|candidate| {
580                            candidate.node_id == step.node_id
581                                && candidate.phase == CuStepPhase::AnytimeRefine
582                        })
583                        .count() as u32,
584                )
585            } else {
586                None
587            };
588            let phase = match step.phase {
589                CuStepPhase::Whole if is_background => "POLL / DISPATCH?".to_string(),
590                CuStepPhase::Whole => "whole".to_string(),
591                CuStepPhase::AnytimeBase => "base".to_string(),
592                CuStepPhase::AnytimeRefine => format!(
593                    "refine {}/{}",
594                    refine_ordinal.unwrap_or_default(),
595                    refine_totals.get(&step.node_id).copied().unwrap_or(0)
596                ),
597            };
598            let resources = entity_resources(config, entity, step);
599            let footer = if is_background {
600                format!("→ pool {}", step.node.background_pool())
601            } else if resources.is_empty() {
602                "res —".to_string()
603            } else {
604                format!("res {}", resources.join(", "))
605            };
606            let key = step_key(mission, entity, step.phase, refine_ordinal);
607            let tooltip = format!(
608                "{} · main executor worker 1\nStep {}: {}\nphase: {}\nresources: {}\nstable key: {}",
609                cl_label,
610                step_index + 1,
611                entity.label,
612                phase,
613                if resources.is_empty() {
614                    "—".to_string()
615                } else {
616                    resources.join(", ")
617                },
618                key
619            );
620            writeln!(
621                svg,
622                r##"<text class="ordinal mono" x="{x}" y="{}">{} · S{:02}</text><g data-step-key="{}" data-serial-column="{}"{}><title>{}</title><rect x="{x}" y="{row_y}" width="{WAVE_CELL_WIDTH}" height="{WAVE_CELL_HEIGHT}" rx="6" fill="{fill}" stroke="{}"{}/><text class="ordinal mono" x="{}" y="{}">MAIN w1 · {}</text><text class="card-title" x="{}" y="{}">{}</text><text class="card-line" x="{}" y="{}">{}</text><text class="card-line mono" x="{}" y="{}">{}</text></g>"##,
623                row_y - 7.0,
624                xml(&cl_label),
625                step_index + 1,
626                xml_attr(&key),
627                column + 1,
628                if is_background { r#" data-background-gateway="true""# } else { "" },
629                xml(&tooltip),
630                if is_background { "#7f56d9" } else { "#98a2b3" },
631                if is_background { r#" stroke-width="2" stroke-dasharray="4 2""# } else { "" },
632                x + 7.0,
633                row_y + 13.0,
634                if is_background { "GATEWAY" } else { "SERIAL" },
635                x + 7.0,
636                row_y + 29.0,
637                xml(&truncate(&entity.label, 16)),
638                x + 7.0,
639                row_y + 45.0,
640                xml(&truncate(&phase, 18)),
641                x + 7.0,
642                row_y + 62.0,
643                xml(&truncate(&footer, 18)),
644            )
645            .unwrap();
646
647            if column + 1 < block_end {
648                writeln!(
649                    svg,
650                    r#"<line class="flow" x1="{}" y1="{}" x2="{}" y2="{}"/>"#,
651                    x + WAVE_CELL_WIDTH,
652                    row_y + WAVE_CELL_HEIGHT / 2.0,
653                    x + WAVE_CELL_WIDTH + WAVE_CELL_GAP,
654                    row_y + WAVE_CELL_HEIGHT / 2.0,
655                )
656                .unwrap();
657            }
658        }
659
660        let pools_top = row_y + WAVE_CELL_HEIGHT + WAVE_CELL_GAP;
661        let mut triggers = Vec::new();
662        for column in block_start..block_end {
663            let step_index = column % steps.len().max(1);
664            let Some(step) = steps.get(step_index).copied() else {
665                continue;
666            };
667            if !step.node.is_background() {
668                continue;
669            }
670            let cycle = column / steps.len().max(1);
671            triggers.push(BackgroundTrigger {
672                column,
673                stage_index: step_index,
674                step,
675                entity: &entities[step.node_id as usize],
676                source_y: row_y + WAVE_CELL_HEIGHT,
677                cl_label: if cycle == 0 {
678                    "CL n".to_string()
679                } else {
680                    format!("CL n+{cycle}")
681                },
682            });
683        }
684        let background = render_background_lanes(
685            steps,
686            entities,
687            &background_pools,
688            &triggers,
689            block_start,
690            block_end,
691            pools_top,
692        );
693        svg.push_str(&background.svg);
694        y = pools_top + background.height + 26.0;
695    }
696
697    RenderedSection {
698        svg,
699        height: y - top,
700    }
701}
702
703fn render_parallel(
704    config: &CuConfig,
705    stages: &[&CuExecutionStep],
706    entities: &[PlanEntity],
707    refine_totals: &HashMap<u32, u32>,
708    in_flight_limit: usize,
709    top: f64,
710) -> RenderedSection {
711    let rt_pool = config
712        .runtime
713        .as_ref()
714        .and_then(|runtime| runtime.thread_pools.iter().find(|pool| pool.id == RT_POOL));
715    render_staggered_wavefront(
716        config,
717        stages,
718        entities,
719        refine_totals,
720        in_flight_limit,
721        rt_pool,
722        top,
723    )
724}
725
726struct ObservedStageView {
727    label: String,
728    resources: Vec<String>,
729}
730
731fn render_observed(
732    config: &CuConfig,
733    stages: &[&CuExecutionStep],
734    entities: &[PlanEntity],
735    observed: &ObservedSchedule,
736    top: f64,
737) -> RenderedSection {
738    let mut svg = String::new();
739    let stage_views = stages
740        .iter()
741        .map(|step| {
742            let entity = &entities[step.node_id as usize];
743            let origin = observed_origin(entity);
744            let resources = entity_resource_bindings(config, entity, step)
745                .into_iter()
746                .map(|(_, target)| target)
747                .collect();
748            (
749                origin,
750                ObservedStageView {
751                    label: entity.label.clone(),
752                    resources,
753                },
754            )
755        })
756        .collect::<HashMap<_, _>>();
757    let aggregates = observed
758        .stages
759        .iter()
760        .map(|stage| (stage.origin.as_str(), stage))
761        .collect::<HashMap<_, _>>();
762
763    writeln!(
764        svg,
765        r#"<text class="subtitle" x="0" y="{}">Observed execution · proportional recorded process intervals</text>"#,
766        top + 14.0
767    )
768    .unwrap();
769    writeln!(
770        svg,
771        r#"<text class="meta" x="0" y="{}">Recorded task durations are packed back-to-back; hover any segment for its task and timing details.</text>"#,
772        top + 33.0
773    )
774    .unwrap();
775
776    let mut y = top + 55.0;
777    if observed.traces.is_empty() {
778        writeln!(
779            svg,
780            r##"<rect x="0" y="{y}" width="{PARALLEL_WIDTH}" height="44" rx="8" fill="#fff7e6" stroke="#f0a04b"/><text class="blocking" x="12" y="{}">No complete process intervals were found in the CopperList log.</text>"##,
781            y + 27.0
782        )
783        .unwrap();
784        y += 44.0;
785    }
786
787    for trace in &observed.traces {
788        let timeline_width = PARALLEL_WIDTH - LABEL_WIDTH;
789        let collisions = observed_trace_collisions(trace, &stage_views);
790        let active_ns: u64 = trace
791            .intervals
792            .iter()
793            .map(|interval| interval.end_ns.saturating_sub(interval.start_ns))
794            .sum();
795        let trace_start = trace
796            .intervals
797            .iter()
798            .map(|interval| interval.start_ns)
799            .min()
800            .unwrap_or(0);
801        let trace_end = trace
802            .intervals
803            .iter()
804            .map(|interval| interval.end_ns)
805            .max()
806            .unwrap_or(trace_start);
807        let wall_span_ns = if trace.wall_span_ns == 0 {
808            trace_end.saturating_sub(trace_start)
809        } else {
810            trace.wall_span_ns
811        };
812        let excluded_note = if trace.excluded_intervals == 0 {
813            String::new()
814        } else {
815            format!(
816                " · {} carried-forward slot(s) excluded",
817                trace.excluded_intervals
818            )
819        };
820
821        writeln!(
822            svg,
823            r#"<text class="card-title" x="0" y="{y}">{} · CL #{} · {} active · {} wall{}</text>"#,
824            xml(&trace.kind.to_uppercase()),
825            trace.culist_id,
826            xml(&format_duration_ns(active_ns as f64)),
827            xml(&format_duration_ns(wall_span_ns as f64)),
828            xml(&excluded_note),
829        )
830        .unwrap();
831        y += 17.0;
832        for tick in 0..=4 {
833            let x = LABEL_WIDTH + timeline_width * tick as f64 / 4.0;
834            let elapsed = active_ns as f64 * tick as f64 / 4.0;
835            let (label_x, anchor) = if tick == 4 {
836                (x - 3.0, "end")
837            } else {
838                (x + 3.0, "start")
839            };
840            writeln!(
841                svg,
842                r#"<line class="grid" x1="{x}" y1="{y}" x2="{x}" y2="{}"/><text class="ordinal mono" text-anchor="{anchor}" x="{label_x}" y="{}">{}</text>"#,
843                y + 48.0,
844                y + 10.0,
845                xml(&format_duration_ns(elapsed)),
846            )
847            .unwrap();
848        }
849        y += 15.0;
850
851        let minimum_width = 5.0;
852        let minimum_total = minimum_width * trace.intervals.len() as f64;
853        let proportional_width = (timeline_width - minimum_total).max(0.0);
854        let mut x = LABEL_WIDTH;
855        writeln!(
856            svg,
857            r#"<text class="lane mono" x="0" y="{}">process</text>"#,
858            y + 21.0
859        )
860        .unwrap();
861        for interval in &trace.intervals {
862            let view = stage_views.get(&interval.origin);
863            let label = view
864                .map(|view| view.label.as_str())
865                .unwrap_or(interval.origin.as_str());
866            let fill = observed_interval_fill(&interval.origin);
867            let duration_ns = interval.end_ns.saturating_sub(interval.start_ns);
868            let width = if active_ns == 0 {
869                timeline_width / trace.intervals.len().max(1) as f64
870            } else {
871                minimum_width + proportional_width * duration_ns as f64 / active_ns as f64
872            };
873            let aggregate = aggregates.get(interval.origin.as_str());
874            let collision_resources = collisions.get(&interval.origin);
875            let tooltip = format!(
876                "{}\norigin: {} · CL #{}\nrecorded process: {}\noriginal start: +{}\nest. p50: {} · est. p95: {} · max: {} · samples: {}\nresources: {}{}",
877                label,
878                interval.origin,
879                trace.culist_id,
880                format_duration_ns(duration_ns as f64),
881                format_duration_ns(interval.start_ns.saturating_sub(trace_start) as f64),
882                aggregate
883                    .and_then(|stage| stage.durations.p50_ns)
884                    .map_or_else(
885                        || "n/a".to_string(),
886                        |value| format_duration_ns(value as f64)
887                    ),
888                aggregate
889                    .and_then(|stage| stage.durations.p95_ns)
890                    .map_or_else(
891                        || "n/a".to_string(),
892                        |value| format_duration_ns(value as f64)
893                    ),
894                aggregate
895                    .and_then(|stage| stage.durations.max_ns)
896                    .map_or_else(
897                        || "n/a".to_string(),
898                        |value| format_duration_ns(value as f64)
899                    ),
900                aggregate.map_or(0, |stage| stage.samples),
901                view.filter(|view| !view.resources.is_empty())
902                    .map_or_else(|| "—".to_string(), |view| view.resources.join(", ")),
903                collision_resources.map_or_else(String::new, |resources| format!(
904                    "\nobserved overlap risk: {}",
905                    resources.join(", ")
906                )),
907            );
908            let inside_label = if width >= 68.0 {
909                format!(
910                    r#"<text class="ordinal" x="{}" y="{}">{}</text>"#,
911                    x + 5.0,
912                    y + 19.0,
913                    xml(&truncate(label, 10))
914                )
915            } else {
916                String::new()
917            };
918            writeln!(
919                svg,
920                r##"<g class="observed-segment" data-observed-origin="{}"><title>{}</title><rect x="{x}" y="{y}" width="{width}" height="30" rx="3" fill="{fill}" stroke="{}" stroke-width="{}"/>{inside_label}</g>"##,
921                xml_attr(&interval.origin),
922                xml(&tooltip),
923                if collision_resources.is_some() { "#d92d20" } else { "#475467" },
924                if collision_resources.is_some() { 2 } else { 1 },
925            )
926            .unwrap();
927            x += width;
928        }
929        y += 39.0;
930        if let Some(residual) = trace.residual_before_ns {
931            writeln!(
932                svg,
933                r##"<text class="lane mono" x="0" y="{}">between CLs</text><rect x="{LABEL_WIDTH}" y="{y}" width="120" height="13" rx="3" fill="#d7dce2"/><text class="meta mono" x="{}" y="{}">{} before this CL · unclassified, not on the process scale</text>"##,
934                y + 11.0,
935                LABEL_WIDTH + 127.0,
936                y + 11.0,
937                xml(&format_duration_ns(residual as f64)),
938            )
939            .unwrap();
940            y += 24.0;
941        }
942        y += 18.0;
943    }
944
945    writeln!(
946        svg,
947        r#"<text class="subtitle" x="0" y="{y}">Observed declared-resource overlap across the full log</text>"#
948    )
949    .unwrap();
950    y += 14.0;
951    if observed.resource_overlaps.is_empty() {
952        writeln!(
953            svg,
954            r##"<rect x="0" y="{y}" width="{PARALLEL_WIDTH}" height="38" rx="8" fill="#ecfdf3" stroke="#75c58f"/><text class="blocking" x="12" y="{}">No simultaneous recorded process intervals shared a declared resource target.</text>"##,
955            y + 24.0
956        )
957        .unwrap();
958        y += 38.0;
959    } else {
960        let panel_height = 34.0 + observed.resource_overlaps.len() as f64 * 18.0;
961        writeln!(
962            svg,
963            r##"<rect x="0" y="{y}" width="{PARALLEL_WIDTH}" height="{panel_height}" rx="8" fill="#fff1f0" stroke="#d92d20"/><text class="blocking" x="12" y="{}">Overlap is a contention risk signal, not proof that either task waited.</text>"##,
964            y + 20.0
965        )
966        .unwrap();
967        for (index, overlap) in observed.resource_overlaps.iter().enumerate() {
968            writeln!(
969                svg,
970                r#"<text class="card-line mono" x="12" y="{}">⚠ {}: {} ↔ {} · {} overlap(s)</text>"#,
971                y + 40.0 + index as f64 * 18.0,
972                xml(&truncate(&overlap.resource, 28)),
973                xml(&truncate(&overlap.left, 28)),
974                xml(&truncate(&overlap.right, 28)),
975                overlap.occurrences,
976            )
977            .unwrap();
978        }
979        y += panel_height;
980    }
981
982    y += 18.0;
983    let residual_p50 = observed.residual_before.p50_ns.map_or_else(
984        || "n/a".to_string(),
985        |value| format_duration_ns(value as f64),
986    );
987    let residual_p95 = observed.residual_before.p95_ns.map_or_else(
988        || "n/a".to_string(),
989        |value| format_duration_ns(value as f64),
990    );
991    writeln!(
992        svg,
993        r##"<rect x="0" y="{y}" width="{PARALLEL_WIDTH}" height="58" rx="8" fill="#f8fafc" stroke="#98a2b3"/><text class="blocking" x="12" y="{}">Recorded process intervals exclude queueing, keyframes, logging overhead, and serialization.</text><text class="meta" x="12" y="{}">Residual gaps may include serialization, rate limiting, scheduling, and I/O; est. p50 {}, est. p95 {}. Anytime bars span base through final refine.</text>"##,
994        y + 22.0,
995        y + 43.0,
996        xml(&residual_p50),
997        xml(&residual_p95),
998    )
999    .unwrap();
1000    y += 58.0;
1001
1002    RenderedSection {
1003        svg,
1004        height: y - top,
1005    }
1006}
1007
1008fn observed_origin(entity: &PlanEntity) -> String {
1009    match entity.kind {
1010        PlanEntityKind::Task { .. } => entity.label.clone(),
1011        PlanEntityKind::BridgeRx { .. } | PlanEntityKind::BridgeTx { .. } => {
1012            format!("bridge::{}", entity.label)
1013        }
1014    }
1015}
1016
1017fn observed_interval_fill(origin: &str) -> &'static str {
1018    const COLORS: [&str; 12] = [
1019        "#bde3ff", "#ffd6a5", "#cdeac0", "#e2cfea", "#ffcad4", "#b8f2e6", "#f1e3a4", "#cddafd",
1020        "#d8f3dc", "#f7c6c7", "#c9e4de", "#dec9e9",
1021    ];
1022    COLORS[(fnv1a64(origin.as_bytes()) as usize) % COLORS.len()]
1023}
1024
1025fn observed_trace_collisions(
1026    trace: &ObservedTrace,
1027    stages: &HashMap<String, ObservedStageView>,
1028) -> HashMap<String, Vec<String>> {
1029    let mut collisions = HashMap::<String, Vec<String>>::new();
1030    for (index, left) in trace.intervals.iter().enumerate() {
1031        for right in &trace.intervals[..index] {
1032            if left.start_ns >= right.end_ns || right.start_ns >= left.end_ns {
1033                continue;
1034            }
1035            let (Some(left_stage), Some(right_stage)) =
1036                (stages.get(&left.origin), stages.get(&right.origin))
1037            else {
1038                continue;
1039            };
1040            for resource in &left_stage.resources {
1041                if right_stage.resources.contains(resource) {
1042                    for origin in [&left.origin, &right.origin] {
1043                        let targets = collisions.entry(origin.clone()).or_default();
1044                        if !targets.contains(resource) {
1045                            targets.push(resource.clone());
1046                        }
1047                    }
1048                }
1049            }
1050        }
1051    }
1052    collisions
1053}
1054
1055fn render_staggered_wavefront(
1056    config: &CuConfig,
1057    stages: &[&CuExecutionStep],
1058    entities: &[PlanEntity],
1059    refine_totals: &HashMap<u32, u32>,
1060    in_flight_limit: usize,
1061    rt_pool: Option<&ThreadPoolConfig>,
1062    top: f64,
1063) -> RenderedSection {
1064    let mut svg = String::new();
1065    let background_pools = background_pool_views(config, stages);
1066    let active_lanes = in_flight_limit.min(stages.len()).max(1);
1067    let visible_lanes = active_lanes.min(MAX_VISIBLE_COPPERLISTS);
1068    let formation_count = stages
1069        .len()
1070        .saturating_add(visible_lanes)
1071        .saturating_sub(1)
1072        .max(1);
1073
1074    writeln!(
1075        svg,
1076        r#"<text class="subtitle" x="0" y="{}">Staggered concurrency formations · cards in one column can run together</text>"#,
1077        top + 14.0
1078    )
1079    .unwrap();
1080    writeln!(
1081        svg,
1082        r#"<text class="meta" x="0" y="{}">Columns can run together. Purple cells are RT poll/dispatch gateways; arrows feed the aligned background-pool worker lanes.</text>"#,
1083        top + 33.0
1084    )
1085    .unwrap();
1086    let depth_note = if active_lanes > visible_lanes {
1087        format!(
1088            "Configured in-flight depth: {in_flight_limit}. Showing CL n through CL n+{} ({} of {active_lanes} active pipeline positions); the overlap audit still covers every stage.",
1089            visible_lanes - 1,
1090            visible_lanes,
1091        )
1092    } else if in_flight_limit < MAX_VISIBLE_COPPERLISTS {
1093        format!(
1094            "Configured in-flight depth: {in_flight_limit}. CL n+{in_flight_limit} cannot be admitted until an older CopperList is committed and recycled."
1095        )
1096    } else {
1097        format!(
1098            "Configured in-flight depth: {in_flight_limit}. The matrix shows every concurrently active worker lane allowed by that bound."
1099        )
1100    };
1101    writeln!(
1102        svg,
1103        r#"<text class="meta mono" x="0" y="{}">{}</text>"#,
1104        top + 51.0,
1105        xml(&depth_note)
1106    )
1107    .unwrap();
1108
1109    let mut y = top + 76.0;
1110    for block_start in (0..formation_count).step_by(WAVE_COLUMNS) {
1111        let block_end = (block_start + WAVE_COLUMNS).min(formation_count);
1112        for formation in block_start..block_end {
1113            let column = formation - block_start;
1114            let x = LABEL_WIDTH + column as f64 * (WAVE_CELL_WIDTH + WAVE_CELL_GAP);
1115            let collisions =
1116                formation_collisions(config, stages, entities, visible_lanes, formation);
1117            writeln!(
1118                svg,
1119                r#"<text class="ordinal mono" x="{x}" y="{y}"{}>FORMATION {:02}{}</text>"#,
1120                if collisions.is_empty() {
1121                    ""
1122                } else {
1123                    r##" fill="#b42318""##
1124                },
1125                formation + 1,
1126                if collisions.is_empty() { "" } else { " ⚠" }
1127            )
1128            .unwrap();
1129        }
1130
1131        let rows_top = y + 11.0;
1132        for lane in 0..visible_lanes {
1133            let row_y = rows_top + lane as f64 * (WAVE_CELL_HEIGHT + WAVE_CELL_GAP);
1134            writeln!(
1135                svg,
1136                r#"<text class="lane mono" x="0" y="{}">CL n{}</text>"#,
1137                row_y + WAVE_CELL_HEIGHT / 2.0 + 4.0,
1138                if lane == 0 {
1139                    String::new()
1140                } else {
1141                    format!("+{lane}")
1142                }
1143            )
1144            .unwrap();
1145
1146            // Connect consecutive occupied cells before drawing the cards so
1147            // each CopperList's diagonal progression remains visually explicit.
1148            for formation in (block_start + 1)..block_end {
1149                let Some(previous_stage) = (formation - 1).checked_sub(lane) else {
1150                    continue;
1151                };
1152                let Some(current_stage) = formation.checked_sub(lane) else {
1153                    continue;
1154                };
1155                if previous_stage >= stages.len() || current_stage >= stages.len() {
1156                    continue;
1157                }
1158                let column = formation - block_start;
1159                let previous_x =
1160                    LABEL_WIDTH + (column - 1) as f64 * (WAVE_CELL_WIDTH + WAVE_CELL_GAP);
1161                let current_x = LABEL_WIDTH + column as f64 * (WAVE_CELL_WIDTH + WAVE_CELL_GAP);
1162                writeln!(
1163                    svg,
1164                    r#"<line class="flow" x1="{}" y1="{}" x2="{current_x}" y2="{}"/>"#,
1165                    previous_x + WAVE_CELL_WIDTH,
1166                    row_y + WAVE_CELL_HEIGHT / 2.0,
1167                    row_y + WAVE_CELL_HEIGHT / 2.0,
1168                )
1169                .unwrap();
1170            }
1171
1172            for formation in block_start..block_end {
1173                let column = formation - block_start;
1174                let x = LABEL_WIDTH + column as f64 * (WAVE_CELL_WIDTH + WAVE_CELL_GAP);
1175                let Some(stage_index) = formation.checked_sub(lane) else {
1176                    writeln!(
1177                        svg,
1178                        r##"<rect x="{x}" y="{row_y}" width="{WAVE_CELL_WIDTH}" height="{WAVE_CELL_HEIGHT}" rx="6" fill="#fafafa" stroke="#e4e7ec" stroke-dasharray="3 3"/>"##
1179                    )
1180                    .unwrap();
1181                    continue;
1182                };
1183                let Some(step) = stages.get(stage_index) else {
1184                    writeln!(
1185                        svg,
1186                        r##"<rect x="{x}" y="{row_y}" width="{WAVE_CELL_WIDTH}" height="{WAVE_CELL_HEIGHT}" rx="6" fill="#fafafa" stroke="#e4e7ec" stroke-dasharray="3 3"/>"##
1187                    )
1188                    .unwrap();
1189                    continue;
1190                };
1191                let entity = &entities[step.node_id as usize];
1192                let (_, entity_fill) = entity_style(entity, step);
1193                let is_background = step.node.is_background();
1194                let fill = if is_background {
1195                    BACKGROUND_GATEWAY_COLOR
1196                } else {
1197                    entity_fill
1198                };
1199                let collisions =
1200                    formation_collisions(config, stages, entities, visible_lanes, formation);
1201                let collision_targets = collisions.get(&lane).cloned().unwrap_or_default();
1202                let stroke = if collision_targets.is_empty() {
1203                    "#98a2b3"
1204                } else {
1205                    "#d92d20"
1206                };
1207                let stroke_width = if collision_targets.is_empty() {
1208                    1.0
1209                } else {
1210                    3.0
1211                };
1212                let placement = worker_placement(rt_pool, stage_index);
1213                let resources = entity_resource_bindings(config, entity, step)
1214                    .into_iter()
1215                    .map(|(_, target)| target)
1216                    .collect::<Vec<_>>();
1217                let phase = if step.phase == CuStepPhase::AnytimeBase {
1218                    format!(
1219                        "base + {} refine",
1220                        refine_totals.get(&step.node_id).copied().unwrap_or(0)
1221                    )
1222                } else if is_background {
1223                    "poll / maybe dispatch".to_string()
1224                } else {
1225                    "whole".to_string()
1226                };
1227                let background_detail = if is_background {
1228                    format!(
1229                        "\nRT GATEWAY ONLY: the task work does not execute in this cell\nidle/ready: emit buffered output + FIFO-enqueue current input\nbusy/not ready: emit empty output; do not enqueue another job\nbackground pool: {}",
1230                        step.node.background_pool()
1231                    )
1232                } else {
1233                    String::new()
1234                };
1235                let tooltip = format!(
1236                    "Formation {} · CL n+{}\nStage {}: {}\nphase: {}\nworker placement: {}\nconfigured resource targets: {}{}{}",
1237                    formation + 1,
1238                    lane,
1239                    stage_index + 1,
1240                    entity.label,
1241                    phase,
1242                    placement,
1243                    if resources.is_empty() {
1244                        "—".to_string()
1245                    } else {
1246                        resources.join(", ")
1247                    },
1248                    background_detail,
1249                    if collision_targets.is_empty() {
1250                        String::new()
1251                    } else {
1252                        format!(
1253                            "\nPOTENTIAL CONTENTION in this formation: {}",
1254                            collision_targets.join(", ")
1255                        )
1256                    }
1257                );
1258                let placement_label = if is_background {
1259                    "RT GATEWAY".to_string()
1260                } else {
1261                    truncate(&placement, 13)
1262                };
1263                let footer = if is_background {
1264                    format!("pool {}", step.node.background_pool())
1265                } else if collision_targets.is_empty() {
1266                    if resources.is_empty() {
1267                        "res —".to_string()
1268                    } else {
1269                        format!("res {}", resources.join(", "))
1270                    }
1271                } else {
1272                    format!("⚠ {}", collision_targets.join(", "))
1273                };
1274                writeln!(
1275                    svg,
1276                    r##"<g data-formation="{}" data-cl-offset="{lane}" data-stage="{}"{}><title>{}</title><rect x="{x}" y="{row_y}" width="{WAVE_CELL_WIDTH}" height="{WAVE_CELL_HEIGHT}" rx="6" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}"{}/><text class="ordinal mono" x="{}" y="{}">S{:02} · {}</text><text class="card-title" x="{}" y="{}">{}</text><text class="card-line" x="{}" y="{}">{}</text><text class="card-line mono" x="{}" y="{}">{}</text></g>"##,
1277                    formation + 1,
1278                    stage_index + 1,
1279                    if is_background { r#" data-background-gateway="true""# } else { "" },
1280                    xml(&tooltip),
1281                    if is_background { r#" stroke-dasharray="4 2""# } else { "" },
1282                    x + 7.0,
1283                    row_y + 13.0,
1284                    stage_index + 1,
1285                    xml(&placement_label),
1286                    x + 7.0,
1287                    row_y + 29.0,
1288                    xml(&truncate(&entity.label, 16)),
1289                    x + 7.0,
1290                    row_y + 45.0,
1291                    xml(&truncate(&phase, 18)),
1292                    x + 7.0,
1293                    row_y + 62.0,
1294                    xml(&truncate(&footer, 18)),
1295                )
1296                .unwrap();
1297            }
1298        }
1299        let pools_top = rows_top + visible_lanes as f64 * (WAVE_CELL_HEIGHT + WAVE_CELL_GAP);
1300        let mut triggers = Vec::new();
1301        for lane in 0..visible_lanes {
1302            for formation in block_start..block_end {
1303                let Some(stage_index) = formation.checked_sub(lane) else {
1304                    continue;
1305                };
1306                let Some(step) = stages.get(stage_index).copied() else {
1307                    continue;
1308                };
1309                if !step.node.is_background() {
1310                    continue;
1311                }
1312                triggers.push(BackgroundTrigger {
1313                    column: formation,
1314                    stage_index,
1315                    step,
1316                    entity: &entities[step.node_id as usize],
1317                    source_y: rows_top
1318                        + lane as f64 * (WAVE_CELL_HEIGHT + WAVE_CELL_GAP)
1319                        + WAVE_CELL_HEIGHT,
1320                    cl_label: if lane == 0 {
1321                        "CL n".to_string()
1322                    } else {
1323                        format!("CL n+{lane}")
1324                    },
1325                });
1326            }
1327        }
1328        let background = render_background_lanes(
1329            stages,
1330            entities,
1331            &background_pools,
1332            &triggers,
1333            block_start,
1334            block_end,
1335            pools_top,
1336        );
1337        svg.push_str(&background.svg);
1338        y = pools_top + background.height + 28.0;
1339    }
1340
1341    let overlap_groups = resource_overlap_groups(config, stages, entities, in_flight_limit);
1342    let has_background = stages.iter().any(|step| step.node.is_background());
1343    writeln!(
1344        svg,
1345        r#"<text class="subtitle" x="0" y="{y}">Potential configured-resource overlap across skewed formations and background jobs</text>"#
1346    )
1347    .unwrap();
1348    y += 13.0;
1349    if in_flight_limit < 2 && !has_background {
1350        writeln!(
1351            svg,
1352            r##"<rect x="0" y="{y}" width="{PARALLEL_WIDTH}" height="50" rx="8" fill="#ecfdf3" stroke="#75c58f"/><text class="blocking" x="12" y="{}">Configured depth is 1, so foreground stages cannot overlap across CopperLists.</text><text class="meta" x="12" y="{}">Repeated bindings still execute serially inside that single CopperList.</text>"##,
1353            y + 21.0,
1354            y + 39.0,
1355        )
1356        .unwrap();
1357        y += 50.0;
1358    } else if overlap_groups.is_empty() {
1359        writeln!(
1360            svg,
1361            r##"<rect x="0" y="{y}" width="{PARALLEL_WIDTH}" height="50" rx="8" fill="#ecfdf3" stroke="#75c58f"/><text class="blocking" x="12" y="{}">No resource target is bound by more than one potentially overlapping stage or background job.</text><text class="meta" x="12" y="{}">This cannot detect GPU/device use that tasks do not declare as a Copper resource binding.</text>"##,
1362            y + 21.0,
1363            y + 39.0,
1364        )
1365        .unwrap();
1366        y += 50.0;
1367    } else {
1368        let panel_height = 42.0 + overlap_groups.len() as f64 * 20.0;
1369        writeln!(
1370            svg,
1371            r##"<rect x="0" y="{y}" width="{PARALLEL_WIDTH}" height="{panel_height}" rx="8" fill="#fff1f0" stroke="#d92d20"/><text class="blocking" x="12" y="{}">These executions can overlap across CopperLists or while a background job remains active; bindings are not scheduler reservations.</text>"##,
1372            y + 20.0,
1373        )
1374        .unwrap();
1375        for (index, (target, stage_labels)) in overlap_groups.iter().enumerate() {
1376            writeln!(
1377                svg,
1378                r#"<text class="card-line mono" x="12" y="{}">⚠ {}: {}</text>"#,
1379                y + 42.0 + index as f64 * 20.0,
1380                xml(&truncate(target, 36)),
1381                xml(&truncate(&stage_labels.join(" ↔ "), 118)),
1382            )
1383            .unwrap();
1384        }
1385        y += panel_height;
1386    }
1387
1388    RenderedSection {
1389        svg,
1390        height: y - top,
1391    }
1392}
1393
1394struct BackgroundPoolView<'a> {
1395    id: String,
1396    config: Option<&'a ThreadPoolConfig>,
1397    stage_indices: Vec<usize>,
1398}
1399
1400struct BackgroundTrigger<'a> {
1401    column: usize,
1402    stage_index: usize,
1403    step: &'a CuExecutionStep,
1404    entity: &'a PlanEntity,
1405    source_y: f64,
1406    cl_label: String,
1407}
1408
1409fn background_pool_views<'a>(
1410    config: &'a CuConfig,
1411    stages: &[&CuExecutionStep],
1412) -> Vec<BackgroundPoolView<'a>> {
1413    let mut assignments = BTreeMap::<String, Vec<usize>>::new();
1414    for (stage_index, step) in stages.iter().enumerate() {
1415        if step.node.is_background() {
1416            assignments
1417                .entry(step.node.background_pool().to_string())
1418                .or_default()
1419                .push(stage_index);
1420        }
1421    }
1422    let configured = config
1423        .runtime
1424        .as_ref()
1425        .map(|runtime| runtime.thread_pools.as_slice())
1426        .unwrap_or_default();
1427    assignments
1428        .into_iter()
1429        .map(|(id, stage_indices)| BackgroundPoolView {
1430            config: configured.iter().find(|pool| pool.id == id),
1431            id,
1432            stage_indices,
1433        })
1434        .collect()
1435}
1436
1437#[allow(clippy::too_many_arguments)]
1438fn render_background_lanes(
1439    stages: &[&CuExecutionStep],
1440    entities: &[PlanEntity],
1441    pools: &[BackgroundPoolView<'_>],
1442    triggers: &[BackgroundTrigger<'_>],
1443    block_start: usize,
1444    block_end: usize,
1445    top: f64,
1446) -> RenderedSection {
1447    if pools.is_empty() {
1448        return RenderedSection {
1449            svg: String::new(),
1450            height: 0.0,
1451        };
1452    }
1453
1454    let block_width =
1455        (block_end - block_start) as f64 * (WAVE_CELL_WIDTH + WAVE_CELL_GAP) - WAVE_CELL_GAP;
1456    let mut pool_tops = BTreeMap::<String, f64>::new();
1457    let mut y = top + 29.0;
1458    for pool in pools {
1459        pool_tops.insert(pool.id.clone(), y);
1460        let threads = pool.config.map(|pool| pool.threads).unwrap_or(1);
1461        let queue_rows = usize::from(pool.stage_indices.len() > threads);
1462        y += 25.0 + (threads + queue_rows) as f64 * 39.0 + 12.0;
1463    }
1464
1465    let mut svg = String::new();
1466    writeln!(
1467        svg,
1468        r#"<text class="ordinal mono" x="0" y="{}">BACKGROUND POOL WORKER THREADS · DASHED BARS MAY CONTINUE</text>"#,
1469        top + 17.0
1470    )
1471    .unwrap();
1472
1473    for pool in pools {
1474        let pool_y = pool_tops[&pool.id];
1475        let threads = pool.config.map(|pool| pool.threads).unwrap_or(1);
1476        let active_slots = threads.min(pool.stage_indices.len());
1477        let task_labels = pool
1478            .stage_indices
1479            .iter()
1480            .map(|stage_index| {
1481                entities[stages[*stage_index].node_id as usize]
1482                    .label
1483                    .as_str()
1484            })
1485            .collect::<Vec<_>>();
1486        let metadata = pool
1487            .config
1488            .map(format_pool)
1489            .unwrap_or_else(|| format!("{}: pool metadata unavailable", pool.id));
1490        writeln!(
1491            svg,
1492            r#"<g data-background-pool="{}"><title>{}</title><text class="card-title" x="0" y="{}">POOL {}</text><text class="meta mono" x="{}" y="{}">{}</text>"#,
1493            xml_attr(&pool.id),
1494            xml(&metadata),
1495            pool_y + 13.0,
1496            xml(&pool.id),
1497            LABEL_WIDTH + 22.0,
1498            pool_y + 13.0,
1499            xml(&truncate(&metadata, 105)),
1500        )
1501        .unwrap();
1502
1503        let earliest_dispatch = pool
1504            .stage_indices
1505            .iter()
1506            .copied()
1507            .min()
1508            .unwrap_or(usize::MAX);
1509        for worker in 0..threads {
1510            let lane_y = pool_y + 20.0 + worker as f64 * 39.0;
1511            writeln!(
1512                svg,
1513                r#"<text class="lane mono" x="0" y="{}">{} w{}</text><rect class="pool" x="{LABEL_WIDTH}" y="{lane_y}" width="{block_width}" height="31" rx="5"/>"#,
1514                lane_y + 20.0,
1515                xml(&truncate(&pool.id, 11)),
1516                worker + 1,
1517            )
1518            .unwrap();
1519
1520            if worker < active_slots && earliest_dispatch < block_end {
1521                let start_column = earliest_dispatch.saturating_sub(block_start);
1522                let bar_column = start_column.min(block_end - block_start - 1);
1523                let bar_x = LABEL_WIDTH + bar_column as f64 * (WAVE_CELL_WIDTH + WAVE_CELL_GAP);
1524                let bar_width = LABEL_WIDTH + block_width - bar_x;
1525                let carry_in = earliest_dispatch < block_start;
1526                let label = if task_labels.len() == 1 {
1527                    format!("{} job · running ?", task_labels[0])
1528                } else {
1529                    format!("{} jobs · running ?", task_labels.join(" / "))
1530                };
1531                writeln!(
1532                    svg,
1533                    r##"<rect class="job" x="{bar_x}" y="{}" width="{bar_width}" height="25" rx="4" stroke-dasharray="6 3"/><text class="card-line mono" x="{}" y="{}">{}{}</text><line class="job-flow" x1="{}" y1="{}" x2="{}" y2="{}"/>"##,
1534                    lane_y + 3.0,
1535                    bar_x + 7.0,
1536                    lane_y + 19.0,
1537                    if carry_in { "← " } else { "" },
1538                    xml(&truncate(&label, 42)),
1539                    (bar_x + 190.0).min(LABEL_WIDTH + block_width - 40.0),
1540                    lane_y + 16.0,
1541                    LABEL_WIDTH + block_width - 10.0,
1542                    lane_y + 16.0,
1543                )
1544                .unwrap();
1545            }
1546        }
1547
1548        if pool.stage_indices.len() > threads {
1549            let queue_y = pool_y + 20.0 + threads as f64 * 39.0;
1550            writeln!(
1551                svg,
1552                r##"<text class="lane mono" x="0" y="{}" fill="#b42318">FIFO queue</text><rect x="{LABEL_WIDTH}" y="{queue_y}" width="{block_width}" height="31" rx="5" fill="#fff1f0" stroke="#d92d20"/><text class="card-line mono" x="{}" y="{}">⚠ {} task jobs share {} workers · excess jobs can wait here</text>"##,
1553                queue_y + 20.0,
1554                LABEL_WIDTH + 7.0,
1555                queue_y + 20.0,
1556                pool.stage_indices.len(),
1557                threads,
1558            )
1559            .unwrap();
1560        }
1561        svg.push_str("</g>\n");
1562    }
1563
1564    for (trigger_ordinal, trigger) in triggers.iter().enumerate() {
1565        let pool_id = trigger.step.node.background_pool();
1566        let Some(pool_y) = pool_tops.get(pool_id) else {
1567            continue;
1568        };
1569        let column = trigger.column - block_start;
1570        let cell_x = LABEL_WIDTH + column as f64 * (WAVE_CELL_WIDTH + WAVE_CELL_GAP);
1571        let source_x = cell_x + WAVE_CELL_WIDTH - 8.0;
1572        let target_x = (cell_x + WAVE_CELL_WIDTH + 3.0 + (trigger_ordinal % 3) as f64 * 2.0)
1573            .min(LABEL_WIDTH + block_width - 4.0);
1574        let source_y = trigger.source_y;
1575        let target_y = pool_y + 36.0;
1576        let tooltip = format!(
1577            "{} {} gateway → pool {}\nDispatch occurs only if this background task is idle/ready\nIf already busy: empty output, no second job",
1578            trigger.cl_label, trigger.entity.label, pool_id,
1579        );
1580        writeln!(
1581            svg,
1582            r##"<g data-background-trigger="{}"><title>{}</title><path class="bg-trigger" d="M {source_x} {source_y} L {target_x} {} L {target_x} {target_y}"/><circle cx="{target_x}" cy="{target_y}" r="3" fill="#7f56d9"/></g>"##,
1583            trigger.stage_index + 1,
1584            xml(&tooltip),
1585            source_y + 6.0,
1586        )
1587        .unwrap();
1588    }
1589
1590    RenderedSection {
1591        svg,
1592        height: y - top,
1593    }
1594}
1595
1596fn formation_collisions(
1597    config: &CuConfig,
1598    stages: &[&CuExecutionStep],
1599    entities: &[PlanEntity],
1600    visible_lanes: usize,
1601    formation: usize,
1602) -> HashMap<usize, Vec<String>> {
1603    let mut target_lanes = BTreeMap::<String, BTreeSet<usize>>::new();
1604    for lane in 0..visible_lanes {
1605        let Some(stage_index) = formation.checked_sub(lane) else {
1606            continue;
1607        };
1608        let Some(step) = stages.get(stage_index) else {
1609            continue;
1610        };
1611        // A background stage is only a state-check/dispatch gateway. Its
1612        // declared resources belong to the asynchronous job, whose lifetime
1613        // is independent of this nominal RT formation and is audited below.
1614        if step.node.is_background() {
1615            continue;
1616        }
1617        let entity = &entities[step.node_id as usize];
1618        for (_, target) in entity_resource_bindings(config, entity, step) {
1619            target_lanes.entry(target).or_default().insert(lane);
1620        }
1621    }
1622
1623    let mut collisions = HashMap::<usize, Vec<String>>::new();
1624    for (target, lanes) in target_lanes {
1625        if lanes.len() < 2 {
1626            continue;
1627        }
1628        for lane in lanes {
1629            collisions.entry(lane).or_default().push(target.clone());
1630        }
1631    }
1632    collisions
1633}
1634
1635fn resource_overlap_groups(
1636    config: &CuConfig,
1637    stages: &[&CuExecutionStep],
1638    entities: &[PlanEntity],
1639    in_flight_limit: usize,
1640) -> Vec<(String, Vec<String>)> {
1641    let mut target_stages = BTreeMap::<String, BTreeSet<usize>>::new();
1642    for (stage_index, step) in stages.iter().enumerate() {
1643        let entity = &entities[step.node_id as usize];
1644        for (_, target) in entity_resource_bindings(config, entity, step) {
1645            target_stages.entry(target).or_default().insert(stage_index);
1646        }
1647    }
1648
1649    target_stages
1650        .into_iter()
1651        .filter(|(_, stage_indices)| {
1652            stage_indices.len() > 1
1653                && (in_flight_limit >= 2
1654                    || stage_indices
1655                        .iter()
1656                        .any(|stage_index| stages[*stage_index].node.is_background()))
1657        })
1658        .map(|(target, stage_indices)| {
1659            let labels = stage_indices
1660                .into_iter()
1661                .map(|stage_index| {
1662                    let step = stages[stage_index];
1663                    let entity = &entities[step.node_id as usize];
1664                    format!(
1665                        "S{:02} {}{}",
1666                        stage_index + 1,
1667                        entity.label,
1668                        if step.node.is_background() {
1669                            " (background job)"
1670                        } else {
1671                            ""
1672                        }
1673                    )
1674                })
1675                .collect();
1676            (target, labels)
1677        })
1678        .collect()
1679}
1680
1681fn worker_placement(rt_pool: Option<&ThreadPoolConfig>, stage_index: usize) -> String {
1682    let Some(pool) = rt_pool else {
1683        return "OS scheduled".to_string();
1684    };
1685    let affinity = pool.affinity.as_deref().unwrap_or_default();
1686    if affinity.is_empty() {
1687        "OS scheduled".to_string()
1688    } else {
1689        format!("CPU {}", affinity[stage_index % affinity.len()])
1690    }
1691}
1692
1693fn entity_style(entity: &PlanEntity, step: &CuExecutionStep) -> (&'static str, &'static str) {
1694    match entity.kind {
1695        PlanEntityKind::BridgeRx { .. } | PlanEntityKind::BridgeTx { .. } => {
1696            ("bridge", BRIDGE_COLOR)
1697        }
1698        PlanEntityKind::Task { .. } => match step.task_type {
1699            CuTaskType::Source => ("source", SOURCE_COLOR),
1700            CuTaskType::Regular => ("task", TASK_COLOR),
1701            CuTaskType::Sink => ("sink", SINK_COLOR),
1702        },
1703    }
1704}
1705
1706fn entity_resources(config: &CuConfig, entity: &PlanEntity, step: &CuExecutionStep) -> Vec<String> {
1707    entity_resource_bindings(config, entity, step)
1708        .into_iter()
1709        .map(|(binding, resource)| format!("{binding}→{resource}"))
1710        .collect()
1711}
1712
1713fn entity_resource_bindings(
1714    config: &CuConfig,
1715    entity: &PlanEntity,
1716    step: &CuExecutionStep,
1717) -> Vec<(String, String)> {
1718    let resources = match entity.kind {
1719        PlanEntityKind::Task { .. } => step.node.get_resources(),
1720        PlanEntityKind::BridgeRx {
1721            bridge_config_index,
1722            ..
1723        }
1724        | PlanEntityKind::BridgeTx {
1725            bridge_config_index,
1726            ..
1727        } => config.bridges[bridge_config_index].resources.as_ref(),
1728    };
1729    let mut bindings = resources
1730        .into_iter()
1731        .flat_map(|resources| resources.iter())
1732        .map(|(binding, resource)| (binding.clone(), resource.clone()))
1733        .collect::<Vec<_>>();
1734    bindings.sort();
1735    bindings
1736}
1737
1738fn format_pool(pool: &ThreadPoolConfig) -> String {
1739    let affinity = pool
1740        .affinity
1741        .as_ref()
1742        .map(|cores| {
1743            format!(
1744                "[{}]",
1745                cores
1746                    .iter()
1747                    .map(usize::to_string)
1748                    .collect::<Vec<_>>()
1749                    .join(", ")
1750            )
1751        })
1752        .unwrap_or_else(|| "OS scheduled".to_string());
1753    format!(
1754        "{}: threads={}, affinity={}, policy={}, on_error={}",
1755        pool.id,
1756        pool.threads,
1757        affinity,
1758        format_policy(pool.policy),
1759        match pool.on_error {
1760            OnError::Warn => "Warn",
1761            OnError::Strict => "Strict",
1762        }
1763    )
1764}
1765
1766fn format_policy(policy: SchedulingPolicy) -> String {
1767    match policy {
1768        SchedulingPolicy::Fair => "Fair".to_string(),
1769        SchedulingPolicy::Nice(value) => format!("Nice({value})"),
1770        SchedulingPolicy::Fifo { priority } => format!("Fifo(priority: {priority})"),
1771        SchedulingPolicy::RoundRobin { priority } => {
1772            format!("RoundRobin(priority: {priority})")
1773        }
1774    }
1775}
1776
1777fn truncate(value: &str, max_chars: usize) -> String {
1778    let mut chars = value.chars();
1779    let prefix = chars.by_ref().take(max_chars).collect::<String>();
1780    if chars.next().is_some() {
1781        format!("{prefix}…")
1782    } else {
1783        prefix
1784    }
1785}
1786
1787fn format_duration_ns(nanos: f64) -> String {
1788    if nanos >= 1_000_000_000.0 {
1789        format!("{:.3} s", nanos / 1_000_000_000.0)
1790    } else if nanos >= 1_000_000.0 {
1791        format!("{:.3} ms", nanos / 1_000_000.0)
1792    } else if nanos >= 1_000.0 {
1793        format!("{:.3} us", nanos / 1_000.0)
1794    } else {
1795        format!("{nanos:.0} ns")
1796    }
1797}
1798
1799fn xml(value: &str) -> String {
1800    value
1801        .replace('&', "&amp;")
1802        .replace('<', "&lt;")
1803        .replace('>', "&gt;")
1804}
1805
1806fn xml_attr(value: &str) -> String {
1807    xml(value).replace('"', "&quot;").replace('\'', "&apos;")
1808}
1809
1810fn open_svg(path: &Path) -> std::io::Result<()> {
1811    if cfg!(target_os = "windows") {
1812        Command::new("cmd")
1813            .args(["/C", "start", ""])
1814            .arg(path)
1815            .status()?;
1816        return Ok(());
1817    }
1818    let program = if cfg!(target_os = "macos") {
1819        "open"
1820    } else {
1821        "xdg-open"
1822    };
1823    Command::new(program).arg(path).status()?;
1824    Ok(())
1825}
1826
1827#[cfg(test)]
1828mod tests {
1829    use super::*;
1830    use cu29_runtime::config::CuConfig;
1831
1832    fn config(ron: &str) -> CuConfig {
1833        CuConfig::deserialize_ron(ron).expect("valid test config")
1834    }
1835
1836    #[test]
1837    fn deterministic_svg_contains_schedule_details_and_stable_keys() {
1838        let config = config(
1839            r#"(
1840                runtime: (
1841                    rate_target_hz: 100,
1842                    thread_pools: [
1843                        (id: "rt", threads: 2, affinity: [2, 4], policy: Fifo(priority: 80), on_error: Strict),
1844                    ],
1845                ),
1846                resources: [(id: "board", provider: "demo::Board")],
1847                tasks: [
1848                    (id: "src", type: "demo::Src", kind: source),
1849                    (id: "work", type: "demo::Work", kind: task, resources: { "bus": "board.spi" }),
1850                    (id: "sink", type: "demo::Sink", kind: sink),
1851                ],
1852                cnx: [
1853                    (src: "src", dst: "work", msg: "demo::A"),
1854                    (src: "work", dst: "sink", msg: "demo::B"),
1855                ],
1856            )"#,
1857        );
1858        let sections = selected_graphs(&config, None).unwrap();
1859        let first = render_document(&config, &sections, None).unwrap();
1860        let second = render_document(&config, &sections, None).unwrap();
1861        assert_eq!(first, second);
1862        assert!(first.contains("Serial projection"));
1863        assert!(first.contains("Parallel projection"));
1864        assert!(first.contains("CPU 2"));
1865        assert!(first.contains("CPU 4"));
1866        assert!(first.contains("bus→board.spi"));
1867        assert!(first.contains("mission:default|task:work|phase:whole"));
1868        assert!(first.contains("threads=2"));
1869    }
1870
1871    #[test]
1872    fn anytime_is_woven_in_serial_and_collapsed_in_parallel() {
1873        let config = config(
1874            r#"(
1875                tasks: [
1876                    (id: "src", type: "demo::Src", kind: source),
1877                    (id: "any", type: "demo::Any", kind: task, anytime: (max_refines: 3)),
1878                    (id: "other", type: "demo::Other", kind: source),
1879                    (id: "sink", type: "demo::Sink", kind: sink),
1880                ],
1881                cnx: [
1882                    (src: "src", dst: "any", msg: "demo::A"),
1883                    (src: "any", dst: "sink", msg: "demo::B"),
1884                    (src: "other", dst: "__nc__", msg: "demo::OtherMsg"),
1885                ],
1886            )"#,
1887        );
1888        let svg = render_document(&config, &selected_graphs(&config, None).unwrap(), None).unwrap();
1889        assert!(svg.contains("refine 1/3"));
1890        assert!(svg.contains("refine 3/3"));
1891        assert!(svg.contains("base + 3 refine"));
1892        assert!(svg.contains("phase:refine:3"));
1893    }
1894
1895    #[test]
1896    fn missions_are_sorted_and_selectable() {
1897        let config = config(
1898            r#"(
1899                missions: [(id: "zeta"), (id: "alpha")],
1900                tasks: [(id: "src", type: "demo::Src", kind: source)],
1901                cnx: [(src: "src", dst: "__nc__", msg: "demo::A")],
1902            )"#,
1903        );
1904        let all = selected_graphs(&config, None).unwrap();
1905        assert_eq!(all[0].0, "alpha");
1906        assert_eq!(all[1].0, "zeta");
1907        let selected = selected_graphs(&config, Some("zeta")).unwrap();
1908        assert_eq!(selected.len(), 1);
1909        assert!(selected_graphs(&config, Some("missing")).is_err());
1910    }
1911
1912    #[test]
1913    fn cli_defaults_to_plan_svg_and_parses_features() {
1914        let args = Args::try_parse_from([
1915            "cu29-plan",
1916            "copperconfig.ron",
1917            "--features",
1918            "camera,mock",
1919            "--logstats",
1920            "stats.json",
1921        ])
1922        .unwrap();
1923        assert_eq!(args.output, PathBuf::from("plan.svg"));
1924        assert_eq!(args.features, ["camera", "mock"]);
1925        assert_eq!(args.logstats, Some(PathBuf::from("stats.json")));
1926    }
1927
1928    #[test]
1929    fn background_and_unpinned_workers_are_explicit() {
1930        let config = config(
1931            r#"(
1932                runtime: (
1933                    thread_pools: [
1934                        (id: "vision", threads: 1, policy: Nice(10), on_error: Warn),
1935                    ],
1936                ),
1937                tasks: [
1938                    (id: "src", type: "demo::Src", kind: source),
1939                    (id: "vision", type: "demo::Vision", kind: task, background: (pool: "vision")),
1940                    (id: "sink", type: "demo::Sink", kind: sink),
1941                ],
1942                cnx: [
1943                    (src: "src", dst: "vision", msg: "demo::A"),
1944                    (src: "vision", dst: "sink", msg: "demo::B"),
1945                ],
1946            )"#,
1947        );
1948        let svg = render_document(&config, &selected_graphs(&config, None).unwrap(), None).unwrap();
1949        assert!(svg.contains("POLL / DISPATCH?"));
1950        assert!(svg.contains("BACKGROUND POOL WORKER THREADS"));
1951        assert!(svg.contains("RT GATEWAY ONLY"));
1952        assert!(svg.contains("POOL vision"));
1953        assert!(svg.contains("vision job · running ?"));
1954        assert!(svg.contains(r#"data-background-gateway="true""#));
1955        assert!(svg.contains(r#"data-background-pool="vision""#));
1956        assert!(svg.contains(r#"data-background-trigger="2""#));
1957        assert!(svg.contains("OS scheduled"));
1958        assert!(svg.contains("policy=Nice(10)"));
1959        assert!(svg.contains("Stage 2: vision"));
1960        assert!(svg.contains("2 CopperLists max in flight"));
1961        assert!(svg.contains("Configured in-flight depth: 2"));
1962        assert!(svg.contains("CL n+1"));
1963        assert!(!svg.contains("Queue and blocking detail"));
1964    }
1965
1966    #[test]
1967    fn shared_background_pool_and_persistent_resource_contention_are_explicit() {
1968        let config = config(
1969            r#"(
1970                runtime: (
1971                    thread_pools: [
1972                        (id: "vision", threads: 1, affinity: [3], policy: Fair, on_error: Warn),
1973                    ],
1974                ),
1975                resources: [(id: "gpu0", provider: "demo::Gpu")],
1976                tasks: [
1977                    (id: "src", type: "demo::Src", kind: source),
1978                    (id: "detect", type: "demo::Detect", kind: task, background: (pool: "vision"), resources: { "gpu": "gpu0" }),
1979                    (id: "refine", type: "demo::Refine", kind: task, background: (pool: "vision"), resources: { "gpu": "gpu0" }),
1980                    (id: "sink", type: "demo::Sink", kind: sink),
1981                ],
1982                cnx: [
1983                    (src: "src", dst: "detect", msg: "demo::A"),
1984                    (src: "detect", dst: "refine", msg: "demo::B"),
1985                    (src: "refine", dst: "sink", msg: "demo::C"),
1986                ],
1987            )"#,
1988        );
1989        let svg = render_document(&config, &selected_graphs(&config, None).unwrap(), None).unwrap();
1990        assert!(svg.contains("FIFO queue"));
1991        assert!(svg.contains("2 task jobs share 1 workers"));
1992        assert!(svg.contains("excess jobs can wait here"));
1993        assert!(svg.contains("S02 detect (background job) ↔ S03 refine (background job)"));
1994        assert!(!svg.contains("POTENTIAL CONTENTION in this formation: gpu0"));
1995    }
1996
1997    #[test]
1998    fn staggered_wavefront_uses_the_configured_six_copperlist_depth() {
1999        let config = config(
2000            r#"(
2001                logging: (copperlist_count: 6),
2002                tasks: [
2003                    (id: "s0", type: "demo::S0", kind: source),
2004                    (id: "s1", type: "demo::S1", kind: source),
2005                    (id: "s2", type: "demo::S2", kind: source),
2006                    (id: "s3", type: "demo::S3", kind: source),
2007                    (id: "s4", type: "demo::S4", kind: source),
2008                    (id: "s5", type: "demo::S5", kind: source),
2009                ],
2010                cnx: [
2011                    (src: "s0", dst: "__nc__", msg: "demo::M0"),
2012                    (src: "s1", dst: "__nc__", msg: "demo::M1"),
2013                    (src: "s2", dst: "__nc__", msg: "demo::M2"),
2014                    (src: "s3", dst: "__nc__", msg: "demo::M3"),
2015                    (src: "s4", dst: "__nc__", msg: "demo::M4"),
2016                    (src: "s5", dst: "__nc__", msg: "demo::M5"),
2017                ],
2018            )"#,
2019        );
2020        let svg = render_document(&config, &selected_graphs(&config, None).unwrap(), None).unwrap();
2021        assert!(svg.contains("Configured in-flight depth: 6"));
2022        assert!(svg.contains("CL n+5"));
2023        assert!(svg.contains(r#"data-cl-offset="5""#));
2024    }
2025
2026    #[test]
2027    fn large_in_flight_depth_is_a_six_copperlist_window() {
2028        let config = config(
2029            r#"(
2030                logging: (copperlist_count: 32),
2031                tasks: [
2032                    (id: "s0", type: "demo::S0", kind: source),
2033                    (id: "s1", type: "demo::S1", kind: source),
2034                    (id: "s2", type: "demo::S2", kind: source),
2035                    (id: "s3", type: "demo::S3", kind: source),
2036                    (id: "s4", type: "demo::S4", kind: source),
2037                    (id: "s5", type: "demo::S5", kind: source),
2038                    (id: "s6", type: "demo::S6", kind: source),
2039                ],
2040                cnx: [
2041                    (src: "s0", dst: "__nc__", msg: "demo::M0"),
2042                    (src: "s1", dst: "__nc__", msg: "demo::M1"),
2043                    (src: "s2", dst: "__nc__", msg: "demo::M2"),
2044                    (src: "s3", dst: "__nc__", msg: "demo::M3"),
2045                    (src: "s4", dst: "__nc__", msg: "demo::M4"),
2046                    (src: "s5", dst: "__nc__", msg: "demo::M5"),
2047                    (src: "s6", dst: "__nc__", msg: "demo::M6"),
2048                ],
2049            )"#,
2050        );
2051        let svg = render_document(&config, &selected_graphs(&config, None).unwrap(), None).unwrap();
2052        assert!(svg.contains("Showing CL n through CL n+5 (6 of 7 active pipeline positions)"));
2053        assert!(svg.contains(r#"data-cl-offset="5""#));
2054        assert!(!svg.contains(r#"data-cl-offset="6""#));
2055    }
2056
2057    #[test]
2058    fn staggered_wavefront_highlights_declared_resource_contention() {
2059        let config = config(
2060            r#"(
2061                resources: [(id: "gpu0", provider: "demo::Gpu")],
2062                tasks: [
2063                    (id: "src", type: "demo::Src", kind: source),
2064                    (id: "detect", type: "demo::Detect", kind: task, resources: { "gpu": "gpu0" }),
2065                    (id: "refine", type: "demo::Refine", kind: task, resources: { "accelerator": "gpu0" }),
2066                    (id: "sink", type: "demo::Sink", kind: sink),
2067                ],
2068                cnx: [
2069                    (src: "src", dst: "detect", msg: "demo::A"),
2070                    (src: "detect", dst: "refine", msg: "demo::B"),
2071                    (src: "refine", dst: "sink", msg: "demo::C"),
2072                ],
2073            )"#,
2074        );
2075        let svg = render_document(&config, &selected_graphs(&config, None).unwrap(), None).unwrap();
2076        assert!(svg.contains("Staggered concurrency formations"));
2077        assert!(svg.contains("POTENTIAL CONTENTION in this formation: gpu0"));
2078        assert!(svg.contains("⚠ gpu0: S02 detect ↔ S03 refine"));
2079        assert!(svg.contains(r##"stroke="#d92d20" stroke-width="3""##));
2080    }
2081
2082    #[test]
2083    fn observed_schedule_is_proportional_and_labels_inference_limits() {
2084        let config = config(
2085            r#"(
2086                resources: [(id: "gpu0", provider: "demo::Gpu")],
2087                tasks: [
2088                    (id: "left", type: "demo::Left", kind: source, resources: { "gpu": "gpu0" }),
2089                    (id: "right", type: "demo::Right", kind: source, resources: { "gpu": "gpu0" }),
2090                ],
2091                cnx: [
2092                    (src: "left", dst: "__nc__", msg: "demo::A"),
2093                    (src: "right", dst: "__nc__", msg: "demo::B"),
2094                ],
2095            )"#,
2096        );
2097        let duration = ObservedDurationStats {
2098            p50_ns: Some(100),
2099            p95_ns: Some(200),
2100            max_ns: Some(250),
2101        };
2102        let stats = ObservedLogStats {
2103            schema_version: 2,
2104            config_signature: build_logstats_signature(&config, None).unwrap(),
2105            mission: None,
2106            schedule: Some(ObservedSchedule {
2107                stages: vec![ObservedStage {
2108                    origin: "left".to_string(),
2109                    samples: 4,
2110                    durations: duration,
2111                }],
2112                traces: vec![ObservedTrace {
2113                    kind: "typical".to_string(),
2114                    culist_id: 7,
2115                    wall_span_ns: 300,
2116                    excluded_intervals: 1,
2117                    residual_before_ns: Some(50),
2118                    intervals: vec![
2119                        ObservedInterval {
2120                            origin: "left".to_string(),
2121                            start_ns: 1_000,
2122                            end_ns: 1_200,
2123                        },
2124                        ObservedInterval {
2125                            origin: "right".to_string(),
2126                            start_ns: 1_100,
2127                            end_ns: 1_300,
2128                        },
2129                    ],
2130                }],
2131                residual_before: ObservedDurationStats {
2132                    p50_ns: Some(50),
2133                    p95_ns: Some(75),
2134                    max_ns: Some(90),
2135                },
2136                resource_overlaps: vec![ObservedResourceOverlap {
2137                    resource: "gpu0".to_string(),
2138                    left: "left".to_string(),
2139                    right: "right".to_string(),
2140                    occurrences: 3,
2141                }],
2142            }),
2143        };
2144        let svg = render_document(
2145            &config,
2146            &selected_graphs(&config, None).unwrap(),
2147            Some(&stats),
2148        )
2149        .unwrap();
2150        assert!(svg.contains("Observed execution"));
2151        assert!(svg.contains("packed back-to-back"));
2152        assert!(svg.contains("CL #7"));
2153        assert!(svg.contains("1 carried-forward slot(s) excluded"));
2154        assert!(svg.contains("max: 250 ns"));
2155        assert!(svg.contains("observed overlap risk: gpu0"));
2156        assert!(svg.contains("3 overlap(s)"));
2157        assert!(svg.contains("Residual gaps may include serialization"));
2158        assert!(svg.contains(r#"data-observed-origin="left""#));
2159    }
2160
2161    #[test]
2162    fn run_writes_an_explicit_output_and_missing_config_errors() {
2163        let temp = tempfile::tempdir().unwrap();
2164        let config_path = temp.path().join("copperconfig.ron");
2165        let output_path = temp.path().join("custom.svg");
2166        fs::write(
2167            &config_path,
2168            r#"(
2169                tasks: [(id: "src", type: "demo::Src", kind: source)],
2170                cnx: [],
2171            )"#,
2172        )
2173        .unwrap();
2174        run(Args {
2175            config: config_path,
2176            mission: None,
2177            features: Vec::new(),
2178            list_missions: false,
2179            open: false,
2180            output: output_path.clone(),
2181            logstats: None,
2182        })
2183        .unwrap();
2184        assert!(output_path.is_file());
2185        assert!(
2186            fs::read_to_string(output_path)
2187                .unwrap()
2188                .contains("Mission: default")
2189        );
2190
2191        let missing = run(Args {
2192            config: temp.path().join("missing.ron"),
2193            mission: None,
2194            features: Vec::new(),
2195            list_missions: false,
2196            open: false,
2197            output: temp.path().join("missing.svg"),
2198            logstats: None,
2199        });
2200        assert!(missing.is_err());
2201    }
2202
2203    #[test]
2204    fn logstats_mission_becomes_the_default_plan_selection() {
2205        let temp = tempfile::tempdir().unwrap();
2206        let config_path = temp.path().join("copperconfig.ron");
2207        let logstats_path = temp.path().join("logstats.json");
2208        let output_path = temp.path().join("plan.svg");
2209        fs::write(
2210            &config_path,
2211            r#"(
2212                missions: [(id: "default"), (id: "flow")],
2213                tasks: [(id: "src", type: "demo::Src", kind: source)],
2214                cnx: [],
2215            )"#,
2216        )
2217        .unwrap();
2218        fs::write(
2219            &logstats_path,
2220            r#"{
2221                "schema_version": 2,
2222                "config_signature": "test",
2223                "mission": "default",
2224                "schedule": null
2225            }"#,
2226        )
2227        .unwrap();
2228
2229        run(Args {
2230            config: config_path,
2231            mission: None,
2232            features: Vec::new(),
2233            list_missions: false,
2234            open: false,
2235            output: output_path.clone(),
2236            logstats: Some(logstats_path),
2237        })
2238        .unwrap();
2239
2240        let svg = fs::read_to_string(output_path).unwrap();
2241        assert!(svg.contains("Mission: default"));
2242        assert!(!svg.contains("Mission: flow"));
2243    }
2244
2245    #[test]
2246    fn multi_copper_input_is_rejected_with_clear_guidance() {
2247        let temp = tempfile::tempdir().unwrap();
2248        fs::write(
2249            temp.path().join("alpha.ron"),
2250            r#"(
2251                tasks: [(id: "src", type: "demo::Src", kind: source)],
2252                cnx: [],
2253            )"#,
2254        )
2255        .unwrap();
2256        let multi_path = temp.path().join("multi_copper.ron");
2257        fs::write(
2258            &multi_path,
2259            r#"(
2260                subsystems: [(id: "alpha", config: "alpha.ron")],
2261                interconnects: [],
2262            )"#,
2263        )
2264        .unwrap();
2265        let error = load_single_config(&multi_path, &[]).unwrap_err();
2266        assert!(error.to_string().contains("not supported yet"));
2267    }
2268}