Skip to main content

cu29_runtime/
planner.rs

1//! Execution planning: the pluggable [`CuPlanner`] trait, the planners shipped
2//! with copper, and the shared assembly of the generated CopperList plan.
3//!
4//! A planner only decides a [`StepOrder`] over the synthetic plan graph; a
5//! shared pipeline validates the order and materializes the exact plan the
6//! runtime generates. Planners run at build time, never on the robot: the
7//! ship-with-copper ones execute inside `#[copper_runtime]`, out-of-tree ones
8//! in the application's `build.rs` via [`emit_plan`].
9
10use crate::config::{
11    BridgeChannelConfigRepresentation, ComponentConfig, ConfigGraphs, CuConfig, CuDirection,
12    CuGraph, Flavor, Node, NodeId,
13};
14use crate::curuntime::{
15    CuExecutionLoop, CuExecutionStep, CuExecutionUnit, CuInputMsg, CuOutputPack, CuStepPhase,
16    CuTaskType, expand_anytime_steps, find_task_type_for_id,
17};
18use alloc::boxed::Box;
19use alloc::collections::{BTreeMap, BTreeSet, VecDeque};
20use alloc::format;
21use alloc::string::{String, ToString};
22use alloc::vec;
23use alloc::vec::Vec;
24use cu29_traits::{CuError, CuResult};
25use serde::{Deserialize, Serialize};
26
27/// Default number of preallocated CopperLists compiled into a runtime.
28///
29/// Code generation and plan tooling share this value so the displayed
30/// in-flight bound cannot drift from the generated executor.
31#[doc(hidden)]
32pub const DEFAULT_COPPERLIST_COUNT: usize = 2;
33
34/// Stable identity for one generated execution entity.
35#[doc(hidden)]
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum PlanEntityKind {
38    Task {
39        original_node_id: NodeId,
40        task_index: usize,
41    },
42    BridgeRx {
43        bridge_config_index: usize,
44        channel_config_index: usize,
45    },
46    BridgeTx {
47        bridge_config_index: usize,
48        channel_config_index: usize,
49    },
50}
51
52/// Metadata for a node in the synthetic graph consumed by the scheduler.
53#[doc(hidden)]
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct PlanEntity {
56    pub key: String,
57    pub label: String,
58    pub kind: PlanEntityKind,
59}
60
61/// The canonical generated plan plus the stable identity of every plan node.
62#[doc(hidden)]
63pub struct AssembledPlan {
64    pub execution: CuExecutionLoop,
65    /// Indexed by the `NodeId` used in `execution`.
66    pub entities: Vec<PlanEntity>,
67    /// Indexed by the plan `NodeId`; bridge stages contain `None`.
68    pub plan_to_original: Vec<Option<NodeId>>,
69}
70
71/// The only decision a planner makes: a total step order over plan `NodeId`s.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct StepOrder(pub Vec<NodeId>);
74
75/// A pluggable execution planner, selected by `runtime.planner` in the RON
76/// config (mirroring how monitors are selected by `type`).
77///
78/// `plan` receives the synthetic plan graph of one mission: one node per task
79/// ([`Flavor::Task`]) and one per used bridge channel stage
80/// ([`Flavor::Bridge`]), with every connection as an edge. It returns the
81/// execution order over those nodes; the shared pipeline then rejects illegal
82/// orders (a step before one of its inputs, missing or duplicated steps) and
83/// materializes the CopperList plan.
84///
85/// Planners run at build time, never on the robot. Copper ships [`Linearity`]
86/// (the default) and [`Pinned`]; any crate can implement this trait and
87/// resolve through [`emit_plan`] in the application's `build.rs`.
88pub trait CuPlanner {
89    /// Construct from the `config:` block of the `runtime.planner` section.
90    fn new(config: Option<&ComponentConfig>) -> CuResult<Self>
91    where
92        Self: Sized;
93
94    /// Decide the step order for one mission's plan graph.
95    fn plan(&self, graph: &CuGraph) -> CuResult<StepOrder>;
96}
97
98/// Canonical config `type` for [`Linearity`].
99const LINEARITY_PLANNER: &str = "cu29::planner::Linearity";
100
101/// Canonical config `type` for [`Pinned`].
102const PINNED_PLANNER: &str = "cu29::planner::Pinned";
103
104/// The default planner: best-effort linearity, keeping each source-to-sink
105/// chain contiguous. Needs no measurements and is bit-identical to the
106/// historical copper order.
107#[derive(Default)]
108pub struct Linearity;
109
110impl CuPlanner for Linearity {
111    fn new(_config: Option<&ComponentConfig>) -> CuResult<Self> {
112        Ok(Linearity)
113    }
114
115    fn plan(&self, graph: &CuGraph) -> CuResult<StepOrder> {
116        topo_bfs_order(graph)
117    }
118}
119
120/// Replays an explicit task order: `config: { "order": [..] }` lists every
121/// task of the mission exactly once, by RON id; bridge stages are placed
122/// automatically next to their tasks. This is what an offline plan search
123/// writes back.
124pub struct Pinned {
125    order: Vec<String>,
126}
127
128impl CuPlanner for Pinned {
129    fn new(config: Option<&ComponentConfig>) -> CuResult<Self> {
130        const NEEDS_ORDER: &str = "The Pinned planner needs config: { \"order\": [..task ids..] }";
131        let order = config
132            .ok_or(CuError::from(NEEDS_ORDER))?
133            .get_value::<Vec<String>>("order")
134            .map_err(|e| CuError::from(format!("Pinned planner: {e}")))?
135            .ok_or(CuError::from(NEEDS_ORDER))?;
136        Ok(Pinned { order })
137    }
138
139    fn plan(&self, graph: &CuGraph) -> CuResult<StepOrder> {
140        pinned_order(graph, &resolve_pinned_ids(graph, &self.order)?)
141    }
142}
143
144/// Instantiate a ship-with-copper planner from its canonical config `type`.
145fn instantiate_builtin_planner(
146    type_path: &str,
147    config: Option<&ComponentConfig>,
148) -> CuResult<Option<Box<dyn CuPlanner>>> {
149    Ok(Some(match type_path {
150        LINEARITY_PLANNER => Box::new(Linearity::new(config)?),
151        PINNED_PLANNER => Box::new(Pinned::new(config)?),
152        _ => return Ok(None),
153    }))
154}
155
156/// The canonical config `type` strings of the planners shipped with copper.
157#[doc(hidden)]
158pub const BUILTIN_PLANNERS: [&str; 2] = [LINEARITY_PLANNER, PINNED_PLANNER];
159
160/// Whether `type_path` names a planner shipped with copper.
161#[doc(hidden)]
162pub fn is_builtin_planner(type_path: &str) -> bool {
163    BUILTIN_PLANNERS.contains(&type_path)
164}
165
166/// Map the configured task ids onto plan node ids, rejecting duplicates,
167/// unknown ids, bridge stage labels, and lists that miss a task.
168fn resolve_pinned_ids(graph: &CuGraph, ids: &[String]) -> CuResult<Vec<NodeId>> {
169    let mut task_ids: BTreeMap<String, NodeId> = BTreeMap::new();
170    let mut bridge_labels: BTreeSet<String> = BTreeSet::new();
171    for (node_id, node) in graph.get_all_nodes() {
172        match node.get_flavor() {
173            Flavor::Task => {
174                task_ids.insert(node.get_id(), node_id);
175            }
176            Flavor::Bridge => {
177                bridge_labels.insert(node.get_id());
178            }
179        }
180    }
181    let valid_ids = || {
182        let mut names: Vec<String> = task_ids.keys().cloned().collect();
183        names.sort();
184        names.join(", ")
185    };
186
187    let mut resolved = Vec::with_capacity(ids.len());
188    let mut seen: BTreeSet<NodeId> = BTreeSet::new();
189    for id in ids {
190        if let Some(&node_id) = task_ids.get(id) {
191            if !seen.insert(node_id) {
192                return Err(CuError::from(format!(
193                    "Pinned plan lists task '{id}' more than once."
194                )));
195            }
196            resolved.push(node_id);
197        } else if bridge_labels.contains(id) {
198            return Err(CuError::from(format!(
199                "Pinned plan lists bridge stage '{id}'; pin only task ids: [{}].",
200                valid_ids()
201            )));
202        } else {
203            return Err(CuError::from(format!(
204                "Pinned plan lists unknown task '{id}'; valid task ids: [{}].",
205                valid_ids()
206            )));
207        }
208    }
209
210    if resolved.len() != task_ids.len() {
211        let mut missing: Vec<String> = task_ids
212            .iter()
213            .filter(|(_, node_id)| !seen.contains(node_id))
214            .map(|(name, _)| name.clone())
215            .collect();
216        missing.sort();
217        return Err(CuError::from(format!(
218            "Pinned plan must list every task exactly once; missing: [{}].",
219            missing.join(", ")
220        )));
221    }
222
223    Ok(resolved)
224}
225
226/// Method for the `Linearity` objective: today's plan walk reduced to a pure
227/// ordering decision.
228///
229/// The order is not a textbook BFS: it emerges from an outer source queue in
230/// `node_ids` order, a per-node petgraph BFS, an early abort when a step's
231/// inputs are not yet planned, and `handled`-gated neighbor enqueueing. This
232/// reproduces that walk exactly, minus the culist/input bookkeeping (which
233/// `plan_from_order` now performs).
234fn topo_bfs_order(graph: &CuGraph) -> CuResult<StepOrder> {
235    #[cfg(all(feature = "std", feature = "macro_debug"))]
236    eprintln!("[step order: Linearity]");
237    let mut order: Vec<NodeId> = Vec::new();
238    let mut planned: BTreeSet<NodeId> = BTreeSet::new();
239
240    let mut queue: VecDeque<NodeId> = VecDeque::new();
241    for node_id in graph.node_ids() {
242        if find_task_type_for_id(graph, node_id)? == CuTaskType::Source {
243            queue.push_back(node_id);
244        }
245    }
246    #[cfg(all(feature = "std", feature = "macro_debug"))]
247    eprintln!("Initial source nodes: {queue:?}");
248
249    while let Some(start_node) = queue.pop_front() {
250        #[cfg(all(feature = "std", feature = "macro_debug"))]
251        eprintln!("→ Starting BFS from source {start_node}");
252        for node_id in graph.bfs_nodes(start_node) {
253            if planned.contains(&node_id) {
254                continue;
255            }
256            if topo_bfs_branch(graph, node_id, &mut order, &mut planned)? {
257                for neighbor in graph.get_neighbor_ids(node_id, CuDirection::Outgoing) {
258                    queue.push_back(neighbor);
259                }
260            }
261        }
262    }
263
264    Ok(StepOrder(order))
265}
266
267/// One branch of the walk: emit nodes reachable from `starting_point` whose
268/// inputs are already planned, aborting at the first step that is not yet ready.
269/// Returns whether any node was emitted (the walk's `handled` flag).
270fn topo_bfs_branch(
271    graph: &CuGraph,
272    starting_point: NodeId,
273    order: &mut Vec<NodeId>,
274    planned: &mut BTreeSet<NodeId>,
275) -> CuResult<bool> {
276    #[cfg(all(feature = "std", feature = "macro_debug"))]
277    eprintln!("-- starting branch from node {starting_point}");
278    let mut handled = false;
279    for id in graph.bfs_nodes(starting_point) {
280        #[cfg(all(feature = "std", feature = "macro_debug"))]
281        eprintln!("  Visiting node: {:?}", graph.get_node(id));
282        if find_task_type_for_id(graph, id)? != CuTaskType::Source {
283            let mut edge_ids = graph.get_dst_edges(id).unwrap_or_default();
284            edge_ids.sort();
285            let mut ready = true;
286            for edge_id in edge_ids {
287                let edge = graph
288                    .edge(edge_id)
289                    .unwrap_or_else(|| panic!("Missing edge {edge_id} for node {id}"));
290                let pid = graph
291                    .get_node_id_by_name(edge.src.as_str())
292                    .unwrap_or_else(|| {
293                        panic!("Missing source node '{}' for edge {edge_id}", edge.src)
294                    });
295                if !planned.contains(&pid) {
296                    #[cfg(all(feature = "std", feature = "macro_debug"))]
297                    eprintln!("      ✗ Input from {pid} not ready, returning");
298                    ready = false;
299                    break;
300                }
301            }
302            if !ready {
303                return Ok(handled);
304            }
305        }
306        // The historical walk had a re-visit path here that reordered an
307        // already-planned step. It is unreachable for validated configs: a node
308        // is planned only once all its producers are planned, so planned ⟹ all
309        // ancestors planned; branches start only at unplanned nodes and BFS
310        // visits each node once, so a branch never reaches a planned node.
311        if planned.contains(&id) {
312            unreachable!("plan re-visit path reached for node {id}");
313        }
314        #[cfg(all(feature = "std", feature = "macro_debug"))]
315        eprintln!("    → Node {id} added to the order");
316        order.push(id);
317        planned.insert(id);
318        handled = true;
319    }
320    #[cfg(all(feature = "std", feature = "macro_debug"))]
321    eprintln!("-- finished branch from node {starting_point} with handled={handled}");
322    Ok(handled)
323}
324
325/// Method for an explicit `order`: complete a pinned task order into a full
326/// step order.
327///
328/// Each bridge rx stage lands immediately before its earliest consumer task in
329/// the pinned order; each bridge tx stage immediately after the last producer
330/// task feeding it. Stages that do not attach to a pinned task fall to the ends
331/// so `check_order` can surface the precedence problem.
332fn pinned_order(graph: &CuGraph, pinned_tasks: &[NodeId]) -> CuResult<StepOrder> {
333    let mut task_position: BTreeMap<NodeId, usize> = BTreeMap::new();
334    for (position, &task) in pinned_tasks.iter().enumerate() {
335        task_position.insert(task, position);
336    }
337
338    let mut before: BTreeMap<usize, Vec<NodeId>> = BTreeMap::new();
339    let mut after: BTreeMap<usize, Vec<NodeId>> = BTreeMap::new();
340    let mut leading: Vec<NodeId> = Vec::new();
341    let mut trailing: Vec<NodeId> = Vec::new();
342
343    for (node_id, node) in graph.get_all_nodes() {
344        if node.get_flavor() != Flavor::Bridge {
345            continue;
346        }
347        match find_task_type_for_id(graph, node_id)? {
348            CuTaskType::Source => {
349                match graph
350                    .get_neighbor_ids(node_id, CuDirection::Outgoing)
351                    .into_iter()
352                    .filter_map(|consumer| task_position.get(&consumer).copied())
353                    .min()
354                {
355                    Some(pos) => before.entry(pos).or_default().push(node_id),
356                    None => leading.push(node_id),
357                }
358            }
359            CuTaskType::Sink => {
360                match graph
361                    .get_neighbor_ids(node_id, CuDirection::Incoming)
362                    .into_iter()
363                    .filter_map(|producer| task_position.get(&producer).copied())
364                    .max()
365                {
366                    Some(pos) => after.entry(pos).or_default().push(node_id),
367                    None => trailing.push(node_id),
368                }
369            }
370            CuTaskType::Regular => trailing.push(node_id),
371        }
372    }
373
374    for stages in before.values_mut() {
375        stages.sort_unstable();
376    }
377    for stages in after.values_mut() {
378        stages.sort_unstable();
379    }
380    leading.sort_unstable();
381    trailing.sort_unstable();
382
383    let mut order = Vec::new();
384    order.append(&mut leading);
385    for (position, &task) in pinned_tasks.iter().enumerate() {
386        if let Some(stages) = before.get(&position) {
387            order.extend(stages.iter().copied());
388        }
389        order.push(task);
390        if let Some(stages) = after.get(&position) {
391            order.extend(stages.iter().copied());
392        }
393    }
394    order.append(&mut trailing);
395
396    Ok(StepOrder(order))
397}
398
399/// Shared legality gate for a step order over `graph`.
400///
401/// Rejects unknown ids, duplicate nodes, missing nodes, and precedence
402/// violations (a step scheduled before one of its inputs). Errors name the
403/// offending task ids; callers that own mission context wrap them.
404pub(crate) fn check_order(graph: &CuGraph, order: &StepOrder) -> CuResult<()> {
405    let mut position: Vec<Option<usize>> = vec![None; graph.node_count()];
406
407    for (index, &node_id) in order.0.iter().enumerate() {
408        let slot = position.get_mut(node_id as usize).ok_or_else(|| {
409            CuError::from(format!("Plan order references unknown node id {node_id}."))
410        })?;
411        if slot.is_some() {
412            return Err(CuError::from(format!(
413                "Task '{}' appears more than once in the plan order.",
414                node_name(graph, node_id)
415            )));
416        }
417        *slot = Some(index);
418    }
419
420    let mut missing: Vec<String> = Vec::new();
421    for node_id in graph.node_ids() {
422        if position[node_id as usize].is_none() {
423            missing.push(node_name(graph, node_id));
424        }
425    }
426    if !missing.is_empty() {
427        missing.sort();
428        return Err(CuError::from(format!(
429            "Execution plan could not include all nodes. Missing: {}. Check for loopback or missing source connections.",
430            missing.join(", ")
431        )));
432    }
433
434    for edge in graph.edges() {
435        let (Some(src), Some(dst)) = (
436            graph.get_node_id_by_name(edge.src.as_str()),
437            graph.get_node_id_by_name(edge.dst.as_str()),
438        ) else {
439            continue;
440        };
441        if position[src as usize] >= position[dst as usize] {
442            return Err(CuError::from(format!(
443                "Task '{}' is scheduled before its input '{}'.",
444                node_name(graph, dst),
445                node_name(graph, src)
446            )));
447        }
448    }
449
450    Ok(())
451}
452
453/// Materialize a validated step order into the concrete execution plan.
454///
455/// Walks the order once, assigning culist output indices in order and resolving
456/// each step's input pack from the already-materialized producers.
457pub(crate) fn plan_from_order(graph: &CuGraph, order: &StepOrder) -> CuResult<CuExecutionLoop> {
458    #[cfg(all(feature = "std", feature = "macro_debug"))]
459    eprintln!("[runtime plan]");
460    let mut plan: Vec<CuExecutionUnit> = Vec::new();
461    let mut next_culist_output_index = 0u32;
462
463    for &id in &order.0 {
464        let node_ref = graph
465            .get_node(id)
466            .ok_or_else(|| CuError::from(format!("Node id {id} not found")))?;
467        let task_type = find_task_type_for_id(graph, id)?;
468        let mut input_msg_indices_types = if task_type == CuTaskType::Source {
469            Vec::new()
470        } else {
471            collect_step_inputs(graph, id, &plan)?
472        };
473        #[cfg(all(feature = "std", feature = "macro_debug"))]
474        eprintln!(
475            "  {task_type:?} node {id} → output index {next_culist_output_index}, inputs {input_msg_indices_types:?}"
476        );
477        let output_msg_pack: Option<CuOutputPack>;
478
479        match task_type {
480            CuTaskType::Source => {
481                let ports = graph.get_node_output_ports_by_id(id)?;
482                if ports.is_empty() {
483                    return Err(CuError::from(format!(
484                        "Source node '{}' has no declared outputs",
485                        node_ref.get_id()
486                    )));
487                }
488                let (msg_types, src_channels) = ports.into_iter().unzip();
489                output_msg_pack = Some(CuOutputPack {
490                    culist_index: next_culist_output_index,
491                    msg_types,
492                    src_channels,
493                });
494                next_culist_output_index += 1;
495            }
496            CuTaskType::Sink => {
497                output_msg_pack = Some(CuOutputPack {
498                    culist_index: next_culist_output_index,
499                    msg_types: Vec::from(["()".to_string()]),
500                    src_channels: vec![None],
501                });
502                next_culist_output_index += 1;
503            }
504            CuTaskType::Regular => {
505                let ports = graph.get_node_output_ports_by_id(id)?;
506                if ports.is_empty() {
507                    return Err(CuError::from(format!(
508                        "Regular node '{}' has no declared outputs",
509                        node_ref.get_id()
510                    )));
511                }
512                let (msg_types, src_channels) = ports.into_iter().unzip();
513                output_msg_pack = Some(CuOutputPack {
514                    culist_index: next_culist_output_index,
515                    msg_types,
516                    src_channels,
517                });
518                next_culist_output_index += 1;
519            }
520        }
521
522        sort_inputs_by_connection_order(&mut input_msg_indices_types);
523        plan.push(CuExecutionUnit::Step(Box::new(CuExecutionStep {
524            node_id: id,
525            node: node_ref.clone(),
526            task_type,
527            phase: CuStepPhase::default(),
528            input_msg_indices_types,
529            output_msg_pack,
530        })));
531    }
532
533    Ok(CuExecutionLoop {
534        steps: plan,
535        loop_count: None,
536    })
537}
538
539/// Resolve a step's input pack from the already-materialized producers.
540///
541/// `check_order` guarantees every producer precedes this node, so each pack is
542/// present.
543fn collect_step_inputs(
544    graph: &CuGraph,
545    id: NodeId,
546    plan: &[CuExecutionUnit],
547) -> CuResult<Vec<CuInputMsg>> {
548    let mut inputs = Vec::new();
549    let mut edge_ids = graph.get_dst_edges(id).unwrap_or_default();
550    edge_ids.sort();
551    for edge_id in edge_ids {
552        let edge = graph
553            .edge(edge_id)
554            .unwrap_or_else(|| panic!("Missing edge {edge_id} for node {id}"));
555        let pid = graph
556            .get_node_id_by_name(edge.src.as_str())
557            .unwrap_or_else(|| panic!("Missing source node '{}' for edge {edge_id}", edge.src));
558        let output_pack = find_output_pack_from_nodeid(pid, plan).ok_or_else(|| {
559            CuError::from(format!(
560                "Plan materialization: input from node {pid} is not available before node {id}"
561            ))
562        })?;
563        let msg_type = edge.msg.as_str();
564        let src_channel = edge.src_channel.as_deref();
565        let src_port = output_pack
566            .msg_types
567            .iter()
568            .zip(output_pack.src_channels.iter())
569            .position(|(msg, ch)| msg == msg_type && ch.as_deref() == src_channel)
570            .unwrap_or_else(|| {
571                panic!("Missing output port for message type '{msg_type}' on node {pid}")
572            });
573        inputs.push(CuInputMsg {
574            culist_index: output_pack.culist_index,
575            msg_type: msg_type.to_string(),
576            src_port,
577            edge_id,
578            connection_order: edge.order,
579        });
580    }
581    Ok(inputs)
582}
583
584fn find_output_pack_from_nodeid(
585    node_id: NodeId,
586    steps: &[CuExecutionUnit],
587) -> Option<CuOutputPack> {
588    for step in steps {
589        match step {
590            CuExecutionUnit::Loop(loop_unit) => {
591                if let Some(output_pack) = find_output_pack_from_nodeid(node_id, &loop_unit.steps) {
592                    return Some(output_pack);
593                }
594            }
595            CuExecutionUnit::Step(step) if step.node_id == node_id => {
596                return step.output_msg_pack.clone();
597            }
598            _ => {}
599        }
600    }
601    None
602}
603
604/// Preserve the original serialized connection order across missions.
605///
606/// Edge ids are assigned per mission graph, so they are not stable enough to
607/// describe a shared input layout when missions selectively include connections.
608fn sort_inputs_by_connection_order(input_msg_indices_types: &mut [CuInputMsg]) {
609    input_msg_indices_types.sort_by_key(|input| input.connection_order);
610}
611
612fn node_name(graph: &CuGraph, node_id: NodeId) -> String {
613    graph
614        .get_node(node_id)
615        .map(|node| node.get_id())
616        .unwrap_or_else(|| format!("node_id_{node_id}"))
617}
618
619#[derive(Clone, Copy, Debug, PartialEq, Eq)]
620enum ChannelDirection {
621    Rx,
622    Tx,
623}
624
625fn channel_is_used(
626    graph: &CuGraph,
627    bridge_id: &str,
628    channel_id: &str,
629    direction: ChannelDirection,
630) -> bool {
631    graph.edges().any(|connection| match direction {
632        ChannelDirection::Rx => {
633            connection.src == bridge_id && connection.src_channel.as_deref() == Some(channel_id)
634        }
635        ChannelDirection::Tx => {
636            connection.dst == bridge_id && connection.dst_channel.as_deref() == Some(channel_id)
637        }
638    })
639}
640
641fn inferred_output_name(node: &Node, task_type: CuTaskType) -> String {
642    let rust_type = node.get_type();
643    if node.anytime().is_some() {
644        return format!(
645            "<<{rust_type} as cu29::cutask_anytime::CuAnytimeTask>::Output<'static> as cu29::cutask::CuSingleOutputMsg>::Payload"
646        );
647    }
648    let task_trait = match task_type {
649        CuTaskType::Source => "cu29::cutask::CuSrcTask",
650        CuTaskType::Regular => "cu29::cutask::CuTask",
651        CuTaskType::Sink => unreachable!("sinks do not have inferred outputs"),
652    };
653    format!(
654        "<<{rust_type} as {task_trait}>::Output<'static> as cu29::cutask::CuSingleOutputMsg>::Payload"
655    )
656}
657
658/// The synthetic graph a planner orders, plus the identity of every node.
659struct PlanGraph {
660    graph: CuGraph,
661    entities: Vec<PlanEntity>,
662    plan_to_original: Vec<Option<NodeId>>,
663}
664
665/// Build the same synthetic task/bridge graph used by generated runtimes.
666/// Mission-agnostic: `graph` selects the mission and callers wrap errors with
667/// its name.
668fn build_plan_graph(config: &CuConfig, graph: &CuGraph) -> CuResult<PlanGraph> {
669    let mut plan_graph = CuGraph::default();
670    let mut entities = Vec::new();
671    let mut plan_to_original = Vec::new();
672    let mut original_to_plan = Vec::new();
673    original_to_plan.resize(graph.node_count(), None);
674
675    let mut task_index = 0usize;
676    for (original_node_id, node) in graph.get_all_nodes() {
677        if node.get_flavor() != Flavor::Task {
678            continue;
679        }
680        let plan_node_id = plan_graph.add_node(node.clone())?;
681        debug_assert_eq!(plan_node_id as usize, entities.len());
682        original_to_plan[original_node_id as usize] = Some(plan_node_id);
683        plan_to_original.push(Some(original_node_id));
684        entities.push(PlanEntity {
685            key: format!("task:{}", node.get_id()),
686            label: node.get_id(),
687            kind: PlanEntityKind::Task {
688                original_node_id,
689                task_index,
690            },
691        });
692        task_index += 1;
693    }
694
695    // A declared task kind permits output inference when an output is marked
696    // unconnected without an explicit message type. The macro later parses
697    // this projection as Rust syntax when it builds the CopperList tuple.
698    for (original_node_id, node) in graph.get_all_nodes() {
699        if node.get_flavor() != Flavor::Task || node.get_declared_task_kind().is_none() {
700            continue;
701        }
702        let task_type = find_task_type_for_id(graph, original_node_id)?;
703        if task_type == CuTaskType::Sink
704            || !graph
705                .get_node_output_msg_types_by_id(original_node_id)?
706                .is_empty()
707        {
708            continue;
709        }
710        let plan_node_id = original_to_plan[original_node_id as usize]
711            .expect("task was mirrored into the plan graph");
712        let message_type = inferred_output_name(node, task_type);
713        plan_graph
714            .get_node_mut(plan_node_id)
715            .expect("mirrored task is present")
716            .add_nc_output(&message_type, usize::MAX);
717    }
718
719    // The generated bridge representation creates all used receive stages,
720    // then all used transmit stages, for each bridge in config order.
721    let mut channel_nodes: Vec<(usize, usize, ChannelDirection, NodeId)> = Vec::new();
722    for (bridge_config_index, bridge) in config.bridges.iter().enumerate() {
723        if graph.get_node_id_by_name(&bridge.id).is_none() {
724            continue;
725        }
726        for direction in [ChannelDirection::Rx, ChannelDirection::Tx] {
727            for (channel_config_index, channel) in bridge.channels.iter().enumerate() {
728                let (channel_id, channel_direction) = match channel {
729                    BridgeChannelConfigRepresentation::Rx { id, .. } => (id, ChannelDirection::Rx),
730                    BridgeChannelConfigRepresentation::Tx { id, .. } => (id, ChannelDirection::Tx),
731                };
732                if channel_direction != direction
733                    || !channel_is_used(graph, &bridge.id, channel_id, direction)
734                {
735                    continue;
736                }
737
738                let direction_label = match direction {
739                    ChannelDirection::Rx => "rx",
740                    ChannelDirection::Tx => "tx",
741                };
742                let label = format!("{}::{direction_label}::{channel_id}", bridge.id);
743                let synthetic_type = match direction {
744                    ChannelDirection::Rx => "__CuBridgeRxChannel",
745                    ChannelDirection::Tx => "__CuBridgeTxChannel",
746                };
747                let mut node = Node::new(&label, synthetic_type);
748                node.set_flavor(Flavor::Bridge);
749                let plan_node_id = plan_graph.add_node(node)?;
750                debug_assert_eq!(plan_node_id as usize, entities.len());
751                plan_to_original.push(None);
752                entities.push(PlanEntity {
753                    key: format!("bridge:{}:{direction_label}:{channel_id}", bridge.id),
754                    label,
755                    kind: match direction {
756                        ChannelDirection::Rx => PlanEntityKind::BridgeRx {
757                            bridge_config_index,
758                            channel_config_index,
759                        },
760                        ChannelDirection::Tx => PlanEntityKind::BridgeTx {
761                            bridge_config_index,
762                            channel_config_index,
763                        },
764                    },
765                });
766                channel_nodes.push((
767                    bridge_config_index,
768                    channel_config_index,
769                    direction,
770                    plan_node_id,
771                ));
772            }
773        }
774    }
775
776    for connection in graph.edges() {
777        let src_plan = if let Some(channel_id) = connection.src_channel.as_deref() {
778            find_channel_plan_node(
779                config,
780                &channel_nodes,
781                &connection.src,
782                channel_id,
783                ChannelDirection::Rx,
784            )?
785        } else {
786            let original_id = graph.get_node_id_by_name(&connection.src).ok_or_else(|| {
787                CuError::from(format!("Unknown source node '{}'", connection.src))
788            })?;
789            original_to_plan[original_id as usize].ok_or_else(|| {
790                CuError::from(format!("Source node '{}' is not a task", connection.src))
791            })?
792        };
793        let dst_plan = if let Some(channel_id) = connection.dst_channel.as_deref() {
794            find_channel_plan_node(
795                config,
796                &channel_nodes,
797                &connection.dst,
798                channel_id,
799                ChannelDirection::Tx,
800            )?
801        } else {
802            let original_id = graph.get_node_id_by_name(&connection.dst).ok_or_else(|| {
803                CuError::from(format!("Unknown destination node '{}'", connection.dst))
804            })?;
805            original_to_plan[original_id as usize].ok_or_else(|| {
806                CuError::from(format!(
807                    "Destination node '{}' is not a task",
808                    connection.dst
809                ))
810            })?
811        };
812
813        plan_graph
814            .connect_ext_with_order(
815                src_plan,
816                dst_plan,
817                &connection.msg,
818                connection.missions.clone(),
819                None,
820                None,
821                connection.order,
822            )
823            .map_err(|error| CuError::from(error.to_string()))?;
824    }
825
826    Ok(PlanGraph {
827        graph: plan_graph,
828        entities,
829        plan_to_original,
830    })
831}
832
833/// Choice (order) then bookkeeping (materialize): one legality gate, one
834/// shared materializer, for every planner and every consumer.
835fn assemble_from_order(plan_graph: PlanGraph, order: StepOrder) -> CuResult<AssembledPlan> {
836    check_order(&plan_graph.graph, &order)?;
837    let mut execution = plan_from_order(&plan_graph.graph, &order)?;
838    expand_anytime_steps(&mut execution)?;
839    Ok(AssembledPlan {
840        execution,
841        entities: plan_graph.entities,
842        plan_to_original: plan_graph.plan_to_original,
843    })
844}
845
846/// Assemble the generated execution plan, ordering it with the planner the
847/// config selects (`runtime.planner`, defaulting to [`Linearity`]).
848///
849/// Only ship-with-copper planners can be instantiated here; a config naming an
850/// out-of-tree planner must carry its build-time resolved order — see
851/// [`assemble_runtime_plan_from_step_keys`] and [`emit_plan`].
852#[doc(hidden)]
853pub fn assemble_runtime_plan(config: &CuConfig, graph: &CuGraph) -> CuResult<AssembledPlan> {
854    let planner: Box<dyn CuPlanner> = match config.planner_config() {
855        None => Box::new(Linearity),
856        Some(selection) => instantiate_builtin_planner(selection.get_type(), selection.get_config())?
857            .ok_or_else(|| {
858                CuError::from(format!(
859                    "Planner '{}' is not shipped with copper (shipped: {}) and the config carries no resolved order for this mission. Resolve it at build time: call cu29::planner::emit_plan::<{}>(\"<config>.ron\") from the application's build.rs.",
860                    selection.get_type(),
861                    BUILTIN_PLANNERS.join(", "),
862                    selection.get_type(),
863                ))
864            })?,
865    };
866    assemble_runtime_plan_with_planner(config, graph, planner.as_ref())
867}
868
869/// Assemble with an explicit planner instance, bypassing the config selection.
870#[doc(hidden)]
871pub fn assemble_runtime_plan_with_planner(
872    config: &CuConfig,
873    graph: &CuGraph,
874    planner: &dyn CuPlanner,
875) -> CuResult<AssembledPlan> {
876    let plan_graph = build_plan_graph(config, graph)?;
877    let order = planner.plan(&plan_graph.graph)?;
878    assemble_from_order(plan_graph, order)
879}
880
881/// Assemble from a step order already resolved at build time, given as the
882/// stable step keys [`emit_plan`] emits and codegen bakes into the config.
883#[doc(hidden)]
884pub fn assemble_runtime_plan_from_step_keys(
885    config: &CuConfig,
886    graph: &CuGraph,
887    step_keys: &[String],
888) -> CuResult<AssembledPlan> {
889    let plan_graph = build_plan_graph(config, graph)?;
890    let by_key: BTreeMap<&str, NodeId> = plan_graph
891        .entities
892        .iter()
893        .enumerate()
894        .map(|(id, entity)| (entity.key.as_str(), id as NodeId))
895        .collect();
896    let order = step_keys
897        .iter()
898        .map(|key| {
899            by_key.get(key.as_str()).copied().ok_or_else(|| {
900                CuError::from(format!(
901                    "Resolved plan references unknown step '{key}'; the baked order no longer matches the config."
902                ))
903            })
904        })
905        .collect::<CuResult<Vec<NodeId>>>()
906        .map(StepOrder)?;
907    assemble_from_order(plan_graph, order)
908}
909
910fn find_channel_plan_node(
911    config: &CuConfig,
912    channel_nodes: &[(usize, usize, ChannelDirection, NodeId)],
913    bridge_id: &str,
914    channel_id: &str,
915    direction: ChannelDirection,
916) -> CuResult<NodeId> {
917    channel_nodes
918        .iter()
919        .find_map(
920            |(bridge_index, channel_index, candidate_direction, node_id)| {
921                let bridge = &config.bridges[*bridge_index];
922                let channel = &bridge.channels[*channel_index];
923                (bridge.id == bridge_id
924                    && channel.id() == channel_id
925                    && *candidate_direction == direction)
926                    .then_some(*node_id)
927            },
928        )
929        .ok_or_else(|| {
930            CuError::from(format!(
931                "Bridge channel '{bridge_id}/{channel_id}' is missing from the execution plan"
932            ))
933        })
934}
935
936/// Sorted mission views used by proc-macro generation and visualizers.
937#[doc(hidden)]
938pub fn mission_graphs(config: &CuConfig) -> Vec<(String, &CuGraph)> {
939    match &config.graphs {
940        ConfigGraphs::Simple(graph) => vec![("default".to_string(), graph)],
941        ConfigGraphs::Missions(graphs) => {
942            let mut missions: Vec<_> = graphs
943                .iter()
944                .map(|(mission, graph)| (mission.clone(), graph))
945                .collect();
946            missions.sort_by(|left, right| left.0.cmp(&right.0));
947            missions
948        }
949    }
950}
951
952/// Return a stable key for a concrete serial step.
953#[doc(hidden)]
954pub fn step_key(
955    mission: &str,
956    entity: &PlanEntity,
957    phase: CuStepPhase,
958    refine_ordinal: Option<u32>,
959) -> String {
960    let phase = match phase {
961        CuStepPhase::Whole => "whole".to_string(),
962        CuStepPhase::AnytimeBase => "base".to_string(),
963        CuStepPhase::AnytimeRefine => format!("refine:{}", refine_ordinal.unwrap_or(0)),
964    };
965    format!("mission:{mission}|{}|phase:{phase}", entity.key)
966}
967
968/// Where [`emit_plan`] writes its artifact inside `OUT_DIR`.
969#[doc(hidden)]
970pub const PLAN_ARTIFACT_FILE: &str = "cu29_plan.ron";
971
972/// A build-time resolved plan: the planner that produced it, a digest of the
973/// config it was computed from, and the step order (stable step keys) per
974/// mission.
975#[doc(hidden)]
976#[derive(Serialize, Deserialize)]
977pub struct PlanArtifact {
978    pub planner_type: String,
979    pub config_digest: String,
980    pub orders: BTreeMap<String, Vec<String>>,
981}
982
983/// FNV-1a digest of the effective config, shared by [`emit_plan`] and the
984/// macro to detect a stale artifact.
985///
986/// Digests a canonical rendering with map entries sorted: `CuConfig` maps are
987/// `HashMap`s whose serialization order differs between the build.rs process
988/// and the rustc process, so the raw RON bytes cannot be compared.
989#[doc(hidden)]
990pub fn config_digest(config: &CuConfig) -> CuResult<String> {
991    let ron = config.serialize_ron()?;
992    let value: ron::Value = CuConfig::get_options()
993        .from_str(&ron)
994        .map_err(|e| CuError::from(format!("Could not re-parse the config for digesting: {e}")))?;
995    let mut canonical = String::new();
996    write_canonical_ron(&value, &mut canonical);
997    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
998    for byte in canonical.into_bytes() {
999        hash ^= u64::from(byte);
1000        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1001    }
1002    Ok(format!("{hash:016x}"))
1003}
1004
1005fn write_canonical_ron(value: &ron::Value, out: &mut String) {
1006    use core::fmt::Write;
1007    match value {
1008        ron::Value::Map(map) => {
1009            let mut entries: Vec<(String, &ron::Value)> = map
1010                .iter()
1011                .map(|(key, entry)| {
1012                    let mut rendered = String::new();
1013                    write_canonical_ron(key, &mut rendered);
1014                    (rendered, entry)
1015                })
1016                .collect();
1017            entries.sort_by(|left, right| left.0.cmp(&right.0));
1018            out.push('{');
1019            for (key, entry) in entries {
1020                out.push_str(&key);
1021                out.push(':');
1022                write_canonical_ron(entry, out);
1023                out.push(',');
1024            }
1025            out.push('}');
1026        }
1027        ron::Value::Seq(entries) => {
1028            out.push('[');
1029            for entry in entries {
1030                write_canonical_ron(entry, out);
1031                out.push(',');
1032            }
1033            out.push(']');
1034        }
1035        other => {
1036            let _ = write!(out, "{other:?}");
1037        }
1038    }
1039}
1040
1041/// Resolve an out-of-tree [`CuPlanner`] for `config_path` and write the
1042/// resulting step orders where `#[copper_runtime]` picks them up.
1043///
1044/// Call it from the application's `build.rs`:
1045///
1046/// ```rust,ignore
1047/// fn main() {
1048///     cu29_build::setup();
1049///     cu29::planner::emit_plan::<my_planners::Alphabetical>("copperconfig.ron").unwrap();
1050/// }
1051/// ```
1052///
1053/// `config_path` is relative to the crate root, like the macro's `config`
1054/// attribute. Honors the crate's Cargo feature set (`CARGO_CFG_FEATURE`) like
1055/// `#[copper_runtime]` does.
1056#[cfg(feature = "std")]
1057pub fn emit_plan<P: CuPlanner>(config_path: &str) -> CuResult<()> {
1058    let out_dir = std::env::var("OUT_DIR")
1059        .map_err(|_| CuError::from("emit_plan must run from a build.rs (OUT_DIR is not set)"))?;
1060    let artifact = build_plan_artifact::<P>(config_path)?;
1061    let ron = ron::ser::to_string(&artifact)
1062        .map_err(|e| CuError::from(format!("Could not serialize the plan artifact: {e}")))?;
1063    let path = std::path::Path::new(&out_dir).join(PLAN_ARTIFACT_FILE);
1064    std::fs::write(&path, ron)
1065        .map_err(|e| CuError::new_with_cause("Could not write the plan artifact", e))?;
1066    println!("cargo::rerun-if-changed={config_path}");
1067    Ok(())
1068}
1069
1070/// Run planner `P` over every mission of the config at `config_path`.
1071#[cfg(feature = "std")]
1072fn build_plan_artifact<P: CuPlanner>(config_path: &str) -> CuResult<PlanArtifact> {
1073    // Build scripts see the raw Cargo feature list; cu29_build::setup()
1074    // forwards the same list to the macro as COPPER_CFG_FEATURES.
1075    let features_var = std::env::var("CARGO_CFG_FEATURE").unwrap_or_default();
1076    let features: Vec<&str> = features_var.split(',').filter(|f| !f.is_empty()).collect();
1077    let config = crate::config::read_configuration_with_features(config_path, &features)?;
1078    let planner = P::new(
1079        config
1080            .planner_config()
1081            .and_then(|selection| selection.get_config()),
1082    )?;
1083    let mut orders = BTreeMap::new();
1084    for (mission, graph) in mission_graphs(&config) {
1085        let keys = (|| -> CuResult<Vec<String>> {
1086            let plan_graph = build_plan_graph(&config, graph)?;
1087            let order = planner.plan(&plan_graph.graph)?;
1088            check_order(&plan_graph.graph, &order)?;
1089            Ok(order
1090                .0
1091                .iter()
1092                .map(|&id| plan_graph.entities[id as usize].key.clone())
1093                .collect())
1094        })()
1095        .map_err(|e| CuError::from(format!("mission '{mission}': {e}")))?;
1096        orders.insert(mission, keys);
1097    }
1098    Ok(PlanArtifact {
1099        planner_type: core::any::type_name::<P>().to_string(),
1100        config_digest: config_digest(&config)?,
1101        orders,
1102    })
1103}
1104
1105/// Read back an artifact written by [`emit_plan`].
1106#[doc(hidden)]
1107#[cfg(feature = "std")]
1108pub fn read_plan_artifact(path: &std::path::Path) -> CuResult<PlanArtifact> {
1109    let text = std::fs::read_to_string(path)
1110        .map_err(|e| CuError::new_with_cause("Could not read the plan artifact", e))?;
1111    ron::from_str(&text)
1112        .map_err(|e| CuError::from(format!("Could not parse the plan artifact: {e}")))
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118    use crate::curuntime::CuExecutionUnit;
1119
1120    fn config(ron: &str) -> CuConfig {
1121        CuConfig::deserialize_ron(ron).expect("valid planner test config")
1122    }
1123
1124    fn step_labels(plan: &AssembledPlan) -> Vec<String> {
1125        plan.execution
1126            .steps
1127            .iter()
1128            .map(|unit| match unit {
1129                CuExecutionUnit::Step(step) => plan.entities[step.node_id as usize].label.clone(),
1130                CuExecutionUnit::Loop(_) => panic!("unexpected nested loop"),
1131            })
1132            .collect()
1133    }
1134
1135    #[test]
1136    fn plans_diamond_fan_in_with_stable_input_order() {
1137        let config = config(
1138            r#"(
1139                tasks: [
1140                    (id: "left", type: "demo::Left"),
1141                    (id: "right", type: "demo::Right"),
1142                    (id: "join", type: "demo::Join"),
1143                    (id: "sink", type: "demo::Sink"),
1144                ],
1145                cnx: [
1146                    (src: "right", dst: "join", msg: "demo::RightMsg"),
1147                    (src: "left", dst: "join", msg: "demo::LeftMsg"),
1148                    (src: "join", dst: "sink", msg: "demo::Joined"),
1149                ],
1150            )"#,
1151        );
1152        let graph = config.get_graph(None).unwrap();
1153        let plan = assemble_runtime_plan(&config, graph).unwrap();
1154        assert_eq!(step_labels(&plan), ["left", "right", "join", "sink"]);
1155        let join = plan
1156            .execution
1157            .steps
1158            .iter()
1159            .find_map(|unit| match unit {
1160                CuExecutionUnit::Step(step) if step.node.get_id() == "join" => Some(step),
1161                _ => None,
1162            })
1163            .unwrap();
1164        assert_eq!(join.input_msg_indices_types.len(), 2);
1165        assert_eq!(join.input_msg_indices_types[0].msg_type, "demo::RightMsg");
1166        assert_eq!(join.input_msg_indices_types[1].msg_type, "demo::LeftMsg");
1167    }
1168
1169    #[test]
1170    fn inserts_bridge_rx_and_tx_channel_stages() {
1171        let config = config(
1172            r#"(
1173                tasks: [
1174                    (id: "task", type: "demo::Task"),
1175                ],
1176                bridges: [
1177                    (
1178                        id: "radio",
1179                        type: "demo::Radio",
1180                        channels: [Rx(id: "incoming"), Tx(id: "outgoing")],
1181                    ),
1182                ],
1183                cnx: [
1184                    (src: "radio/incoming", dst: "task", msg: "demo::In"),
1185                    (src: "task", dst: "radio/outgoing", msg: "demo::Out"),
1186                ],
1187            )"#,
1188        );
1189        let graph = config.get_graph(None).unwrap();
1190        let plan = assemble_runtime_plan(&config, graph).unwrap();
1191        assert_eq!(
1192            step_labels(&plan),
1193            ["radio::rx::incoming", "task", "radio::tx::outgoing"]
1194        );
1195        assert!(matches!(
1196            plan.entities[1].kind,
1197            PlanEntityKind::BridgeRx { .. }
1198        ));
1199        assert!(matches!(
1200            plan.entities[2].kind,
1201            PlanEntityKind::BridgeTx { .. }
1202        ));
1203    }
1204
1205    #[test]
1206    fn synthesizes_declared_unconnected_output() {
1207        let config = config(
1208            r#"(
1209                tasks: [(id: "generated", type: "demo::Generated", kind: source)],
1210                cnx: [],
1211            )"#,
1212        );
1213        let graph = config.get_graph(None).unwrap();
1214        let plan = assemble_runtime_plan(&config, graph).unwrap();
1215        let CuExecutionUnit::Step(step) = &plan.execution.steps[0] else {
1216            panic!("expected one generated step")
1217        };
1218        let output = step.output_msg_pack.as_ref().unwrap();
1219        assert_eq!(output.culist_index, 0);
1220        assert!(output.msg_types[0].contains("CuSingleOutputMsg"));
1221        assert!(output.msg_types[0].contains("CuSrcTask"));
1222    }
1223
1224    // ---- Pinned resolution and ordering ----
1225
1226    /// radio(rx incoming, tx outgoing) feeding/consuming a small task chain,
1227    /// with `planner` as its `runtime.planner` section.
1228    fn pinned_graph(planner: &str) -> CuConfig {
1229        config(&format!(
1230            r#"(
1231                tasks: [
1232                    (id: "cam", type: "demo::Cam"),
1233                    (id: "ekf", type: "demo::Ekf"),
1234                    (id: "motor", type: "demo::Motor"),
1235                ],
1236                bridges: [(
1237                    id: "radio",
1238                    type: "demo::Radio",
1239                    channels: [Rx(id: "incoming"), Tx(id: "outgoing")],
1240                )],
1241                cnx: [
1242                    (src: "radio/incoming", dst: "cam", msg: "demo::In"),
1243                    (src: "cam", dst: "ekf", msg: "demo::Frame"),
1244                    (src: "ekf", dst: "motor", msg: "demo::State"),
1245                    (src: "motor", dst: "radio/outgoing", msg: "demo::Cmd"),
1246                ],
1247                runtime: (planner: {planner}),
1248            )"#
1249        ))
1250    }
1251
1252    fn pinned(ids: &[&str]) -> String {
1253        let quoted: Vec<String> = ids.iter().map(|id| format!("{id:?}")).collect();
1254        format!(
1255            r#"(type: "cu29::planner::Pinned", config: {{ "order": [{}] }})"#,
1256            quoted.join(", ")
1257        )
1258    }
1259
1260    #[test]
1261    fn pinned_plan_weaves_bridge_stages_and_matches_task_order() {
1262        let config = pinned_graph(&pinned(&["cam", "ekf", "motor"]));
1263        let graph = config.get_graph(None).unwrap();
1264        let plan = assemble_runtime_plan(&config, graph).unwrap();
1265        assert_eq!(
1266            step_labels(&plan),
1267            [
1268                "radio::rx::incoming",
1269                "cam",
1270                "ekf",
1271                "motor",
1272                "radio::tx::outgoing"
1273            ]
1274        );
1275    }
1276
1277    #[test]
1278    fn pinned_plan_rejects_bad_id_lists() {
1279        let rejects = |planner: &str| {
1280            let config = pinned_graph(planner);
1281            let graph = config.get_graph(None).unwrap();
1282            assemble_runtime_plan(&config, graph)
1283                .err()
1284                .unwrap()
1285                .to_string()
1286        };
1287
1288        let err = rejects(&pinned(&["cam", "ekf"]));
1289        assert!(err.contains("missing"), "{err}");
1290
1291        let err = rejects(&pinned(&["radio::rx::incoming", "cam", "ekf", "motor"]));
1292        assert!(err.contains("bridge stage"), "{err}");
1293
1294        let err = rejects(&pinned(&["cam", "ekf", "motor", "ghost"]));
1295        assert!(err.contains("unknown task 'ghost'"), "{err}");
1296
1297        let err = rejects(&pinned(&["cam", "cam", "ekf"]));
1298        assert!(err.contains("more than once"), "{err}");
1299
1300        let err = rejects(r#"(type: "cu29::planner::Pinned")"#);
1301        assert!(err.contains("needs config"), "{err}");
1302
1303        // A type copper does not ship needs a build-time resolved order.
1304        let err = rejects(r#"(type: "acme::Planner")"#);
1305        assert!(err.contains("emit_plan"), "{err}");
1306    }
1307
1308    // ---- Out-of-tree planners ----
1309
1310    /// Kahn's algorithm with a reverse-alphabetical tie-break: a valid order a
1311    /// third-party planner could produce, distinct from `Linearity`.
1312    struct ReverseAlpha;
1313
1314    impl CuPlanner for ReverseAlpha {
1315        fn new(_config: Option<&ComponentConfig>) -> CuResult<Self> {
1316            Ok(ReverseAlpha)
1317        }
1318
1319        fn plan(&self, graph: &CuGraph) -> CuResult<StepOrder> {
1320            let mut order = Vec::new();
1321            let mut planned: BTreeSet<NodeId> = BTreeSet::new();
1322            while order.len() < graph.node_count() {
1323                let next = graph
1324                    .get_all_nodes()
1325                    .into_iter()
1326                    .filter(|(id, _)| !planned.contains(id))
1327                    .filter(|(id, _)| {
1328                        graph
1329                            .get_neighbor_ids(*id, CuDirection::Incoming)
1330                            .iter()
1331                            .all(|input| planned.contains(input))
1332                    })
1333                    .max_by_key(|(_, node)| node.get_id())
1334                    .map(|(id, _)| id)
1335                    .expect("acyclic graph always has a ready node");
1336                planned.insert(next);
1337                order.push(next);
1338            }
1339            Ok(StepOrder(order))
1340        }
1341    }
1342
1343    #[test]
1344    fn custom_planner_orders_the_plan() {
1345        let config = config(
1346            r#"(
1347                tasks: [
1348                    (id: "left", type: "demo::Left"),
1349                    (id: "right", type: "demo::Right"),
1350                    (id: "join", type: "demo::Join"),
1351                    (id: "sink", type: "demo::Sink"),
1352                ],
1353                cnx: [
1354                    (src: "left", dst: "join", msg: "demo::LeftMsg"),
1355                    (src: "right", dst: "join", msg: "demo::RightMsg"),
1356                    (src: "join", dst: "sink", msg: "demo::Joined"),
1357                ],
1358            )"#,
1359        );
1360        let graph = config.get_graph(None).unwrap();
1361        let plan = assemble_runtime_plan_with_planner(&config, graph, &ReverseAlpha).unwrap();
1362        assert_eq!(step_labels(&plan), ["right", "left", "join", "sink"]);
1363
1364        // The same order replays from baked step keys.
1365        let keys: Vec<String> = plan
1366            .execution
1367            .steps
1368            .iter()
1369            .map(|unit| match unit {
1370                CuExecutionUnit::Step(step) => plan.entities[step.node_id as usize].key.clone(),
1371                CuExecutionUnit::Loop(_) => panic!("unexpected nested loop"),
1372            })
1373            .collect();
1374        let replayed = assemble_runtime_plan_from_step_keys(&config, graph, &keys).unwrap();
1375        assert_eq!(step_labels(&replayed), step_labels(&plan));
1376
1377        let err = assemble_runtime_plan_from_step_keys(&config, graph, &["task:ghost".to_string()])
1378            .err()
1379            .unwrap()
1380            .to_string();
1381        assert!(err.contains("unknown step 'task:ghost'"), "{err}");
1382    }
1383
1384    /// An illegal planner output is rejected by the shared legality gate.
1385    struct Backwards;
1386
1387    impl CuPlanner for Backwards {
1388        fn new(_config: Option<&ComponentConfig>) -> CuResult<Self> {
1389            Ok(Backwards)
1390        }
1391
1392        fn plan(&self, graph: &CuGraph) -> CuResult<StepOrder> {
1393            let StepOrder(mut order) = topo_bfs_order(graph)?;
1394            order.reverse();
1395            Ok(StepOrder(order))
1396        }
1397    }
1398
1399    #[test]
1400    fn illegal_planner_output_is_rejected() {
1401        let config = build_config(&["s", "k"], &[("s", "k", "m")]);
1402        let graph = config.get_graph(None).unwrap();
1403        let err = assemble_runtime_plan_with_planner(&config, graph, &Backwards)
1404            .err()
1405            .unwrap()
1406            .to_string();
1407        assert!(err.contains("scheduled before its input"), "{err}");
1408    }
1409
1410    #[test]
1411    fn canonical_ron_ignores_map_entry_order() {
1412        // The digest must not depend on map serialization order: emit_plan
1413        // (build.rs) and the macro (rustc) run in different processes with
1414        // different HashMap seeds.
1415        let render = |txt: &str| {
1416            let value: ron::Value = ron::from_str(txt).unwrap();
1417            let mut out = String::new();
1418            write_canonical_ron(&value, &mut out);
1419            out
1420        };
1421        assert_eq!(
1422            render(r#"{"a": 1, "b": [2, 3], "c": {"x": 4, "y": 5}}"#),
1423            render(r#"{"c": {"y": 5, "x": 4}, "b": [2, 3], "a": 1}"#),
1424        );
1425        assert_ne!(render(r#"{"b": [2, 3]}"#), render(r#"{"b": [3, 2]}"#));
1426    }
1427
1428    #[test]
1429    fn check_order_flags_precedence_and_missing() {
1430        let config = build_config(&["s", "k"], &[("s", "k", "m")]);
1431        let graph = config.get_graph(None).unwrap();
1432        let s = graph.get_node_id_by_name("s").unwrap();
1433        let k = graph.get_node_id_by_name("k").unwrap();
1434
1435        // Consumer before producer.
1436        let err = check_order(graph, &StepOrder(vec![k, s])).unwrap_err();
1437        assert!(
1438            err.to_string().contains("scheduled before its input"),
1439            "{err}"
1440        );
1441
1442        // A node left out of the order.
1443        let err = check_order(graph, &StepOrder(vec![s])).unwrap_err();
1444        assert!(err.to_string().contains("Missing"), "{err}");
1445    }
1446
1447    // ---- Differential golden test: legacy walk vs new pipeline ----
1448
1449    /// Build a config from plain node ids and `(src, dst, msg)` edges.
1450    fn build_config(nodes: &[&str], edges: &[(&str, &str, &str)]) -> CuConfig {
1451        let mut config = CuConfig::default();
1452        let graph = config.get_graph_mut(None).unwrap();
1453        let mut ids: BTreeMap<String, NodeId> = BTreeMap::new();
1454        for &name in nodes {
1455            let id = graph.add_node(Node::new(name, "demo::T")).unwrap();
1456            ids.insert(name.to_string(), id);
1457        }
1458        for &(src, dst, msg) in edges {
1459            graph.connect(ids[src], ids[dst], msg).unwrap();
1460        }
1461        config
1462    }
1463
1464    /// Named DAGs the golden test replays: hand-written topologies (chain,
1465    /// fan-out, diamond, multi-source) plus seeded layered graphs.
1466    fn corpus() -> Vec<(String, CuConfig)> {
1467        // Bridge stages are just synthetic source/sink nodes to the walk, so a
1468        // corpus of source/regular/sink DAGs covers the bridge case too.
1469        let mut cases = vec![
1470            (
1471                "chain".to_string(),
1472                build_config(
1473                    &["s", "r1", "r2", "k"],
1474                    &[("s", "r1", "m0"), ("r1", "r2", "m1"), ("r2", "k", "m2")],
1475                ),
1476            ),
1477            (
1478                "fanout_shared_msg".to_string(),
1479                build_config(
1480                    &["s", "a", "b", "c"],
1481                    &[("s", "a", "m"), ("s", "b", "m"), ("s", "c", "n")],
1482                ),
1483            ),
1484            (
1485                "fanin_multisource".to_string(),
1486                build_config(
1487                    &["s1", "s2", "s3", "k"],
1488                    &[("s2", "k", "m2"), ("s1", "k", "m1"), ("s3", "k", "m3")],
1489                ),
1490            ),
1491            (
1492                "diamond".to_string(),
1493                build_config(
1494                    &["s", "a", "b", "j", "k"],
1495                    &[
1496                        ("s", "a", "m0"),
1497                        ("s", "b", "m1"),
1498                        ("a", "j", "ma"),
1499                        ("b", "j", "mb"),
1500                        ("j", "k", "mj"),
1501                    ],
1502                ),
1503            ),
1504            (
1505                "bridge_like".to_string(),
1506                build_config(
1507                    &["rx1", "rx2", "r", "tx1", "tx2"],
1508                    &[
1509                        ("rx1", "r", "m1"),
1510                        ("rx2", "r", "m2"),
1511                        ("r", "tx1", "o1"),
1512                        ("r", "tx2", "o2"),
1513                    ],
1514                ),
1515            ),
1516            (
1517                "multisource_layers".to_string(),
1518                build_config(
1519                    &["s1", "s2", "r1", "r2", "k"],
1520                    &[
1521                        ("s1", "r1", "a"),
1522                        ("s2", "r1", "b"),
1523                        ("s1", "r2", "c"),
1524                        ("s2", "r2", "d"),
1525                        ("r1", "k", "e"),
1526                        ("r2", "k", "f"),
1527                    ],
1528                ),
1529            ),
1530            (
1531                "side_branch".to_string(),
1532                build_config(
1533                    &["s", "r1", "r2", "r3", "k1", "k2"],
1534                    &[
1535                        ("s", "r1", "m0"),
1536                        ("r1", "r2", "m1"),
1537                        ("r1", "r3", "m2"),
1538                        ("r2", "k1", "m3"),
1539                        ("r3", "k2", "m4"),
1540                    ],
1541                ),
1542            ),
1543        ];
1544        // Deterministic layered DAGs with LCG-varied edges.
1545        for seed in 0u64..6 {
1546            cases.push((format!("layered_{seed}"), layered_dag(seed)));
1547        }
1548        cases
1549    }
1550
1551    /// A 4-layer DAG whose edges are picked by a seeded LCG, so each seed gives
1552    /// a different fan-in/fan-out shape and the corpus stays reproducible.
1553    fn layered_dag(seed: u64) -> CuConfig {
1554        let layers = [2usize, 3, 3, 2];
1555        let mut state = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
1556        let mut next = || {
1557            state = state
1558                .wrapping_mul(6364136223846793005)
1559                .wrapping_add(1442695040888963407);
1560            state >> 33
1561        };
1562        let name = |layer: usize, idx: usize| format!("n{layer}_{idx}");
1563        let mut nodes = Vec::new();
1564        for (layer, count) in layers.iter().enumerate() {
1565            for idx in 0..*count {
1566                nodes.push(name(layer, idx));
1567            }
1568        }
1569        let node_refs: Vec<&str> = nodes.iter().map(|s| s.as_str()).collect();
1570        let mut edges: Vec<(String, String, String)> = Vec::new();
1571        for layer in 0..layers.len() - 1 {
1572            for from in 0..layers[layer] {
1573                // guarantee at least one outgoing edge per non-last node
1574                let mut connected = false;
1575                for to in 0..layers[layer + 1] {
1576                    if next() % 2 == 0 || (to == layers[layer + 1] - 1 && !connected) {
1577                        edges.push((
1578                            name(layer, from),
1579                            name(layer + 1, to),
1580                            format!("m{layer}_{from}_{to}"),
1581                        ));
1582                        connected = true;
1583                    }
1584                }
1585            }
1586            // guarantee at least one incoming edge per next-layer node
1587            for to in 0..layers[layer + 1] {
1588                if !edges.iter().any(|(_, d, _)| *d == name(layer + 1, to)) {
1589                    edges.push((
1590                        name(layer, 0),
1591                        name(layer + 1, to),
1592                        format!("f{layer}_{to}"),
1593                    ));
1594                }
1595            }
1596        }
1597        let edge_refs: Vec<(&str, &str, &str)> = edges
1598            .iter()
1599            .map(|(s, d, m)| (s.as_str(), d.as_str(), m.as_str()))
1600            .collect();
1601        build_config(&node_refs, &edge_refs)
1602    }
1603
1604    /// Assert the legacy walk and the new pipeline agree on every field of
1605    /// every step: order, task type, culist indices, and input packs.
1606    fn assert_same_plan(name: &str, config: &CuConfig) {
1607        let graph = config.get_graph(None).unwrap();
1608        let legacy = compute_runtime_plan_legacy(graph).expect("legacy plan");
1609        let fresh = crate::curuntime::compute_runtime_plan(graph).expect("new plan");
1610        assert_eq!(
1611            legacy.steps.len(),
1612            fresh.steps.len(),
1613            "{name}: step count differs"
1614        );
1615        for (index, (a, b)) in legacy.steps.iter().zip(fresh.steps.iter()).enumerate() {
1616            let (CuExecutionUnit::Step(a), CuExecutionUnit::Step(b)) = (a, b) else {
1617                panic!("{name}: unexpected nested loop");
1618            };
1619            assert_eq!(a.node_id, b.node_id, "{name}: step {index} node id");
1620            assert_eq!(a.phase, b.phase, "{name}: step {index} phase");
1621            let (oa, ob) = (a.output_msg_pack.as_ref(), b.output_msg_pack.as_ref());
1622            assert_eq!(
1623                oa.map(|p| p.culist_index),
1624                ob.map(|p| p.culist_index),
1625                "{name}: step {index} culist index"
1626            );
1627            assert_eq!(
1628                oa.map(|p| &p.msg_types),
1629                ob.map(|p| &p.msg_types),
1630                "{name}: step {index} output msg types"
1631            );
1632            assert_eq!(
1633                a.input_msg_indices_types.len(),
1634                b.input_msg_indices_types.len(),
1635                "{name}: step {index} input arity"
1636            );
1637            for (ia, ib) in a
1638                .input_msg_indices_types
1639                .iter()
1640                .zip(b.input_msg_indices_types.iter())
1641            {
1642                assert_eq!(ia.culist_index, ib.culist_index, "{name}: input culist");
1643                assert_eq!(ia.msg_type, ib.msg_type, "{name}: input msg");
1644                assert_eq!(ia.src_port, ib.src_port, "{name}: input src_port");
1645                assert_eq!(ia.edge_id, ib.edge_id, "{name}: input edge_id");
1646                assert_eq!(
1647                    ia.connection_order, ib.connection_order,
1648                    "{name}: input connection_order"
1649                );
1650            }
1651        }
1652    }
1653
1654    #[test]
1655    fn topo_bfs_matches_legacy_walk_over_corpus() {
1656        for (name, config) in corpus() {
1657            assert_same_plan(&name, &config);
1658        }
1659    }
1660
1661    // Verbatim pre-refactor walk, kept only to prove `TopoBfs` reproduces it.
1662    fn find_output_pack_from_nodeid_legacy(
1663        node_id: NodeId,
1664        steps: &[CuExecutionUnit],
1665    ) -> Option<CuOutputPack> {
1666        for step in steps {
1667            match step {
1668                CuExecutionUnit::Loop(loop_unit) => {
1669                    if let Some(pack) =
1670                        find_output_pack_from_nodeid_legacy(node_id, &loop_unit.steps)
1671                    {
1672                        return Some(pack);
1673                    }
1674                }
1675                CuExecutionUnit::Step(step) if step.node_id == node_id => {
1676                    return step.output_msg_pack.clone();
1677                }
1678                _ => {}
1679            }
1680        }
1681        None
1682    }
1683
1684    fn plan_tasks_tree_branch_legacy(
1685        graph: &CuGraph,
1686        mut next_culist_output_index: u32,
1687        starting_point: NodeId,
1688        plan: &mut Vec<CuExecutionUnit>,
1689    ) -> CuResult<(u32, bool)> {
1690        let mut handled = false;
1691        for id in graph.bfs_nodes(starting_point) {
1692            let node_ref = graph.get_node(id).unwrap();
1693            let mut input_msg_indices_types: Vec<CuInputMsg> = Vec::new();
1694            let output_msg_pack: Option<CuOutputPack>;
1695            let task_type = find_task_type_for_id(graph, id)?;
1696            match task_type {
1697                CuTaskType::Source => {
1698                    let msg_types = graph.get_node_output_msg_types_by_id(id)?;
1699                    if msg_types.is_empty() {
1700                        return Err(CuError::from(format!(
1701                            "Source node '{}' has no declared outputs",
1702                            node_ref.get_id()
1703                        )));
1704                    }
1705                    output_msg_pack = Some(CuOutputPack {
1706                        culist_index: next_culist_output_index,
1707                        src_channels: vec![None; msg_types.len()],
1708                        msg_types,
1709                    });
1710                    next_culist_output_index += 1;
1711                }
1712                CuTaskType::Sink => {
1713                    let mut edge_ids = graph.get_dst_edges(id).unwrap_or_default();
1714                    edge_ids.sort();
1715                    for edge_id in edge_ids {
1716                        let edge = graph
1717                            .edge(edge_id)
1718                            .unwrap_or_else(|| panic!("Missing edge {edge_id} for node {id}"));
1719                        let pid =
1720                            graph
1721                                .get_node_id_by_name(edge.src.as_str())
1722                                .unwrap_or_else(|| {
1723                                    panic!("Missing source node '{}' for edge {edge_id}", edge.src)
1724                                });
1725                        let output_pack = find_output_pack_from_nodeid_legacy(pid, plan);
1726                        if let Some(output_pack) = output_pack {
1727                            let msg_type = edge.msg.as_str();
1728                            let src_port = output_pack
1729                                .msg_types
1730                                .iter()
1731                                .position(|msg| msg == msg_type)
1732                                .unwrap_or_else(|| {
1733                                    panic!(
1734                                        "Missing output port for message type '{msg_type}' on node {pid}"
1735                                    )
1736                                });
1737                            input_msg_indices_types.push(CuInputMsg {
1738                                culist_index: output_pack.culist_index,
1739                                msg_type: msg_type.to_string(),
1740                                src_port,
1741                                edge_id,
1742                                connection_order: edge.order,
1743                            });
1744                        } else {
1745                            return Ok((next_culist_output_index, handled));
1746                        }
1747                    }
1748                    output_msg_pack = Some(CuOutputPack {
1749                        culist_index: next_culist_output_index,
1750                        msg_types: Vec::from(["()".to_string()]),
1751                        src_channels: vec![None],
1752                    });
1753                    next_culist_output_index += 1;
1754                }
1755                CuTaskType::Regular => {
1756                    let mut edge_ids = graph.get_dst_edges(id).unwrap_or_default();
1757                    edge_ids.sort();
1758                    for edge_id in edge_ids {
1759                        let edge = graph
1760                            .edge(edge_id)
1761                            .unwrap_or_else(|| panic!("Missing edge {edge_id} for node {id}"));
1762                        let pid =
1763                            graph
1764                                .get_node_id_by_name(edge.src.as_str())
1765                                .unwrap_or_else(|| {
1766                                    panic!("Missing source node '{}' for edge {edge_id}", edge.src)
1767                                });
1768                        let output_pack = find_output_pack_from_nodeid_legacy(pid, plan);
1769                        if let Some(output_pack) = output_pack {
1770                            let msg_type = edge.msg.as_str();
1771                            let src_port = output_pack
1772                                .msg_types
1773                                .iter()
1774                                .position(|msg| msg == msg_type)
1775                                .unwrap_or_else(|| {
1776                                    panic!(
1777                                        "Missing output port for message type '{msg_type}' on node {pid}"
1778                                    )
1779                                });
1780                            input_msg_indices_types.push(CuInputMsg {
1781                                culist_index: output_pack.culist_index,
1782                                msg_type: msg_type.to_string(),
1783                                src_port,
1784                                edge_id,
1785                                connection_order: edge.order,
1786                            });
1787                        } else {
1788                            return Ok((next_culist_output_index, handled));
1789                        }
1790                    }
1791                    let msg_types = graph.get_node_output_msg_types_by_id(id)?;
1792                    if msg_types.is_empty() {
1793                        return Err(CuError::from(format!(
1794                            "Regular node '{}' has no declared outputs",
1795                            node_ref.get_id()
1796                        )));
1797                    }
1798                    output_msg_pack = Some(CuOutputPack {
1799                        culist_index: next_culist_output_index,
1800                        src_channels: vec![None; msg_types.len()],
1801                        msg_types,
1802                    });
1803                    next_culist_output_index += 1;
1804                }
1805            }
1806
1807            sort_inputs_by_connection_order(&mut input_msg_indices_types);
1808            if let Some(pos) = plan
1809                .iter()
1810                .position(|step| matches!(step, CuExecutionUnit::Step(s) if s.node_id == id))
1811            {
1812                let mut step = plan.remove(pos);
1813                if let CuExecutionUnit::Step(ref mut s) = step {
1814                    s.input_msg_indices_types = input_msg_indices_types;
1815                }
1816                plan.push(step);
1817            } else {
1818                let step = CuExecutionStep {
1819                    node_id: id,
1820                    node: node_ref.clone(),
1821                    task_type,
1822                    phase: CuStepPhase::default(),
1823                    input_msg_indices_types,
1824                    output_msg_pack,
1825                };
1826                plan.push(CuExecutionUnit::Step(Box::new(step)));
1827            }
1828            handled = true;
1829        }
1830        Ok((next_culist_output_index, handled))
1831    }
1832
1833    /// The pre-refactor entry point: one walk that picked the order and did the
1834    /// culist bookkeeping in the same pass. The golden test compares to this.
1835    fn compute_runtime_plan_legacy(graph: &CuGraph) -> CuResult<CuExecutionLoop> {
1836        let mut plan = Vec::new();
1837        let mut next_culist_output_index = 0u32;
1838        let mut queue: VecDeque<NodeId> = VecDeque::new();
1839        for node_id in graph.node_ids() {
1840            if find_task_type_for_id(graph, node_id)? == CuTaskType::Source {
1841                queue.push_back(node_id);
1842            }
1843        }
1844        while let Some(start_node) = queue.pop_front() {
1845            for node_id in graph.bfs_nodes(start_node) {
1846                let already = plan
1847                    .iter()
1848                    .any(|unit| matches!(unit, CuExecutionUnit::Step(s) if s.node_id == node_id));
1849                if already {
1850                    continue;
1851                }
1852                let (new_index, handled) = plan_tasks_tree_branch_legacy(
1853                    graph,
1854                    next_culist_output_index,
1855                    node_id,
1856                    &mut plan,
1857                )?;
1858                next_culist_output_index = new_index;
1859                if !handled {
1860                    continue;
1861                }
1862                for neighbor in graph.get_neighbor_ids(node_id, CuDirection::Outgoing) {
1863                    queue.push_back(neighbor);
1864                }
1865            }
1866        }
1867        let mut planned_nodes = BTreeSet::new();
1868        for unit in &plan {
1869            if let CuExecutionUnit::Step(step) = unit {
1870                planned_nodes.insert(step.node_id);
1871            }
1872        }
1873        let mut missing = Vec::new();
1874        for node_id in graph.node_ids() {
1875            if !planned_nodes.contains(&node_id) {
1876                if let Some(node) = graph.get_node(node_id) {
1877                    missing.push(node.get_id().to_string());
1878                } else {
1879                    missing.push(format!("node_id_{node_id}"));
1880                }
1881            }
1882        }
1883        if !missing.is_empty() {
1884            missing.sort();
1885            return Err(CuError::from(format!(
1886                "Execution plan could not include all nodes. Missing: {}. Check for loopback or missing source connections.",
1887                missing.join(", ")
1888            )));
1889        }
1890        Ok(CuExecutionLoop {
1891            steps: plan,
1892            loop_count: None,
1893        })
1894    }
1895}