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 config: PathBuf,
44 #[arg(long)]
46 mission: Option<String>,
47 #[arg(long, value_delimiter = ',')]
49 features: Vec<String>,
50 #[arg(long)]
52 list_missions: bool,
53 #[arg(long)]
55 open: bool,
56 #[arg(long, default_value = "plan.svg")]
58 output: PathBuf,
59 #[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, §ions, 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(§ion.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(¶llel.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(§ion.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 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 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('&', "&")
1802 .replace('<', "<")
1803 .replace('>', ">")
1804}
1805
1806fn xml_attr(value: &str) -> String {
1807 xml(value).replace('"', """).replace('\'', "'")
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, §ions, None).unwrap();
1860 let second = render_document(&config, §ions, 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}