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 msg_types = graph.get_node_output_msg_types_by_id(id)?;
482                if msg_types.is_empty() {
483                    return Err(CuError::from(format!(
484                        "Source node '{}' has no declared outputs",
485                        node_ref.get_id()
486                    )));
487                }
488                output_msg_pack = Some(CuOutputPack {
489                    culist_index: next_culist_output_index,
490                    msg_types,
491                });
492                next_culist_output_index += 1;
493            }
494            CuTaskType::Sink => {
495                output_msg_pack = Some(CuOutputPack {
496                    culist_index: next_culist_output_index,
497                    msg_types: Vec::from(["()".to_string()]),
498                });
499                next_culist_output_index += 1;
500            }
501            CuTaskType::Regular => {
502                let msg_types = graph.get_node_output_msg_types_by_id(id)?;
503                if msg_types.is_empty() {
504                    return Err(CuError::from(format!(
505                        "Regular node '{}' has no declared outputs",
506                        node_ref.get_id()
507                    )));
508                }
509                output_msg_pack = Some(CuOutputPack {
510                    culist_index: next_culist_output_index,
511                    msg_types,
512                });
513                next_culist_output_index += 1;
514            }
515        }
516
517        sort_inputs_by_connection_order(&mut input_msg_indices_types);
518        plan.push(CuExecutionUnit::Step(Box::new(CuExecutionStep {
519            node_id: id,
520            node: node_ref.clone(),
521            task_type,
522            phase: CuStepPhase::default(),
523            input_msg_indices_types,
524            output_msg_pack,
525        })));
526    }
527
528    Ok(CuExecutionLoop {
529        steps: plan,
530        loop_count: None,
531    })
532}
533
534/// Resolve a step's input pack from the already-materialized producers.
535///
536/// `check_order` guarantees every producer precedes this node, so each pack is
537/// present.
538fn collect_step_inputs(
539    graph: &CuGraph,
540    id: NodeId,
541    plan: &[CuExecutionUnit],
542) -> CuResult<Vec<CuInputMsg>> {
543    let mut inputs = Vec::new();
544    let mut edge_ids = graph.get_dst_edges(id).unwrap_or_default();
545    edge_ids.sort();
546    for edge_id in edge_ids {
547        let edge = graph
548            .edge(edge_id)
549            .unwrap_or_else(|| panic!("Missing edge {edge_id} for node {id}"));
550        let pid = graph
551            .get_node_id_by_name(edge.src.as_str())
552            .unwrap_or_else(|| panic!("Missing source node '{}' for edge {edge_id}", edge.src));
553        let output_pack = find_output_pack_from_nodeid(pid, plan).ok_or_else(|| {
554            CuError::from(format!(
555                "Plan materialization: input from node {pid} is not available before node {id}"
556            ))
557        })?;
558        let msg_type = edge.msg.as_str();
559        let src_port = output_pack
560            .msg_types
561            .iter()
562            .position(|msg| msg == msg_type)
563            .unwrap_or_else(|| {
564                panic!("Missing output port for message type '{msg_type}' on node {pid}")
565            });
566        inputs.push(CuInputMsg {
567            culist_index: output_pack.culist_index,
568            msg_type: msg_type.to_string(),
569            src_port,
570            edge_id,
571            connection_order: edge.order,
572        });
573    }
574    Ok(inputs)
575}
576
577fn find_output_pack_from_nodeid(
578    node_id: NodeId,
579    steps: &[CuExecutionUnit],
580) -> Option<CuOutputPack> {
581    for step in steps {
582        match step {
583            CuExecutionUnit::Loop(loop_unit) => {
584                if let Some(output_pack) = find_output_pack_from_nodeid(node_id, &loop_unit.steps) {
585                    return Some(output_pack);
586                }
587            }
588            CuExecutionUnit::Step(step) if step.node_id == node_id => {
589                return step.output_msg_pack.clone();
590            }
591            _ => {}
592        }
593    }
594    None
595}
596
597/// Preserve the original serialized connection order across missions.
598///
599/// Edge ids are assigned per mission graph, so they are not stable enough to
600/// describe a shared input layout when missions selectively include connections.
601fn sort_inputs_by_connection_order(input_msg_indices_types: &mut [CuInputMsg]) {
602    input_msg_indices_types.sort_by_key(|input| input.connection_order);
603}
604
605fn node_name(graph: &CuGraph, node_id: NodeId) -> String {
606    graph
607        .get_node(node_id)
608        .map(|node| node.get_id())
609        .unwrap_or_else(|| format!("node_id_{node_id}"))
610}
611
612#[derive(Clone, Copy, Debug, PartialEq, Eq)]
613enum ChannelDirection {
614    Rx,
615    Tx,
616}
617
618fn channel_is_used(
619    graph: &CuGraph,
620    bridge_id: &str,
621    channel_id: &str,
622    direction: ChannelDirection,
623) -> bool {
624    graph.edges().any(|connection| match direction {
625        ChannelDirection::Rx => {
626            connection.src == bridge_id && connection.src_channel.as_deref() == Some(channel_id)
627        }
628        ChannelDirection::Tx => {
629            connection.dst == bridge_id && connection.dst_channel.as_deref() == Some(channel_id)
630        }
631    })
632}
633
634fn inferred_output_name(node: &Node, task_type: CuTaskType) -> String {
635    let rust_type = node.get_type();
636    if node.anytime().is_some() {
637        return format!(
638            "<<{rust_type} as cu29::cutask_anytime::CuAnytimeTask>::Output<'static> as cu29::cutask::CuSingleOutputMsg>::Payload"
639        );
640    }
641    let task_trait = match task_type {
642        CuTaskType::Source => "cu29::cutask::CuSrcTask",
643        CuTaskType::Regular => "cu29::cutask::CuTask",
644        CuTaskType::Sink => unreachable!("sinks do not have inferred outputs"),
645    };
646    format!(
647        "<<{rust_type} as {task_trait}>::Output<'static> as cu29::cutask::CuSingleOutputMsg>::Payload"
648    )
649}
650
651/// The synthetic graph a planner orders, plus the identity of every node.
652struct PlanGraph {
653    graph: CuGraph,
654    entities: Vec<PlanEntity>,
655    plan_to_original: Vec<Option<NodeId>>,
656}
657
658/// Build the same synthetic task/bridge graph used by generated runtimes.
659/// Mission-agnostic: `graph` selects the mission and callers wrap errors with
660/// its name.
661fn build_plan_graph(config: &CuConfig, graph: &CuGraph) -> CuResult<PlanGraph> {
662    let mut plan_graph = CuGraph::default();
663    let mut entities = Vec::new();
664    let mut plan_to_original = Vec::new();
665    let mut original_to_plan = Vec::new();
666    original_to_plan.resize(graph.node_count(), None);
667
668    let mut task_index = 0usize;
669    for (original_node_id, node) in graph.get_all_nodes() {
670        if node.get_flavor() != Flavor::Task {
671            continue;
672        }
673        let plan_node_id = plan_graph.add_node(node.clone())?;
674        debug_assert_eq!(plan_node_id as usize, entities.len());
675        original_to_plan[original_node_id as usize] = Some(plan_node_id);
676        plan_to_original.push(Some(original_node_id));
677        entities.push(PlanEntity {
678            key: format!("task:{}", node.get_id()),
679            label: node.get_id(),
680            kind: PlanEntityKind::Task {
681                original_node_id,
682                task_index,
683            },
684        });
685        task_index += 1;
686    }
687
688    // A declared task kind permits output inference when an output is marked
689    // unconnected without an explicit message type. The macro later parses
690    // this projection as Rust syntax when it builds the CopperList tuple.
691    for (original_node_id, node) in graph.get_all_nodes() {
692        if node.get_flavor() != Flavor::Task || node.get_declared_task_kind().is_none() {
693            continue;
694        }
695        let task_type = find_task_type_for_id(graph, original_node_id)?;
696        if task_type == CuTaskType::Sink
697            || !graph
698                .get_node_output_msg_types_by_id(original_node_id)?
699                .is_empty()
700        {
701            continue;
702        }
703        let plan_node_id = original_to_plan[original_node_id as usize]
704            .expect("task was mirrored into the plan graph");
705        let message_type = inferred_output_name(node, task_type);
706        plan_graph
707            .get_node_mut(plan_node_id)
708            .expect("mirrored task is present")
709            .add_nc_output(&message_type, usize::MAX);
710    }
711
712    // The generated bridge representation creates all used receive stages,
713    // then all used transmit stages, for each bridge in config order.
714    let mut channel_nodes: Vec<(usize, usize, ChannelDirection, NodeId)> = Vec::new();
715    for (bridge_config_index, bridge) in config.bridges.iter().enumerate() {
716        if graph.get_node_id_by_name(&bridge.id).is_none() {
717            continue;
718        }
719        for direction in [ChannelDirection::Rx, ChannelDirection::Tx] {
720            for (channel_config_index, channel) in bridge.channels.iter().enumerate() {
721                let (channel_id, channel_direction) = match channel {
722                    BridgeChannelConfigRepresentation::Rx { id, .. } => (id, ChannelDirection::Rx),
723                    BridgeChannelConfigRepresentation::Tx { id, .. } => (id, ChannelDirection::Tx),
724                };
725                if channel_direction != direction
726                    || !channel_is_used(graph, &bridge.id, channel_id, direction)
727                {
728                    continue;
729                }
730
731                let direction_label = match direction {
732                    ChannelDirection::Rx => "rx",
733                    ChannelDirection::Tx => "tx",
734                };
735                let label = format!("{}::{direction_label}::{channel_id}", bridge.id);
736                let synthetic_type = match direction {
737                    ChannelDirection::Rx => "__CuBridgeRxChannel",
738                    ChannelDirection::Tx => "__CuBridgeTxChannel",
739                };
740                let mut node = Node::new(&label, synthetic_type);
741                node.set_flavor(Flavor::Bridge);
742                let plan_node_id = plan_graph.add_node(node)?;
743                debug_assert_eq!(plan_node_id as usize, entities.len());
744                plan_to_original.push(None);
745                entities.push(PlanEntity {
746                    key: format!("bridge:{}:{direction_label}:{channel_id}", bridge.id),
747                    label,
748                    kind: match direction {
749                        ChannelDirection::Rx => PlanEntityKind::BridgeRx {
750                            bridge_config_index,
751                            channel_config_index,
752                        },
753                        ChannelDirection::Tx => PlanEntityKind::BridgeTx {
754                            bridge_config_index,
755                            channel_config_index,
756                        },
757                    },
758                });
759                channel_nodes.push((
760                    bridge_config_index,
761                    channel_config_index,
762                    direction,
763                    plan_node_id,
764                ));
765            }
766        }
767    }
768
769    for connection in graph.edges() {
770        let src_plan = if let Some(channel_id) = connection.src_channel.as_deref() {
771            find_channel_plan_node(
772                config,
773                &channel_nodes,
774                &connection.src,
775                channel_id,
776                ChannelDirection::Rx,
777            )?
778        } else {
779            let original_id = graph.get_node_id_by_name(&connection.src).ok_or_else(|| {
780                CuError::from(format!("Unknown source node '{}'", connection.src))
781            })?;
782            original_to_plan[original_id as usize].ok_or_else(|| {
783                CuError::from(format!("Source node '{}' is not a task", connection.src))
784            })?
785        };
786        let dst_plan = if let Some(channel_id) = connection.dst_channel.as_deref() {
787            find_channel_plan_node(
788                config,
789                &channel_nodes,
790                &connection.dst,
791                channel_id,
792                ChannelDirection::Tx,
793            )?
794        } else {
795            let original_id = graph.get_node_id_by_name(&connection.dst).ok_or_else(|| {
796                CuError::from(format!("Unknown destination node '{}'", connection.dst))
797            })?;
798            original_to_plan[original_id as usize].ok_or_else(|| {
799                CuError::from(format!(
800                    "Destination node '{}' is not a task",
801                    connection.dst
802                ))
803            })?
804        };
805
806        plan_graph
807            .connect_ext_with_order(
808                src_plan,
809                dst_plan,
810                &connection.msg,
811                connection.missions.clone(),
812                None,
813                None,
814                connection.order,
815            )
816            .map_err(|error| CuError::from(error.to_string()))?;
817    }
818
819    Ok(PlanGraph {
820        graph: plan_graph,
821        entities,
822        plan_to_original,
823    })
824}
825
826/// Choice (order) then bookkeeping (materialize): one legality gate, one
827/// shared materializer, for every planner and every consumer.
828fn assemble_from_order(plan_graph: PlanGraph, order: StepOrder) -> CuResult<AssembledPlan> {
829    check_order(&plan_graph.graph, &order)?;
830    let mut execution = plan_from_order(&plan_graph.graph, &order)?;
831    expand_anytime_steps(&mut execution)?;
832    Ok(AssembledPlan {
833        execution,
834        entities: plan_graph.entities,
835        plan_to_original: plan_graph.plan_to_original,
836    })
837}
838
839/// Assemble the generated execution plan, ordering it with the planner the
840/// config selects (`runtime.planner`, defaulting to [`Linearity`]).
841///
842/// Only ship-with-copper planners can be instantiated here; a config naming an
843/// out-of-tree planner must carry its build-time resolved order — see
844/// [`assemble_runtime_plan_from_step_keys`] and [`emit_plan`].
845#[doc(hidden)]
846pub fn assemble_runtime_plan(config: &CuConfig, graph: &CuGraph) -> CuResult<AssembledPlan> {
847    let planner: Box<dyn CuPlanner> = match config.planner_config() {
848        None => Box::new(Linearity),
849        Some(selection) => instantiate_builtin_planner(selection.get_type(), selection.get_config())?
850            .ok_or_else(|| {
851                CuError::from(format!(
852                    "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.",
853                    selection.get_type(),
854                    BUILTIN_PLANNERS.join(", "),
855                    selection.get_type(),
856                ))
857            })?,
858    };
859    assemble_runtime_plan_with_planner(config, graph, planner.as_ref())
860}
861
862/// Assemble with an explicit planner instance, bypassing the config selection.
863#[doc(hidden)]
864pub fn assemble_runtime_plan_with_planner(
865    config: &CuConfig,
866    graph: &CuGraph,
867    planner: &dyn CuPlanner,
868) -> CuResult<AssembledPlan> {
869    let plan_graph = build_plan_graph(config, graph)?;
870    let order = planner.plan(&plan_graph.graph)?;
871    assemble_from_order(plan_graph, order)
872}
873
874/// Assemble from a step order already resolved at build time, given as the
875/// stable step keys [`emit_plan`] emits and codegen bakes into the config.
876#[doc(hidden)]
877pub fn assemble_runtime_plan_from_step_keys(
878    config: &CuConfig,
879    graph: &CuGraph,
880    step_keys: &[String],
881) -> CuResult<AssembledPlan> {
882    let plan_graph = build_plan_graph(config, graph)?;
883    let by_key: BTreeMap<&str, NodeId> = plan_graph
884        .entities
885        .iter()
886        .enumerate()
887        .map(|(id, entity)| (entity.key.as_str(), id as NodeId))
888        .collect();
889    let order = step_keys
890        .iter()
891        .map(|key| {
892            by_key.get(key.as_str()).copied().ok_or_else(|| {
893                CuError::from(format!(
894                    "Resolved plan references unknown step '{key}'; the baked order no longer matches the config."
895                ))
896            })
897        })
898        .collect::<CuResult<Vec<NodeId>>>()
899        .map(StepOrder)?;
900    assemble_from_order(plan_graph, order)
901}
902
903fn find_channel_plan_node(
904    config: &CuConfig,
905    channel_nodes: &[(usize, usize, ChannelDirection, NodeId)],
906    bridge_id: &str,
907    channel_id: &str,
908    direction: ChannelDirection,
909) -> CuResult<NodeId> {
910    channel_nodes
911        .iter()
912        .find_map(
913            |(bridge_index, channel_index, candidate_direction, node_id)| {
914                let bridge = &config.bridges[*bridge_index];
915                let channel = &bridge.channels[*channel_index];
916                (bridge.id == bridge_id
917                    && channel.id() == channel_id
918                    && *candidate_direction == direction)
919                    .then_some(*node_id)
920            },
921        )
922        .ok_or_else(|| {
923            CuError::from(format!(
924                "Bridge channel '{bridge_id}/{channel_id}' is missing from the execution plan"
925            ))
926        })
927}
928
929/// Sorted mission views used by proc-macro generation and visualizers.
930#[doc(hidden)]
931pub fn mission_graphs(config: &CuConfig) -> Vec<(String, &CuGraph)> {
932    match &config.graphs {
933        ConfigGraphs::Simple(graph) => vec![("default".to_string(), graph)],
934        ConfigGraphs::Missions(graphs) => {
935            let mut missions: Vec<_> = graphs
936                .iter()
937                .map(|(mission, graph)| (mission.clone(), graph))
938                .collect();
939            missions.sort_by(|left, right| left.0.cmp(&right.0));
940            missions
941        }
942    }
943}
944
945/// Return a stable key for a concrete serial step.
946#[doc(hidden)]
947pub fn step_key(
948    mission: &str,
949    entity: &PlanEntity,
950    phase: CuStepPhase,
951    refine_ordinal: Option<u32>,
952) -> String {
953    let phase = match phase {
954        CuStepPhase::Whole => "whole".to_string(),
955        CuStepPhase::AnytimeBase => "base".to_string(),
956        CuStepPhase::AnytimeRefine => format!("refine:{}", refine_ordinal.unwrap_or(0)),
957    };
958    format!("mission:{mission}|{}|phase:{phase}", entity.key)
959}
960
961/// Where [`emit_plan`] writes its artifact inside `OUT_DIR`.
962#[doc(hidden)]
963pub const PLAN_ARTIFACT_FILE: &str = "cu29_plan.ron";
964
965/// A build-time resolved plan: the planner that produced it, a digest of the
966/// config it was computed from, and the step order (stable step keys) per
967/// mission.
968#[doc(hidden)]
969#[derive(Serialize, Deserialize)]
970pub struct PlanArtifact {
971    pub planner_type: String,
972    pub config_digest: String,
973    pub orders: BTreeMap<String, Vec<String>>,
974}
975
976/// FNV-1a digest of the effective config, shared by [`emit_plan`] and the
977/// macro to detect a stale artifact.
978///
979/// Digests a canonical rendering with map entries sorted: `CuConfig` maps are
980/// `HashMap`s whose serialization order differs between the build.rs process
981/// and the rustc process, so the raw RON bytes cannot be compared.
982#[doc(hidden)]
983pub fn config_digest(config: &CuConfig) -> CuResult<String> {
984    let ron = config.serialize_ron()?;
985    let value: ron::Value = CuConfig::get_options()
986        .from_str(&ron)
987        .map_err(|e| CuError::from(format!("Could not re-parse the config for digesting: {e}")))?;
988    let mut canonical = String::new();
989    write_canonical_ron(&value, &mut canonical);
990    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
991    for byte in canonical.into_bytes() {
992        hash ^= u64::from(byte);
993        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
994    }
995    Ok(format!("{hash:016x}"))
996}
997
998fn write_canonical_ron(value: &ron::Value, out: &mut String) {
999    use core::fmt::Write;
1000    match value {
1001        ron::Value::Map(map) => {
1002            let mut entries: Vec<(String, &ron::Value)> = map
1003                .iter()
1004                .map(|(key, entry)| {
1005                    let mut rendered = String::new();
1006                    write_canonical_ron(key, &mut rendered);
1007                    (rendered, entry)
1008                })
1009                .collect();
1010            entries.sort_by(|left, right| left.0.cmp(&right.0));
1011            out.push('{');
1012            for (key, entry) in entries {
1013                out.push_str(&key);
1014                out.push(':');
1015                write_canonical_ron(entry, out);
1016                out.push(',');
1017            }
1018            out.push('}');
1019        }
1020        ron::Value::Seq(entries) => {
1021            out.push('[');
1022            for entry in entries {
1023                write_canonical_ron(entry, out);
1024                out.push(',');
1025            }
1026            out.push(']');
1027        }
1028        other => {
1029            let _ = write!(out, "{other:?}");
1030        }
1031    }
1032}
1033
1034/// Resolve an out-of-tree [`CuPlanner`] for `config_path` and write the
1035/// resulting step orders where `#[copper_runtime]` picks them up.
1036///
1037/// Call it from the application's `build.rs`:
1038///
1039/// ```rust,ignore
1040/// fn main() {
1041///     cu29_build::setup();
1042///     cu29::planner::emit_plan::<my_planners::Alphabetical>("copperconfig.ron").unwrap();
1043/// }
1044/// ```
1045///
1046/// `config_path` is relative to the crate root, like the macro's `config`
1047/// attribute. Honors the crate's Cargo feature set (`CARGO_CFG_FEATURE`) like
1048/// `#[copper_runtime]` does.
1049#[cfg(feature = "std")]
1050pub fn emit_plan<P: CuPlanner>(config_path: &str) -> CuResult<()> {
1051    let out_dir = std::env::var("OUT_DIR")
1052        .map_err(|_| CuError::from("emit_plan must run from a build.rs (OUT_DIR is not set)"))?;
1053    let artifact = build_plan_artifact::<P>(config_path)?;
1054    let ron = ron::ser::to_string(&artifact)
1055        .map_err(|e| CuError::from(format!("Could not serialize the plan artifact: {e}")))?;
1056    let path = std::path::Path::new(&out_dir).join(PLAN_ARTIFACT_FILE);
1057    std::fs::write(&path, ron)
1058        .map_err(|e| CuError::new_with_cause("Could not write the plan artifact", e))?;
1059    println!("cargo::rerun-if-changed={config_path}");
1060    Ok(())
1061}
1062
1063/// Run planner `P` over every mission of the config at `config_path`.
1064#[cfg(feature = "std")]
1065fn build_plan_artifact<P: CuPlanner>(config_path: &str) -> CuResult<PlanArtifact> {
1066    // Build scripts see the raw Cargo feature list; cu29_build::setup()
1067    // forwards the same list to the macro as COPPER_CFG_FEATURES.
1068    let features_var = std::env::var("CARGO_CFG_FEATURE").unwrap_or_default();
1069    let features: Vec<&str> = features_var.split(',').filter(|f| !f.is_empty()).collect();
1070    let config = crate::config::read_configuration_with_features(config_path, &features)?;
1071    let planner = P::new(
1072        config
1073            .planner_config()
1074            .and_then(|selection| selection.get_config()),
1075    )?;
1076    let mut orders = BTreeMap::new();
1077    for (mission, graph) in mission_graphs(&config) {
1078        let keys = (|| -> CuResult<Vec<String>> {
1079            let plan_graph = build_plan_graph(&config, graph)?;
1080            let order = planner.plan(&plan_graph.graph)?;
1081            check_order(&plan_graph.graph, &order)?;
1082            Ok(order
1083                .0
1084                .iter()
1085                .map(|&id| plan_graph.entities[id as usize].key.clone())
1086                .collect())
1087        })()
1088        .map_err(|e| CuError::from(format!("mission '{mission}': {e}")))?;
1089        orders.insert(mission, keys);
1090    }
1091    Ok(PlanArtifact {
1092        planner_type: core::any::type_name::<P>().to_string(),
1093        config_digest: config_digest(&config)?,
1094        orders,
1095    })
1096}
1097
1098/// Read back an artifact written by [`emit_plan`].
1099#[doc(hidden)]
1100#[cfg(feature = "std")]
1101pub fn read_plan_artifact(path: &std::path::Path) -> CuResult<PlanArtifact> {
1102    let text = std::fs::read_to_string(path)
1103        .map_err(|e| CuError::new_with_cause("Could not read the plan artifact", e))?;
1104    ron::from_str(&text)
1105        .map_err(|e| CuError::from(format!("Could not parse the plan artifact: {e}")))
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111    use crate::curuntime::CuExecutionUnit;
1112
1113    fn config(ron: &str) -> CuConfig {
1114        CuConfig::deserialize_ron(ron).expect("valid planner test config")
1115    }
1116
1117    fn step_labels(plan: &AssembledPlan) -> Vec<String> {
1118        plan.execution
1119            .steps
1120            .iter()
1121            .map(|unit| match unit {
1122                CuExecutionUnit::Step(step) => plan.entities[step.node_id as usize].label.clone(),
1123                CuExecutionUnit::Loop(_) => panic!("unexpected nested loop"),
1124            })
1125            .collect()
1126    }
1127
1128    #[test]
1129    fn plans_diamond_fan_in_with_stable_input_order() {
1130        let config = config(
1131            r#"(
1132                tasks: [
1133                    (id: "left", type: "demo::Left"),
1134                    (id: "right", type: "demo::Right"),
1135                    (id: "join", type: "demo::Join"),
1136                    (id: "sink", type: "demo::Sink"),
1137                ],
1138                cnx: [
1139                    (src: "right", dst: "join", msg: "demo::RightMsg"),
1140                    (src: "left", dst: "join", msg: "demo::LeftMsg"),
1141                    (src: "join", dst: "sink", msg: "demo::Joined"),
1142                ],
1143            )"#,
1144        );
1145        let graph = config.get_graph(None).unwrap();
1146        let plan = assemble_runtime_plan(&config, graph).unwrap();
1147        assert_eq!(step_labels(&plan), ["left", "right", "join", "sink"]);
1148        let join = plan
1149            .execution
1150            .steps
1151            .iter()
1152            .find_map(|unit| match unit {
1153                CuExecutionUnit::Step(step) if step.node.get_id() == "join" => Some(step),
1154                _ => None,
1155            })
1156            .unwrap();
1157        assert_eq!(join.input_msg_indices_types.len(), 2);
1158        assert_eq!(join.input_msg_indices_types[0].msg_type, "demo::RightMsg");
1159        assert_eq!(join.input_msg_indices_types[1].msg_type, "demo::LeftMsg");
1160    }
1161
1162    #[test]
1163    fn inserts_bridge_rx_and_tx_channel_stages() {
1164        let config = config(
1165            r#"(
1166                tasks: [
1167                    (id: "task", type: "demo::Task"),
1168                ],
1169                bridges: [
1170                    (
1171                        id: "radio",
1172                        type: "demo::Radio",
1173                        channels: [Rx(id: "incoming"), Tx(id: "outgoing")],
1174                    ),
1175                ],
1176                cnx: [
1177                    (src: "radio/incoming", dst: "task", msg: "demo::In"),
1178                    (src: "task", dst: "radio/outgoing", msg: "demo::Out"),
1179                ],
1180            )"#,
1181        );
1182        let graph = config.get_graph(None).unwrap();
1183        let plan = assemble_runtime_plan(&config, graph).unwrap();
1184        assert_eq!(
1185            step_labels(&plan),
1186            ["radio::rx::incoming", "task", "radio::tx::outgoing"]
1187        );
1188        assert!(matches!(
1189            plan.entities[1].kind,
1190            PlanEntityKind::BridgeRx { .. }
1191        ));
1192        assert!(matches!(
1193            plan.entities[2].kind,
1194            PlanEntityKind::BridgeTx { .. }
1195        ));
1196    }
1197
1198    #[test]
1199    fn synthesizes_declared_unconnected_output() {
1200        let config = config(
1201            r#"(
1202                tasks: [(id: "generated", type: "demo::Generated", kind: source)],
1203                cnx: [],
1204            )"#,
1205        );
1206        let graph = config.get_graph(None).unwrap();
1207        let plan = assemble_runtime_plan(&config, graph).unwrap();
1208        let CuExecutionUnit::Step(step) = &plan.execution.steps[0] else {
1209            panic!("expected one generated step")
1210        };
1211        let output = step.output_msg_pack.as_ref().unwrap();
1212        assert_eq!(output.culist_index, 0);
1213        assert!(output.msg_types[0].contains("CuSingleOutputMsg"));
1214        assert!(output.msg_types[0].contains("CuSrcTask"));
1215    }
1216
1217    // ---- Pinned resolution and ordering ----
1218
1219    /// radio(rx incoming, tx outgoing) feeding/consuming a small task chain,
1220    /// with `planner` as its `runtime.planner` section.
1221    fn pinned_graph(planner: &str) -> CuConfig {
1222        config(&format!(
1223            r#"(
1224                tasks: [
1225                    (id: "cam", type: "demo::Cam"),
1226                    (id: "ekf", type: "demo::Ekf"),
1227                    (id: "motor", type: "demo::Motor"),
1228                ],
1229                bridges: [(
1230                    id: "radio",
1231                    type: "demo::Radio",
1232                    channels: [Rx(id: "incoming"), Tx(id: "outgoing")],
1233                )],
1234                cnx: [
1235                    (src: "radio/incoming", dst: "cam", msg: "demo::In"),
1236                    (src: "cam", dst: "ekf", msg: "demo::Frame"),
1237                    (src: "ekf", dst: "motor", msg: "demo::State"),
1238                    (src: "motor", dst: "radio/outgoing", msg: "demo::Cmd"),
1239                ],
1240                runtime: (planner: {planner}),
1241            )"#
1242        ))
1243    }
1244
1245    fn pinned(ids: &[&str]) -> String {
1246        let quoted: Vec<String> = ids.iter().map(|id| format!("{id:?}")).collect();
1247        format!(
1248            r#"(type: "cu29::planner::Pinned", config: {{ "order": [{}] }})"#,
1249            quoted.join(", ")
1250        )
1251    }
1252
1253    #[test]
1254    fn pinned_plan_weaves_bridge_stages_and_matches_task_order() {
1255        let config = pinned_graph(&pinned(&["cam", "ekf", "motor"]));
1256        let graph = config.get_graph(None).unwrap();
1257        let plan = assemble_runtime_plan(&config, graph).unwrap();
1258        assert_eq!(
1259            step_labels(&plan),
1260            [
1261                "radio::rx::incoming",
1262                "cam",
1263                "ekf",
1264                "motor",
1265                "radio::tx::outgoing"
1266            ]
1267        );
1268    }
1269
1270    #[test]
1271    fn pinned_plan_rejects_bad_id_lists() {
1272        let rejects = |planner: &str| {
1273            let config = pinned_graph(planner);
1274            let graph = config.get_graph(None).unwrap();
1275            assemble_runtime_plan(&config, graph)
1276                .err()
1277                .unwrap()
1278                .to_string()
1279        };
1280
1281        let err = rejects(&pinned(&["cam", "ekf"]));
1282        assert!(err.contains("missing"), "{err}");
1283
1284        let err = rejects(&pinned(&["radio::rx::incoming", "cam", "ekf", "motor"]));
1285        assert!(err.contains("bridge stage"), "{err}");
1286
1287        let err = rejects(&pinned(&["cam", "ekf", "motor", "ghost"]));
1288        assert!(err.contains("unknown task 'ghost'"), "{err}");
1289
1290        let err = rejects(&pinned(&["cam", "cam", "ekf"]));
1291        assert!(err.contains("more than once"), "{err}");
1292
1293        let err = rejects(r#"(type: "cu29::planner::Pinned")"#);
1294        assert!(err.contains("needs config"), "{err}");
1295
1296        // A type copper does not ship needs a build-time resolved order.
1297        let err = rejects(r#"(type: "acme::Planner")"#);
1298        assert!(err.contains("emit_plan"), "{err}");
1299    }
1300
1301    // ---- Out-of-tree planners ----
1302
1303    /// Kahn's algorithm with a reverse-alphabetical tie-break: a valid order a
1304    /// third-party planner could produce, distinct from `Linearity`.
1305    struct ReverseAlpha;
1306
1307    impl CuPlanner for ReverseAlpha {
1308        fn new(_config: Option<&ComponentConfig>) -> CuResult<Self> {
1309            Ok(ReverseAlpha)
1310        }
1311
1312        fn plan(&self, graph: &CuGraph) -> CuResult<StepOrder> {
1313            let mut order = Vec::new();
1314            let mut planned: BTreeSet<NodeId> = BTreeSet::new();
1315            while order.len() < graph.node_count() {
1316                let next = graph
1317                    .get_all_nodes()
1318                    .into_iter()
1319                    .filter(|(id, _)| !planned.contains(id))
1320                    .filter(|(id, _)| {
1321                        graph
1322                            .get_neighbor_ids(*id, CuDirection::Incoming)
1323                            .iter()
1324                            .all(|input| planned.contains(input))
1325                    })
1326                    .max_by_key(|(_, node)| node.get_id())
1327                    .map(|(id, _)| id)
1328                    .expect("acyclic graph always has a ready node");
1329                planned.insert(next);
1330                order.push(next);
1331            }
1332            Ok(StepOrder(order))
1333        }
1334    }
1335
1336    #[test]
1337    fn custom_planner_orders_the_plan() {
1338        let config = config(
1339            r#"(
1340                tasks: [
1341                    (id: "left", type: "demo::Left"),
1342                    (id: "right", type: "demo::Right"),
1343                    (id: "join", type: "demo::Join"),
1344                    (id: "sink", type: "demo::Sink"),
1345                ],
1346                cnx: [
1347                    (src: "left", dst: "join", msg: "demo::LeftMsg"),
1348                    (src: "right", dst: "join", msg: "demo::RightMsg"),
1349                    (src: "join", dst: "sink", msg: "demo::Joined"),
1350                ],
1351            )"#,
1352        );
1353        let graph = config.get_graph(None).unwrap();
1354        let plan = assemble_runtime_plan_with_planner(&config, graph, &ReverseAlpha).unwrap();
1355        assert_eq!(step_labels(&plan), ["right", "left", "join", "sink"]);
1356
1357        // The same order replays from baked step keys.
1358        let keys: Vec<String> = plan
1359            .execution
1360            .steps
1361            .iter()
1362            .map(|unit| match unit {
1363                CuExecutionUnit::Step(step) => plan.entities[step.node_id as usize].key.clone(),
1364                CuExecutionUnit::Loop(_) => panic!("unexpected nested loop"),
1365            })
1366            .collect();
1367        let replayed = assemble_runtime_plan_from_step_keys(&config, graph, &keys).unwrap();
1368        assert_eq!(step_labels(&replayed), step_labels(&plan));
1369
1370        let err = assemble_runtime_plan_from_step_keys(&config, graph, &["task:ghost".to_string()])
1371            .err()
1372            .unwrap()
1373            .to_string();
1374        assert!(err.contains("unknown step 'task:ghost'"), "{err}");
1375    }
1376
1377    /// An illegal planner output is rejected by the shared legality gate.
1378    struct Backwards;
1379
1380    impl CuPlanner for Backwards {
1381        fn new(_config: Option<&ComponentConfig>) -> CuResult<Self> {
1382            Ok(Backwards)
1383        }
1384
1385        fn plan(&self, graph: &CuGraph) -> CuResult<StepOrder> {
1386            let StepOrder(mut order) = topo_bfs_order(graph)?;
1387            order.reverse();
1388            Ok(StepOrder(order))
1389        }
1390    }
1391
1392    #[test]
1393    fn illegal_planner_output_is_rejected() {
1394        let config = build_config(&["s", "k"], &[("s", "k", "m")]);
1395        let graph = config.get_graph(None).unwrap();
1396        let err = assemble_runtime_plan_with_planner(&config, graph, &Backwards)
1397            .err()
1398            .unwrap()
1399            .to_string();
1400        assert!(err.contains("scheduled before its input"), "{err}");
1401    }
1402
1403    #[test]
1404    fn canonical_ron_ignores_map_entry_order() {
1405        // The digest must not depend on map serialization order: emit_plan
1406        // (build.rs) and the macro (rustc) run in different processes with
1407        // different HashMap seeds.
1408        let render = |txt: &str| {
1409            let value: ron::Value = ron::from_str(txt).unwrap();
1410            let mut out = String::new();
1411            write_canonical_ron(&value, &mut out);
1412            out
1413        };
1414        assert_eq!(
1415            render(r#"{"a": 1, "b": [2, 3], "c": {"x": 4, "y": 5}}"#),
1416            render(r#"{"c": {"y": 5, "x": 4}, "b": [2, 3], "a": 1}"#),
1417        );
1418        assert_ne!(render(r#"{"b": [2, 3]}"#), render(r#"{"b": [3, 2]}"#));
1419    }
1420
1421    #[test]
1422    fn check_order_flags_precedence_and_missing() {
1423        let config = build_config(&["s", "k"], &[("s", "k", "m")]);
1424        let graph = config.get_graph(None).unwrap();
1425        let s = graph.get_node_id_by_name("s").unwrap();
1426        let k = graph.get_node_id_by_name("k").unwrap();
1427
1428        // Consumer before producer.
1429        let err = check_order(graph, &StepOrder(vec![k, s])).unwrap_err();
1430        assert!(
1431            err.to_string().contains("scheduled before its input"),
1432            "{err}"
1433        );
1434
1435        // A node left out of the order.
1436        let err = check_order(graph, &StepOrder(vec![s])).unwrap_err();
1437        assert!(err.to_string().contains("Missing"), "{err}");
1438    }
1439
1440    // ---- Differential golden test: legacy walk vs new pipeline ----
1441
1442    /// Build a config from plain node ids and `(src, dst, msg)` edges.
1443    fn build_config(nodes: &[&str], edges: &[(&str, &str, &str)]) -> CuConfig {
1444        let mut config = CuConfig::default();
1445        let graph = config.get_graph_mut(None).unwrap();
1446        let mut ids: BTreeMap<String, NodeId> = BTreeMap::new();
1447        for &name in nodes {
1448            let id = graph.add_node(Node::new(name, "demo::T")).unwrap();
1449            ids.insert(name.to_string(), id);
1450        }
1451        for &(src, dst, msg) in edges {
1452            graph.connect(ids[src], ids[dst], msg).unwrap();
1453        }
1454        config
1455    }
1456
1457    /// Named DAGs the golden test replays: hand-written topologies (chain,
1458    /// fan-out, diamond, multi-source) plus seeded layered graphs.
1459    fn corpus() -> Vec<(String, CuConfig)> {
1460        // Bridge stages are just synthetic source/sink nodes to the walk, so a
1461        // corpus of source/regular/sink DAGs covers the bridge case too.
1462        let mut cases = vec![
1463            (
1464                "chain".to_string(),
1465                build_config(
1466                    &["s", "r1", "r2", "k"],
1467                    &[("s", "r1", "m0"), ("r1", "r2", "m1"), ("r2", "k", "m2")],
1468                ),
1469            ),
1470            (
1471                "fanout_shared_msg".to_string(),
1472                build_config(
1473                    &["s", "a", "b", "c"],
1474                    &[("s", "a", "m"), ("s", "b", "m"), ("s", "c", "n")],
1475                ),
1476            ),
1477            (
1478                "fanin_multisource".to_string(),
1479                build_config(
1480                    &["s1", "s2", "s3", "k"],
1481                    &[("s2", "k", "m2"), ("s1", "k", "m1"), ("s3", "k", "m3")],
1482                ),
1483            ),
1484            (
1485                "diamond".to_string(),
1486                build_config(
1487                    &["s", "a", "b", "j", "k"],
1488                    &[
1489                        ("s", "a", "m0"),
1490                        ("s", "b", "m1"),
1491                        ("a", "j", "ma"),
1492                        ("b", "j", "mb"),
1493                        ("j", "k", "mj"),
1494                    ],
1495                ),
1496            ),
1497            (
1498                "bridge_like".to_string(),
1499                build_config(
1500                    &["rx1", "rx2", "r", "tx1", "tx2"],
1501                    &[
1502                        ("rx1", "r", "m1"),
1503                        ("rx2", "r", "m2"),
1504                        ("r", "tx1", "o1"),
1505                        ("r", "tx2", "o2"),
1506                    ],
1507                ),
1508            ),
1509            (
1510                "multisource_layers".to_string(),
1511                build_config(
1512                    &["s1", "s2", "r1", "r2", "k"],
1513                    &[
1514                        ("s1", "r1", "a"),
1515                        ("s2", "r1", "b"),
1516                        ("s1", "r2", "c"),
1517                        ("s2", "r2", "d"),
1518                        ("r1", "k", "e"),
1519                        ("r2", "k", "f"),
1520                    ],
1521                ),
1522            ),
1523            (
1524                "side_branch".to_string(),
1525                build_config(
1526                    &["s", "r1", "r2", "r3", "k1", "k2"],
1527                    &[
1528                        ("s", "r1", "m0"),
1529                        ("r1", "r2", "m1"),
1530                        ("r1", "r3", "m2"),
1531                        ("r2", "k1", "m3"),
1532                        ("r3", "k2", "m4"),
1533                    ],
1534                ),
1535            ),
1536        ];
1537        // Deterministic layered DAGs with LCG-varied edges.
1538        for seed in 0u64..6 {
1539            cases.push((format!("layered_{seed}"), layered_dag(seed)));
1540        }
1541        cases
1542    }
1543
1544    /// A 4-layer DAG whose edges are picked by a seeded LCG, so each seed gives
1545    /// a different fan-in/fan-out shape and the corpus stays reproducible.
1546    fn layered_dag(seed: u64) -> CuConfig {
1547        let layers = [2usize, 3, 3, 2];
1548        let mut state = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
1549        let mut next = || {
1550            state = state
1551                .wrapping_mul(6364136223846793005)
1552                .wrapping_add(1442695040888963407);
1553            state >> 33
1554        };
1555        let name = |layer: usize, idx: usize| format!("n{layer}_{idx}");
1556        let mut nodes = Vec::new();
1557        for (layer, count) in layers.iter().enumerate() {
1558            for idx in 0..*count {
1559                nodes.push(name(layer, idx));
1560            }
1561        }
1562        let node_refs: Vec<&str> = nodes.iter().map(|s| s.as_str()).collect();
1563        let mut edges: Vec<(String, String, String)> = Vec::new();
1564        for layer in 0..layers.len() - 1 {
1565            for from in 0..layers[layer] {
1566                // guarantee at least one outgoing edge per non-last node
1567                let mut connected = false;
1568                for to in 0..layers[layer + 1] {
1569                    if next() % 2 == 0 || (to == layers[layer + 1] - 1 && !connected) {
1570                        edges.push((
1571                            name(layer, from),
1572                            name(layer + 1, to),
1573                            format!("m{layer}_{from}_{to}"),
1574                        ));
1575                        connected = true;
1576                    }
1577                }
1578            }
1579            // guarantee at least one incoming edge per next-layer node
1580            for to in 0..layers[layer + 1] {
1581                if !edges.iter().any(|(_, d, _)| *d == name(layer + 1, to)) {
1582                    edges.push((
1583                        name(layer, 0),
1584                        name(layer + 1, to),
1585                        format!("f{layer}_{to}"),
1586                    ));
1587                }
1588            }
1589        }
1590        let edge_refs: Vec<(&str, &str, &str)> = edges
1591            .iter()
1592            .map(|(s, d, m)| (s.as_str(), d.as_str(), m.as_str()))
1593            .collect();
1594        build_config(&node_refs, &edge_refs)
1595    }
1596
1597    /// Assert the legacy walk and the new pipeline agree on every field of
1598    /// every step: order, task type, culist indices, and input packs.
1599    fn assert_same_plan(name: &str, config: &CuConfig) {
1600        let graph = config.get_graph(None).unwrap();
1601        let legacy = compute_runtime_plan_legacy(graph).expect("legacy plan");
1602        let fresh = crate::curuntime::compute_runtime_plan(graph).expect("new plan");
1603        assert_eq!(
1604            legacy.steps.len(),
1605            fresh.steps.len(),
1606            "{name}: step count differs"
1607        );
1608        for (index, (a, b)) in legacy.steps.iter().zip(fresh.steps.iter()).enumerate() {
1609            let (CuExecutionUnit::Step(a), CuExecutionUnit::Step(b)) = (a, b) else {
1610                panic!("{name}: unexpected nested loop");
1611            };
1612            assert_eq!(a.node_id, b.node_id, "{name}: step {index} node id");
1613            assert_eq!(a.phase, b.phase, "{name}: step {index} phase");
1614            let (oa, ob) = (a.output_msg_pack.as_ref(), b.output_msg_pack.as_ref());
1615            assert_eq!(
1616                oa.map(|p| p.culist_index),
1617                ob.map(|p| p.culist_index),
1618                "{name}: step {index} culist index"
1619            );
1620            assert_eq!(
1621                oa.map(|p| &p.msg_types),
1622                ob.map(|p| &p.msg_types),
1623                "{name}: step {index} output msg types"
1624            );
1625            assert_eq!(
1626                a.input_msg_indices_types.len(),
1627                b.input_msg_indices_types.len(),
1628                "{name}: step {index} input arity"
1629            );
1630            for (ia, ib) in a
1631                .input_msg_indices_types
1632                .iter()
1633                .zip(b.input_msg_indices_types.iter())
1634            {
1635                assert_eq!(ia.culist_index, ib.culist_index, "{name}: input culist");
1636                assert_eq!(ia.msg_type, ib.msg_type, "{name}: input msg");
1637                assert_eq!(ia.src_port, ib.src_port, "{name}: input src_port");
1638                assert_eq!(ia.edge_id, ib.edge_id, "{name}: input edge_id");
1639                assert_eq!(
1640                    ia.connection_order, ib.connection_order,
1641                    "{name}: input connection_order"
1642                );
1643            }
1644        }
1645    }
1646
1647    #[test]
1648    fn topo_bfs_matches_legacy_walk_over_corpus() {
1649        for (name, config) in corpus() {
1650            assert_same_plan(&name, &config);
1651        }
1652    }
1653
1654    // Verbatim pre-refactor walk, kept only to prove `TopoBfs` reproduces it.
1655    fn find_output_pack_from_nodeid_legacy(
1656        node_id: NodeId,
1657        steps: &[CuExecutionUnit],
1658    ) -> Option<CuOutputPack> {
1659        for step in steps {
1660            match step {
1661                CuExecutionUnit::Loop(loop_unit) => {
1662                    if let Some(pack) =
1663                        find_output_pack_from_nodeid_legacy(node_id, &loop_unit.steps)
1664                    {
1665                        return Some(pack);
1666                    }
1667                }
1668                CuExecutionUnit::Step(step) if step.node_id == node_id => {
1669                    return step.output_msg_pack.clone();
1670                }
1671                _ => {}
1672            }
1673        }
1674        None
1675    }
1676
1677    fn plan_tasks_tree_branch_legacy(
1678        graph: &CuGraph,
1679        mut next_culist_output_index: u32,
1680        starting_point: NodeId,
1681        plan: &mut Vec<CuExecutionUnit>,
1682    ) -> CuResult<(u32, bool)> {
1683        let mut handled = false;
1684        for id in graph.bfs_nodes(starting_point) {
1685            let node_ref = graph.get_node(id).unwrap();
1686            let mut input_msg_indices_types: Vec<CuInputMsg> = Vec::new();
1687            let output_msg_pack: Option<CuOutputPack>;
1688            let task_type = find_task_type_for_id(graph, id)?;
1689            match task_type {
1690                CuTaskType::Source => {
1691                    let msg_types = graph.get_node_output_msg_types_by_id(id)?;
1692                    if msg_types.is_empty() {
1693                        return Err(CuError::from(format!(
1694                            "Source node '{}' has no declared outputs",
1695                            node_ref.get_id()
1696                        )));
1697                    }
1698                    output_msg_pack = Some(CuOutputPack {
1699                        culist_index: next_culist_output_index,
1700                        msg_types,
1701                    });
1702                    next_culist_output_index += 1;
1703                }
1704                CuTaskType::Sink => {
1705                    let mut edge_ids = graph.get_dst_edges(id).unwrap_or_default();
1706                    edge_ids.sort();
1707                    for edge_id in edge_ids {
1708                        let edge = graph
1709                            .edge(edge_id)
1710                            .unwrap_or_else(|| panic!("Missing edge {edge_id} for node {id}"));
1711                        let pid =
1712                            graph
1713                                .get_node_id_by_name(edge.src.as_str())
1714                                .unwrap_or_else(|| {
1715                                    panic!("Missing source node '{}' for edge {edge_id}", edge.src)
1716                                });
1717                        let output_pack = find_output_pack_from_nodeid_legacy(pid, plan);
1718                        if let Some(output_pack) = output_pack {
1719                            let msg_type = edge.msg.as_str();
1720                            let src_port = output_pack
1721                                .msg_types
1722                                .iter()
1723                                .position(|msg| msg == msg_type)
1724                                .unwrap_or_else(|| {
1725                                    panic!(
1726                                        "Missing output port for message type '{msg_type}' on node {pid}"
1727                                    )
1728                                });
1729                            input_msg_indices_types.push(CuInputMsg {
1730                                culist_index: output_pack.culist_index,
1731                                msg_type: msg_type.to_string(),
1732                                src_port,
1733                                edge_id,
1734                                connection_order: edge.order,
1735                            });
1736                        } else {
1737                            return Ok((next_culist_output_index, handled));
1738                        }
1739                    }
1740                    output_msg_pack = Some(CuOutputPack {
1741                        culist_index: next_culist_output_index,
1742                        msg_types: Vec::from(["()".to_string()]),
1743                    });
1744                    next_culist_output_index += 1;
1745                }
1746                CuTaskType::Regular => {
1747                    let mut edge_ids = graph.get_dst_edges(id).unwrap_or_default();
1748                    edge_ids.sort();
1749                    for edge_id in edge_ids {
1750                        let edge = graph
1751                            .edge(edge_id)
1752                            .unwrap_or_else(|| panic!("Missing edge {edge_id} for node {id}"));
1753                        let pid =
1754                            graph
1755                                .get_node_id_by_name(edge.src.as_str())
1756                                .unwrap_or_else(|| {
1757                                    panic!("Missing source node '{}' for edge {edge_id}", edge.src)
1758                                });
1759                        let output_pack = find_output_pack_from_nodeid_legacy(pid, plan);
1760                        if let Some(output_pack) = output_pack {
1761                            let msg_type = edge.msg.as_str();
1762                            let src_port = output_pack
1763                                .msg_types
1764                                .iter()
1765                                .position(|msg| msg == msg_type)
1766                                .unwrap_or_else(|| {
1767                                    panic!(
1768                                        "Missing output port for message type '{msg_type}' on node {pid}"
1769                                    )
1770                                });
1771                            input_msg_indices_types.push(CuInputMsg {
1772                                culist_index: output_pack.culist_index,
1773                                msg_type: msg_type.to_string(),
1774                                src_port,
1775                                edge_id,
1776                                connection_order: edge.order,
1777                            });
1778                        } else {
1779                            return Ok((next_culist_output_index, handled));
1780                        }
1781                    }
1782                    let msg_types = graph.get_node_output_msg_types_by_id(id)?;
1783                    if msg_types.is_empty() {
1784                        return Err(CuError::from(format!(
1785                            "Regular node '{}' has no declared outputs",
1786                            node_ref.get_id()
1787                        )));
1788                    }
1789                    output_msg_pack = Some(CuOutputPack {
1790                        culist_index: next_culist_output_index,
1791                        msg_types,
1792                    });
1793                    next_culist_output_index += 1;
1794                }
1795            }
1796
1797            sort_inputs_by_connection_order(&mut input_msg_indices_types);
1798            if let Some(pos) = plan
1799                .iter()
1800                .position(|step| matches!(step, CuExecutionUnit::Step(s) if s.node_id == id))
1801            {
1802                let mut step = plan.remove(pos);
1803                if let CuExecutionUnit::Step(ref mut s) = step {
1804                    s.input_msg_indices_types = input_msg_indices_types;
1805                }
1806                plan.push(step);
1807            } else {
1808                let step = CuExecutionStep {
1809                    node_id: id,
1810                    node: node_ref.clone(),
1811                    task_type,
1812                    phase: CuStepPhase::default(),
1813                    input_msg_indices_types,
1814                    output_msg_pack,
1815                };
1816                plan.push(CuExecutionUnit::Step(Box::new(step)));
1817            }
1818            handled = true;
1819        }
1820        Ok((next_culist_output_index, handled))
1821    }
1822
1823    /// The pre-refactor entry point: one walk that picked the order and did the
1824    /// culist bookkeeping in the same pass. The golden test compares to this.
1825    fn compute_runtime_plan_legacy(graph: &CuGraph) -> CuResult<CuExecutionLoop> {
1826        let mut plan = Vec::new();
1827        let mut next_culist_output_index = 0u32;
1828        let mut queue: VecDeque<NodeId> = VecDeque::new();
1829        for node_id in graph.node_ids() {
1830            if find_task_type_for_id(graph, node_id)? == CuTaskType::Source {
1831                queue.push_back(node_id);
1832            }
1833        }
1834        while let Some(start_node) = queue.pop_front() {
1835            for node_id in graph.bfs_nodes(start_node) {
1836                let already = plan
1837                    .iter()
1838                    .any(|unit| matches!(unit, CuExecutionUnit::Step(s) if s.node_id == node_id));
1839                if already {
1840                    continue;
1841                }
1842                let (new_index, handled) = plan_tasks_tree_branch_legacy(
1843                    graph,
1844                    next_culist_output_index,
1845                    node_id,
1846                    &mut plan,
1847                )?;
1848                next_culist_output_index = new_index;
1849                if !handled {
1850                    continue;
1851                }
1852                for neighbor in graph.get_neighbor_ids(node_id, CuDirection::Outgoing) {
1853                    queue.push_back(neighbor);
1854                }
1855            }
1856        }
1857        let mut planned_nodes = BTreeSet::new();
1858        for unit in &plan {
1859            if let CuExecutionUnit::Step(step) = unit {
1860                planned_nodes.insert(step.node_id);
1861            }
1862        }
1863        let mut missing = Vec::new();
1864        for node_id in graph.node_ids() {
1865            if !planned_nodes.contains(&node_id) {
1866                if let Some(node) = graph.get_node(node_id) {
1867                    missing.push(node.get_id().to_string());
1868                } else {
1869                    missing.push(format!("node_id_{node_id}"));
1870                }
1871            }
1872        }
1873        if !missing.is_empty() {
1874            missing.sort();
1875            return Err(CuError::from(format!(
1876                "Execution plan could not include all nodes. Missing: {}. Check for loopback or missing source connections.",
1877                missing.join(", ")
1878            )));
1879        }
1880        Ok(CuExecutionLoop {
1881            steps: plan,
1882            loop_count: None,
1883        })
1884    }
1885}