Skip to main content

cu29_runtime/
planner.rs

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