Skip to main content

cu29_runtime/planner/
schedule.rs

1//! Portable periodic schedule representation and structural validation.
2
3use super::AssembledPlan;
4use super::PlanEntityKind;
5use super::explicit_schedule::execution_keys;
6use crate::config::CuConfig;
7use crate::config::CuGraph;
8use crate::config::DEFAULT_BACKGROUND_POOL;
9use crate::config::MAX_NICE;
10use crate::config::MAX_RT_PRIORITY;
11use crate::config::MIN_NICE;
12use crate::config::MIN_RT_PRIORITY;
13use crate::config::SchedulingPolicy;
14use crate::config::TaskKind;
15use crate::curuntime::CuExecutionUnit;
16use alloc::collections::BTreeMap;
17use alloc::collections::BTreeSet;
18use alloc::collections::VecDeque;
19use alloc::format;
20use alloc::string::String;
21use alloc::string::ToString;
22use alloc::vec;
23use alloc::vec::Vec;
24use cu29_traits::CuError;
25use cu29_traits::CuResult;
26use serde::Deserialize;
27use serde::Serialize;
28
29/// A repeating execution graph for one mission, indexed by CopperList id.
30///
31/// `steps` is a stable inventory of occurrences. Each worker lists its exact
32/// execution order. Dependencies add precedence across workers or cycles.
33#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct CuMissionPlan {
36    /// Number of consecutive CopperLists represented by one schedule cycle.
37    pub copperlists_per_cycle: u32,
38    /// Largest number of CopperLists admitted but not yet committed.
39    pub max_in_flight: u32,
40    /// Each concrete process step occurs once per CopperList in this inventory.
41    pub steps: Vec<CuPlanStep>,
42    /// Ordered work and placement for each worker.
43    pub workers: Vec<CuPlanWorker>,
44    /// Placement of the thread that admits and commits CopperLists.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub dispatcher: Option<CuPlanThread>,
47    /// One entry per background task in the mission.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub background: Vec<CuPlanBackground>,
50    /// Additional precedence edges.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub dependencies: Vec<CuPlanDependency>,
53}
54
55/// One occurrence of a concrete process step within a schedule cycle.
56#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct CuPlanStep {
59    /// Opaque stable identity exported from the generated process plan.
60    pub key: String,
61    /// Zero-based CopperList offset within the repeating cycle.
62    pub copperlist: u32,
63}
64
65/// One sequential worker. Every inventory index belongs to exactly one worker.
66#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct CuPlanWorker {
69    /// Unique worker id; names the thread and appears in diagnostics.
70    pub id: String,
71    /// Execution context assigned to this worker.
72    pub placement: CuPlanPlacement,
73    /// Inventory indices in the order this worker executes them.
74    pub steps: Vec<u32>,
75}
76
77/// Where a worker runs.
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
80pub enum CuPlanPlacement {
81    /// The application's main thread.
82    Main,
83    /// A dedicated thread with an optional CPU pin and scheduling policy.
84    Thread {
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        cpu: Option<usize>,
87        #[serde(default)]
88        policy: SchedulingPolicy,
89    },
90}
91
92/// CPU pin and scheduling policy of a plan-owned thread.
93#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct CuPlanThread {
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub cpu: Option<usize>,
98    #[serde(default)]
99    pub policy: SchedulingPolicy,
100}
101
102/// How one background task's compute runs and is observed.
103#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct CuPlanBackground {
106    /// Task id.
107    pub task: String,
108    /// `runtime.thread_pools` entry running its compute.
109    pub pool: String,
110    /// Largest number of compute jobs in flight at once.
111    pub max_running: u32,
112    /// Result published by the in-CopperList gateway.
113    pub result: CuPlanBackgroundResult,
114}
115
116/// Which compute result a background gateway publishes into CopperList `n`.
117#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
119pub enum CuPlanBackgroundResult {
120    /// Publish the newest completed result without waiting.
121    Sampled,
122    /// Wait for the result dispatched for CopperList `n - lag`.
123    Lag { lag: u32 },
124}
125
126/// Precedence from `from` to `to`, addressed by inventory index.
127#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct CuPlanDependency {
130    /// Predecessor occurrence index.
131    pub from: u32,
132    /// Dependent occurrence index.
133    pub to: u32,
134    /// Number of schedule cycles between predecessor and dependent.
135    pub cycle_lag: u32,
136}
137
138pub(super) struct PlanShape {
139    keys: Vec<String>,
140    required: BTreeSet<(usize, usize)>,
141    components: Vec<Vec<usize>>,
142    background: Vec<CuPlanBackground>,
143}
144
145impl PlanShape {
146    pub(super) fn new(
147        plan: &AssembledPlan,
148        config: &CuConfig,
149        graph: &CuGraph,
150        mission: &str,
151        concurrent_resources: &[String],
152    ) -> CuResult<Self> {
153        let keys = execution_keys(plan, mission)?;
154        let mut producers = BTreeMap::new();
155        let mut entities: BTreeMap<_, Vec<usize>> = BTreeMap::new();
156        let mut components: BTreeMap<_, Vec<usize>> = BTreeMap::new();
157        let mut resources: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
158        let mut background = Vec::new();
159
160        for (index, unit) in plan.execution.steps.iter().enumerate() {
161            let CuExecutionUnit::Step(step) = unit else {
162                return Err(CuError::from("Nested execution loops cannot be exported"));
163            };
164            if let Some(output) = &step.output_msg_pack {
165                producers.insert(output.culist_index, index);
166            }
167            entities.entry(step.node_id).or_default().push(index);
168            let (component, bindings) = match plan.entities[step.node_id as usize].kind {
169                PlanEntityKind::Task {
170                    original_node_id,
171                    task_index,
172                } => {
173                    let node = graph.get_node(original_node_id).ok_or_else(|| {
174                        CuError::from(format!("Task node {original_node_id} not found"))
175                    })?;
176                    if node.is_background() {
177                        if entities[&step.node_id].len() == 1 {
178                            background.push(CuPlanBackground {
179                                task: node.get_id(),
180                                pool: node.background_pool().to_string(),
181                                max_running: 1,
182                                result: CuPlanBackgroundResult::Sampled,
183                            });
184                        }
185                        continue;
186                    }
187                    if node.get_declared_task_kind() == Some(TaskKind::Stateless) {
188                        continue;
189                    }
190                    ((0, task_index), node.get_resources())
191                }
192                PlanEntityKind::BridgeRx {
193                    bridge_config_index,
194                    ..
195                }
196                | PlanEntityKind::BridgeTx {
197                    bridge_config_index,
198                    ..
199                } => (
200                    (1, bridge_config_index),
201                    config.bridges[bridge_config_index].resources.as_ref(),
202                ),
203            };
204            components.entry(component).or_default().push(index);
205            for resource in bindings.into_iter().flat_map(|map| map.values()) {
206                if !concurrent_resources.contains(resource) {
207                    resources.entry(resource).or_default().push(index);
208                }
209            }
210        }
211
212        let mut required = BTreeSet::new();
213        for indices in entities.values() {
214            for pair in indices.windows(2) {
215                required.insert((pair[0], pair[1]));
216            }
217        }
218        for (index, unit) in plan.execution.steps.iter().enumerate() {
219            let CuExecutionUnit::Step(step) = unit else {
220                continue;
221            };
222            for input in &step.input_msg_indices_types {
223                let producer = producers
224                    .get(&input.culist_index)
225                    .ok_or_else(|| CuError::from("Missing process-step producer"))?;
226                required.insert((*producer, index));
227            }
228        }
229
230        let mut components: Vec<Vec<usize>> = components.into_values().collect();
231        for (_, mut steps) in resources {
232            steps.sort_unstable();
233            steps.dedup();
234            let owners = components
235                .iter()
236                .filter(|component| component.iter().any(|step| steps.contains(step)))
237                .count();
238            if owners > 1 {
239                components.push(steps);
240            }
241        }
242
243        Ok(Self {
244            keys,
245            required,
246            components,
247            background,
248        })
249    }
250
251    pub(super) fn cyclic_plan(self, copperlists_per_cycle: u32) -> CuResult<CuMissionPlan> {
252        if copperlists_per_cycle <= 1 {
253            return self.serial_plan();
254        }
255        let per_cl = self.keys.len();
256        let occurrence = |index: usize, offset: u32| step_id(offset as usize * per_cl + index);
257        let mut dependencies = BTreeSet::new();
258        for offset in 0..copperlists_per_cycle {
259            for &(from, to) in &self.required {
260                dependencies.insert(CuPlanDependency {
261                    from: occurrence(from, offset)?,
262                    to: occurrence(to, offset)?,
263                    cycle_lag: 0,
264                });
265            }
266        }
267        for component in &self.components {
268            let (Some(&first), Some(&last)) = (component.first(), component.last()) else {
269                continue;
270            };
271            for offset in 0..copperlists_per_cycle {
272                for pair in component.windows(2) {
273                    dependencies.insert(CuPlanDependency {
274                        from: occurrence(pair[0], offset)?,
275                        to: occurrence(pair[1], offset)?,
276                        cycle_lag: 0,
277                    });
278                }
279                if offset + 1 < copperlists_per_cycle {
280                    dependencies.insert(CuPlanDependency {
281                        from: occurrence(last, offset)?,
282                        to: occurrence(first, offset + 1)?,
283                        cycle_lag: 0,
284                    });
285                }
286            }
287            dependencies.insert(CuPlanDependency {
288                from: occurrence(last, copperlists_per_cycle - 1)?,
289                to: occurrence(first, 0)?,
290                cycle_lag: 1,
291            });
292        }
293        let mut steps = Vec::with_capacity(per_cl * copperlists_per_cycle as usize);
294        for offset in 0..copperlists_per_cycle {
295            steps.extend(self.keys.iter().map(|key| CuPlanStep {
296                key: key.clone(),
297                copperlist: offset,
298            }));
299        }
300        Ok(CuMissionPlan {
301            copperlists_per_cycle,
302            max_in_flight: copperlists_per_cycle,
303            workers: vec![CuPlanWorker {
304                id: "w0".into(),
305                placement: CuPlanPlacement::Thread {
306                    cpu: None,
307                    policy: SchedulingPolicy::Fair,
308                },
309                steps: (0..steps.len()).map(step_id).collect::<CuResult<_>>()?,
310            }],
311            steps,
312            dispatcher: None,
313            background: self.background,
314            dependencies: dependencies.into_iter().collect(),
315        })
316    }
317
318    pub(super) fn serial_plan(self) -> CuResult<CuMissionPlan> {
319        let mut dependencies = BTreeSet::new();
320        for (from, to) in self.required {
321            dependencies.insert(CuPlanDependency {
322                from: step_id(from)?,
323                to: step_id(to)?,
324                cycle_lag: 0,
325            });
326        }
327        for component in &self.components {
328            for pair in component.windows(2) {
329                dependencies.insert(CuPlanDependency {
330                    from: step_id(pair[0])?,
331                    to: step_id(pair[1])?,
332                    cycle_lag: 0,
333                });
334            }
335            if let (Some(first), Some(last)) = (component.first(), component.last()) {
336                dependencies.insert(CuPlanDependency {
337                    from: step_id(*last)?,
338                    to: step_id(*first)?,
339                    cycle_lag: 1,
340                });
341            }
342        }
343        let order = (0..self.keys.len())
344            .map(step_id)
345            .collect::<CuResult<Vec<_>>>()?;
346        Ok(CuMissionPlan {
347            copperlists_per_cycle: 1,
348            max_in_flight: 1,
349            steps: self
350                .keys
351                .into_iter()
352                .map(|key| CuPlanStep { key, copperlist: 0 })
353                .collect(),
354            workers: vec![CuPlanWorker {
355                id: "main".into(),
356                placement: CuPlanPlacement::Main,
357                steps: order,
358            }],
359            dispatcher: None,
360            background: self.background,
361            dependencies: dependencies.into_iter().collect(),
362        })
363    }
364}
365
366fn step_id(index: usize) -> CuResult<u32> {
367    u32::try_from(index).map_err(|_| CuError::from("Execution plan has too many steps"))
368}
369
370fn validate_policy(what: &str, policy: SchedulingPolicy) -> CuResult<()> {
371    match policy {
372        SchedulingPolicy::Fifo { priority } | SchedulingPolicy::RoundRobin { priority } => {
373            if !(MIN_RT_PRIORITY..=MAX_RT_PRIORITY).contains(&priority) {
374                return Err(CuError::from(format!(
375                    "{what}: real-time priority {priority} is out of range ({MIN_RT_PRIORITY}..={MAX_RT_PRIORITY})"
376                )));
377            }
378        }
379        SchedulingPolicy::Nice(nice) => {
380            if !(MIN_NICE..=MAX_NICE).contains(&nice) {
381                return Err(CuError::from(format!(
382                    "{what}: niceness {nice} is out of range ({MIN_NICE}..={MAX_NICE})"
383                )));
384            }
385        }
386        SchedulingPolicy::Fair => {}
387    }
388    Ok(())
389}
390
391impl CuMissionPlan {
392    /// Whether this is the serial subset.
393    pub fn is_serial(&self) -> bool {
394        self.copperlists_per_cycle == 1
395            && self.max_in_flight == 1
396            && self.workers.len() == 1
397            && self.workers[0].placement == CuPlanPlacement::Main
398    }
399
400    /// Reasons this plan's CopperList contents can depend on timing.
401    pub fn nondeterminism(&self) -> Vec<String> {
402        self.background
403            .iter()
404            .filter(|entry| entry.result == CuPlanBackgroundResult::Sampled)
405            .map(|entry| {
406                format!(
407                    "background task '{}' publishes its newest result (sampled)",
408                    entry.task
409                )
410            })
411            .collect()
412    }
413
414    pub(super) fn serial_keys(&self) -> CuResult<Vec<String>> {
415        if !self.is_serial() {
416            return Err(CuError::from(
417                "Serial keys require one main worker, one CopperList per cycle, and one in flight.",
418            ));
419        }
420        self.workers[0]
421            .steps
422            .iter()
423            .map(|&index| {
424                self.steps
425                    .get(index as usize)
426                    .map(|step| step.key.clone())
427                    .ok_or_else(|| CuError::from("Unknown worker step"))
428            })
429            .collect()
430    }
431
432    pub(super) fn validate(&self, config: &CuConfig, shape: &PlanShape) -> CuResult<Vec<usize>> {
433        if self.copperlists_per_cycle == 0 {
434            return Err(CuError::from("copperlists_per_cycle must be positive"));
435        }
436        if self.max_in_flight == 0 {
437            return Err(CuError::from("max_in_flight must be positive"));
438        }
439        if self.max_in_flight < self.copperlists_per_cycle {
440            return Err(CuError::from(format!(
441                "max_in_flight {} is smaller than copperlists_per_cycle {}",
442                self.max_in_flight, self.copperlists_per_cycle
443            )));
444        }
445        let storage = config
446            .logging
447            .as_ref()
448            .and_then(|logging| logging.copperlist_count)
449            .unwrap_or(super::DEFAULT_COPPERLIST_COUNT);
450        if self.max_in_flight as usize > storage {
451            return Err(CuError::from(format!(
452                "max_in_flight {} exceeds the {storage} preallocated CopperLists",
453                self.max_in_flight
454            )));
455        }
456        let expected = shape
457            .keys
458            .len()
459            .checked_mul(self.copperlists_per_cycle as usize)
460            .ok_or_else(|| CuError::from("Execution plan step count overflow"))?;
461        if self.steps.len() != expected {
462            return Err(CuError::from(format!(
463                "Expected {expected} process-step occurrences, found {}",
464                self.steps.len()
465            )));
466        }
467
468        let by_key: BTreeMap<_, _> = shape
469            .keys
470            .iter()
471            .enumerate()
472            .map(|(index, key)| (key, index))
473            .collect();
474        let mut occurrences = BTreeMap::new();
475        for (index, step) in self.steps.iter().enumerate() {
476            let key = *by_key
477                .get(&step.key)
478                .ok_or_else(|| CuError::from(format!("Unknown process step '{}'", step.key)))?;
479            if step.copperlist >= self.copperlists_per_cycle {
480                return Err(CuError::from(format!(
481                    "CopperList offset {} is outside the schedule cycle",
482                    step.copperlist
483                )));
484            }
485            if occurrences.insert((key, step.copperlist), index).is_some() {
486                return Err(CuError::from(format!(
487                    "Duplicate process step '{}' in CopperList {}",
488                    step.key, step.copperlist
489                )));
490            }
491        }
492
493        if self.workers.is_empty() {
494            return Err(CuError::from("Execution plan needs at least one worker"));
495        }
496        let mut assigned = vec![false; self.steps.len()];
497        let mut ids = BTreeSet::new();
498        let mut edges = vec![Vec::new(); self.steps.len()];
499        for worker in &self.workers {
500            if worker.id.is_empty() || !ids.insert(worker.id.as_str()) {
501                return Err(CuError::from(format!(
502                    "Worker id '{}' is empty or used twice",
503                    worker.id
504                )));
505            }
506            match &worker.placement {
507                CuPlanPlacement::Main => {
508                    if !self.is_serial() || self.dispatcher.is_some() {
509                        return Err(CuError::from(
510                            "A main-thread worker is only valid in a serial plan",
511                        ));
512                    }
513                }
514                CuPlanPlacement::Thread { policy, .. } => {
515                    validate_policy(&format!("Worker '{}'", worker.id), *policy)?;
516                }
517            }
518            if worker.steps.is_empty() {
519                return Err(CuError::from(format!(
520                    "Worker '{}' has no steps",
521                    worker.id
522                )));
523            }
524            for &index in &worker.steps {
525                let seen = assigned
526                    .get_mut(index as usize)
527                    .ok_or_else(|| CuError::from(format!("Unknown worker step index {index}")))?;
528                if core::mem::replace(seen, true) {
529                    return Err(CuError::from(format!(
530                        "Step index {index} is assigned more than once"
531                    )));
532                }
533            }
534            for pair in worker.steps.windows(2) {
535                edges[pair[0] as usize].push((pair[1] as usize, 0));
536            }
537            if let (Some(first), Some(last)) = (worker.steps.first(), worker.steps.last()) {
538                edges[*last as usize].push((*first as usize, 1));
539            }
540        }
541        if let Some(dispatcher) = &self.dispatcher {
542            validate_policy("Dispatcher", dispatcher.policy)?;
543        }
544        if assigned.iter().any(|assigned| !assigned) {
545            return Err(CuError::from(
546                "Every process-step occurrence must be assigned to a worker",
547            ));
548        }
549
550        let expected_background: BTreeSet<_> =
551            shape.background.iter().map(|entry| &entry.task).collect();
552        let declared: BTreeSet<_> = self.background.iter().map(|entry| &entry.task).collect();
553        if declared.len() != self.background.len() || declared != expected_background {
554            return Err(CuError::from(format!(
555                "background must list exactly the mission's background tasks: {expected_background:?}"
556            )));
557        }
558        for entry in &self.background {
559            let known = entry.pool == DEFAULT_BACKGROUND_POOL
560                || config.runtime.as_ref().is_some_and(|runtime| {
561                    runtime
562                        .thread_pools
563                        .iter()
564                        .any(|pool| pool.id == entry.pool)
565                });
566            if !known {
567                return Err(CuError::from(format!(
568                    "Background task '{}': unknown thread pool '{}'",
569                    entry.task, entry.pool
570                )));
571            }
572            if entry.max_running != 1 {
573                return Err(CuError::from(format!(
574                    "Background task '{}': this runtime requires max_running 1",
575                    entry.task
576                )));
577            }
578            if let CuPlanBackgroundResult::Lag { lag } = entry.result
579                && lag != 1
580            {
581                return Err(CuError::from(format!(
582                    "Background task '{}': this runtime supports result lag 1",
583                    entry.task
584                )));
585            }
586        }
587
588        for dependency in &self.dependencies {
589            if dependency.from as usize >= edges.len() || dependency.to as usize >= edges.len() {
590                return Err(CuError::from("Dependency references an unknown step index"));
591            }
592            edges[dependency.from as usize].push((dependency.to as usize, dependency.cycle_lag));
593        }
594        let order = topological_order(&edges)?;
595        let mut rank = vec![0; order.len()];
596        for (position, &step) in order.iter().enumerate() {
597            rank[step] = position;
598        }
599        let require = |from: usize, to: usize, lag: u32| -> CuResult<()> {
600            if !reaches(&edges, from, to, lag) {
601                return Err(CuError::from(format!(
602                    "Missing precedence: '{}' (CL {}) must finish before '{}' (CL {}, cycle lag {lag})",
603                    self.steps[from].key,
604                    self.steps[from].copperlist,
605                    self.steps[to].key,
606                    self.steps[to].copperlist,
607                )));
608            }
609            Ok(())
610        };
611        for copperlist in 0..self.copperlists_per_cycle {
612            for &(from, to) in &shape.required {
613                require(
614                    occurrences[&(from, copperlist)],
615                    occurrences[&(to, copperlist)],
616                    0,
617                )?;
618            }
619        }
620        for component in &shape.components {
621            let mut endpoints = Vec::new();
622            for copperlist in 0..self.copperlists_per_cycle {
623                let mut steps: Vec<_> = component
624                    .iter()
625                    .map(|&key| occurrences[&(key, copperlist)])
626                    .collect();
627                steps.sort_by_key(|&step| rank[step]);
628                for pair in steps.windows(2) {
629                    require(pair[0], pair[1], 0)?;
630                }
631                if let (Some(&first), Some(&last)) = (steps.first(), steps.last()) {
632                    endpoints.push((first, last));
633                }
634            }
635            for pair in endpoints.windows(2) {
636                require(pair[0].1, pair[1].0, 0)?;
637            }
638            if let (Some(first), Some(last)) = (endpoints.first(), endpoints.last()) {
639                require(last.1, first.0, 1)?;
640            }
641        }
642        Ok(order)
643    }
644
645    pub(super) fn layout_keys(&self, order: &[usize]) -> Vec<String> {
646        order
647            .iter()
648            .map(|&index| &self.steps[index])
649            .filter(|step| step.copperlist == 0)
650            .map(|step| step.key.clone())
651            .collect()
652    }
653}
654
655fn topological_order(edges: &[Vec<(usize, u32)>]) -> CuResult<Vec<usize>> {
656    let mut incoming = vec![0usize; edges.len()];
657    for outgoing in edges {
658        for &(to, lag) in outgoing {
659            if lag == 0 {
660                incoming[to] += 1;
661            }
662        }
663    }
664    let mut ready: VecDeque<_> = incoming
665        .iter()
666        .enumerate()
667        .filter_map(|(index, &count)| (count == 0).then_some(index))
668        .collect();
669    let mut order = Vec::with_capacity(edges.len());
670    while let Some(from) = ready.pop_front() {
671        order.push(from);
672        for &(to, lag) in &edges[from] {
673            if lag == 0 {
674                incoming[to] -= 1;
675                if incoming[to] == 0 {
676                    ready.push_back(to);
677                }
678            }
679        }
680    }
681    if order.len() != edges.len() {
682        return Err(CuError::from(
683            "Execution plan contains a dependency/worker-order cycle",
684        ));
685    }
686    Ok(order)
687}
688
689fn reaches(edges: &[Vec<(usize, u32)>], from: usize, to: usize, lag: u32) -> bool {
690    if edges[from].contains(&(to, lag)) {
691        return true;
692    }
693    let mut visited = vec![false; edges.len() * (lag as usize + 1)];
694    let mut ready = vec![(from, 0u32)];
695    visited[from] = true;
696    while let Some((node, elapsed)) = ready.pop() {
697        for &(next, distance) in &edges[node] {
698            let Some(next_lag) = elapsed.checked_add(distance).filter(|&sum| sum <= lag) else {
699                continue;
700            };
701            if next == to && next_lag == lag {
702                return true;
703            }
704            let index = next_lag as usize * edges.len() + next;
705            if !visited[index] {
706                visited[index] = true;
707                ready.push((next, next_lag));
708            }
709        }
710    }
711    false
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use crate::planner::CuPlan;
718    use crate::planner::DistributedSchedule;
719    use crate::planner::ExplicitSchedule;
720    use crate::planner::LaneOccurrence;
721    use crate::planner::assemble_runtime_plan_for_mission;
722
723    fn chain() -> CuConfig {
724        CuConfig::deserialize_ron(
725            r#"(
726                runtime: (thread_pools: [(id: "rt", threads: 4, affinity: [0, 1])]),
727                tasks: [(id: "src", type: "Source"), (id: "sink", type: "Sink")],
728                cnx: [(src: "src", dst: "sink", msg: "u32")],
729            )"#,
730        )
731        .unwrap()
732    }
733
734    fn worker(index: u32, steps: Vec<u32>) -> CuPlanWorker {
735        CuPlanWorker {
736            id: format!("w{index}"),
737            placement: CuPlanPlacement::Thread {
738                cpu: Some(index as usize),
739                policy: SchedulingPolicy::Fifo {
740                    priority: 60 - index as u8,
741                },
742            },
743            steps,
744        }
745    }
746
747    fn two_cl_pipeline() -> (CuConfig, CuPlan) {
748        let config = chain();
749        let mut plan = CuPlan::from_config(&config).unwrap();
750        let mission = plan.missions.get_mut("default").unwrap();
751        let first = mission.steps.clone();
752        mission.steps.extend(first.into_iter().map(|mut step| {
753            step.copperlist = 1;
754            step
755        }));
756        mission.copperlists_per_cycle = 2;
757        mission.max_in_flight = 2;
758        mission.workers = vec![worker(0, vec![0, 2]), worker(1, vec![1, 3])];
759        mission.dispatcher = Some(CuPlanThread {
760            cpu: Some(0),
761            policy: SchedulingPolicy::Fifo { priority: 70 },
762        });
763        mission.dependencies = vec![
764            CuPlanDependency {
765                from: 0,
766                to: 1,
767                cycle_lag: 0,
768            },
769            CuPlanDependency {
770                from: 2,
771                to: 3,
772                cycle_lag: 0,
773            },
774        ];
775        (config, plan)
776    }
777
778    #[test]
779    fn multicore_plan_round_trips_and_resolves_exact_workers() {
780        let (mut config, plan) = two_cl_pipeline();
781        plan.validate(&config).unwrap();
782        let text = plan.serialize_ron().unwrap();
783        assert!(!text.contains("version"));
784        let loaded = CuPlan::deserialize_ron(&text).unwrap();
785        assert_eq!(loaded, plan);
786        ExplicitSchedule::new(loaded).apply(&mut config).unwrap();
787        assert_eq!(CuPlan::from_config(&config).unwrap(), plan);
788
789        let assembled =
790            assemble_runtime_plan_for_mission(&config, config.get_graph(None).unwrap(), "default")
791                .unwrap();
792        let schedule = DistributedSchedule::from_assembled(&assembled).unwrap();
793        assert_eq!(schedule.worker_lengths, [2, 2]);
794        assert_eq!(
795            schedule.completion,
796            [vec![(0, 1), (1, 1)], vec![(0, 2), (1, 2)]]
797        );
798        assert!(schedule.dependencies.iter().all(|(from, to, _)| {
799            schedule.owner[*from] != schedule.owner[*to] && schedule.publish[*from]
800        }));
801        let lanes = assembled.lanes.unwrap();
802        assert_eq!((lanes.copperlists_per_cycle, lanes.max_in_flight), (2, 2));
803        assert_eq!(
804            lanes.occurrences,
805            [(0, 0), (1, 0), (0, 1), (1, 1)]
806                .map(|(step, copperlist)| LaneOccurrence { step, copperlist })
807        );
808        assert_eq!(lanes.workers[0].occurrences, [0, 2]);
809        assert_eq!(lanes.workers[1].occurrences, [1, 3]);
810    }
811
812    #[test]
813    fn pipeline_and_equivalent_explicit_plan_resolve_identically() {
814        let mut pipeline_config = CuConfig::deserialize_ron(
815            r#"(
816                runtime: (planner: (kind: Pipeline, config: { "max_in_flight": 2 })),
817                logging: (copperlist_count: 2),
818                tasks: [(id: "src", type: "Source"), (id: "mid", type: "Mid"),
819                    (id: "sink", type: "Sink")],
820                cnx: [(src: "src", dst: "mid", msg: "u32"),
821                    (src: "mid", dst: "sink", msg: "u32")],
822            )"#,
823        )
824        .unwrap();
825        let plan = CuPlan::from_config(&pipeline_config).unwrap();
826        let pipeline = assemble_runtime_plan_for_mission(
827            &pipeline_config,
828            pipeline_config.get_graph(None).unwrap(),
829            "default",
830        )
831        .unwrap();
832        ExplicitSchedule::new(plan)
833            .apply(&mut pipeline_config)
834            .unwrap();
835        let explicit = assemble_runtime_plan_for_mission(
836            &pipeline_config,
837            pipeline_config.get_graph(None).unwrap(),
838            "default",
839        )
840        .unwrap();
841        assert_eq!(pipeline.lanes, explicit.lanes);
842        assert_eq!(
843            super::execution_keys(&pipeline, "default").unwrap(),
844            super::execution_keys(&explicit, "default").unwrap()
845        );
846    }
847
848    #[test]
849    fn rejects_malformed_inventory_assignment_placement_and_capacity() {
850        let (config, plan) = two_cl_pipeline();
851        for mutation in 0..15 {
852            let mut invalid = plan.clone();
853            let mission = invalid.missions.get_mut("default").unwrap();
854            match mutation {
855                0 => _ = mission.steps.pop(),
856                1 => mission.steps[0].key = "unknown".into(),
857                2 => mission.steps[0].copperlist = 2,
858                3 => mission.steps[2] = mission.steps[0].clone(),
859                4 => _ = mission.workers[0].steps.pop(),
860                5 => mission.workers[0].steps.push(0),
861                6 => mission.workers[0].steps.push(99),
862                7 => mission.workers[0].placement = CuPlanPlacement::Main,
863                8 => {
864                    mission.workers[0].placement = CuPlanPlacement::Thread {
865                        cpu: None,
866                        policy: SchedulingPolicy::Fifo { priority: 100 },
867                    }
868                }
869                9 => mission.workers[1].id = mission.workers[0].id.clone(),
870                10 => mission.dependencies[0].to = 99,
871                11 => mission.max_in_flight = 3,
872                12 => {
873                    mission.dispatcher = Some(CuPlanThread {
874                        cpu: None,
875                        policy: SchedulingPolicy::Nice(40),
876                    })
877                }
878                13 => mission.max_in_flight = 1,
879                _ => mission.copperlists_per_cycle = 0,
880            }
881            assert!(invalid.validate(&config).is_err(), "mutation {mutation}");
882        }
883    }
884
885    #[test]
886    fn rejects_missing_precedence_cycles_and_state_recurrence() {
887        let (config, plan) = two_cl_pipeline();
888        let mut missing = plan.clone();
889        missing
890            .missions
891            .get_mut("default")
892            .unwrap()
893            .dependencies
894            .clear();
895        assert!(missing.validate(&config).is_err());
896
897        let mut cyclic = plan.clone();
898        cyclic
899            .missions
900            .get_mut("default")
901            .unwrap()
902            .dependencies
903            .push(CuPlanDependency {
904                from: 1,
905                to: 0,
906                cycle_lag: 0,
907            });
908        assert!(cyclic.validate(&config).is_err());
909
910        let mut recurrence = plan;
911        recurrence.missions.get_mut("default").unwrap().workers =
912            (0..4).map(|index| worker(index, vec![index])).collect();
913        assert!(recurrence.validate(&config).is_err());
914        recurrence
915            .missions
916            .get_mut("default")
917            .unwrap()
918            .dependencies
919            .extend([
920                CuPlanDependency {
921                    from: 0,
922                    to: 2,
923                    cycle_lag: 0,
924                },
925                CuPlanDependency {
926                    from: 1,
927                    to: 3,
928                    cycle_lag: 0,
929                },
930                CuPlanDependency {
931                    from: 2,
932                    to: 0,
933                    cycle_lag: 1,
934                },
935                CuPlanDependency {
936                    from: 3,
937                    to: 1,
938                    cycle_lag: 1,
939                },
940            ]);
941        recurrence.validate(&config).unwrap();
942    }
943
944    fn fork_join(features_kind: &str) -> CuConfig {
945        CuConfig::deserialize_ron(&format!(
946            r#"(
947                logging: (copperlist_count: 2),
948                tasks: [(id: "source", type: "Source"), (id: "filter", type: "Filter"),
949                    (id: "features", type: "Features", kind: {features_kind}),
950                    (id: "fuse", type: "Fuse")],
951                cnx: [(src: "source", dst: "filter", msg: "u32"),
952                    (src: "source", dst: "features", msg: "u32"),
953                    (src: "filter", dst: "fuse", msg: "u32"),
954                    (src: "features", dst: "fuse", msg: "u32")],
955            )"#
956        ))
957        .unwrap()
958    }
959
960    fn overlapping_features(config: &CuConfig) -> CuPlan {
961        let mut plan = CuPlan::from_config(config).unwrap();
962        let mission = plan.missions.get_mut("default").unwrap();
963        let first = mission.steps.clone();
964        let per_cl = first.len() as u32;
965        mission.steps.extend(first.iter().cloned().map(|mut step| {
966            step.copperlist = 1;
967            step
968        }));
969        mission.copperlists_per_cycle = 2;
970        mission.max_in_flight = 2;
971        let index = |task: &str, cl: u32| {
972            let needle = format!("task:{task}|");
973            first
974                .iter()
975                .position(|step| step.key.contains(&needle))
976                .unwrap() as u32
977                + cl * per_cl
978        };
979        let ordered = [
980            ("source", 0),
981            ("filter", 0),
982            ("source", 1),
983            ("filter", 1),
984            ("fuse", 0),
985            ("fuse", 1),
986        ]
987        .map(|(task, cl)| index(task, cl));
988        mission.workers = vec![
989            worker(0, ordered.to_vec()),
990            worker(1, vec![index("features", 0)]),
991            worker(2, vec![index("features", 1)]),
992        ];
993        mission.dependencies = (0..2)
994            .flat_map(|cl| {
995                [("source", "features"), ("features", "fuse")].map(|(from, to)| CuPlanDependency {
996                    from: index(from, cl),
997                    to: index(to, cl),
998                    cycle_lag: 0,
999                })
1000            })
1001            .collect();
1002        plan
1003    }
1004
1005    #[test]
1006    fn only_stateless_task_invocations_may_overlap_across_copperlists() {
1007        let stateless = fork_join("stateless_task");
1008        overlapping_features(&stateless)
1009            .validate(&stateless)
1010            .unwrap();
1011
1012        let stateful = fork_join("task");
1013        let error = overlapping_features(&stateful)
1014            .validate(&stateful)
1015            .unwrap_err();
1016        assert!(
1017            error.to_string().contains("Missing precedence")
1018                && error.to_string().contains("task:features"),
1019            "{error}"
1020        );
1021    }
1022
1023    #[test]
1024    fn shared_resources_require_order_unless_declared_concurrent() {
1025        let config = CuConfig::deserialize_ron(
1026            r#"(
1027                logging: (copperlist_count: 2),
1028                resources: [(id: "board", provider: "Board")],
1029                tasks: [(id: "left", type: "Source", kind: source,
1030                        resources: {"bus": "board.i2c"}),
1031                    (id: "right", type: "Source", kind: source,
1032                        resources: {"bus": "board.i2c"}),
1033                    (id: "alone", type: "Source", kind: source,
1034                        resources: {"bus": "board.spi"})],
1035            )"#,
1036        )
1037        .unwrap();
1038        let mut plan = CuPlan::from_config(&config).unwrap();
1039        let mission = plan.missions.get_mut("default").unwrap();
1040        let index = |needle: &str| {
1041            mission
1042                .steps
1043                .iter()
1044                .position(|step| step.key.contains(needle))
1045                .unwrap() as u32
1046        };
1047        let (left, right, alone) = (
1048            index("task:left|"),
1049            index("task:right|"),
1050            index("task:alone|"),
1051        );
1052        let shared = |edge: &CuPlanDependency| {
1053            edge.from != edge.to
1054                && [left, right].contains(&edge.from)
1055                && [left, right].contains(&edge.to)
1056        };
1057        mission.max_in_flight = 2;
1058        mission.workers = vec![worker(0, vec![left, alone]), worker(1, vec![right])];
1059        mission.dependencies.retain(|edge| !shared(edge));
1060        assert!(plan.validate(&config).is_err());
1061        plan.concurrent_resources = vec!["board.i2c".into()];
1062        plan.validate(&config).unwrap();
1063    }
1064
1065    #[test]
1066    fn bridge_channels_require_shared_instance_recurrence() {
1067        let config = CuConfig::deserialize_ron(
1068            r#"(
1069                logging: (copperlist_count: 2),
1070                tasks: [(id: "left", type: "Sink"), (id: "right", type: "Sink")],
1071                bridges: [(id: "radio", type: "Radio",
1072                    channels: [Rx(id: "a"), Rx(id: "b")])],
1073                cnx: [(src: "radio/a", dst: "left", msg: "u32"),
1074                    (src: "radio/b", dst: "right", msg: "u32")],
1075            )"#,
1076        )
1077        .unwrap();
1078        let mut plan = CuPlan::from_config(&config).unwrap();
1079        let mission = plan.missions.get_mut("default").unwrap();
1080        let index = |needle: &str| {
1081            mission
1082                .steps
1083                .iter()
1084                .position(|step| step.key.contains(needle))
1085                .unwrap() as u32
1086        };
1087        let (a, b, left, right) = (
1088            index("bridge:radio:rx:a|"),
1089            index("bridge:radio:rx:b|"),
1090            index("task:left|"),
1091            index("task:right|"),
1092        );
1093        mission.max_in_flight = 2;
1094        mission.workers = vec![worker(0, vec![a, left]), worker(1, vec![b, right])];
1095        mission.dependencies = vec![
1096            CuPlanDependency {
1097                from: a,
1098                to: left,
1099                cycle_lag: 0,
1100            },
1101            CuPlanDependency {
1102                from: b,
1103                to: right,
1104                cycle_lag: 0,
1105            },
1106        ];
1107        assert!(plan.validate(&config).is_err());
1108        plan.missions
1109            .get_mut("default")
1110            .unwrap()
1111            .dependencies
1112            .extend([
1113                CuPlanDependency {
1114                    from: a,
1115                    to: b,
1116                    cycle_lag: 0,
1117                },
1118                CuPlanDependency {
1119                    from: b,
1120                    to: a,
1121                    cycle_lag: 1,
1122                },
1123            ]);
1124        plan.validate(&config).unwrap();
1125    }
1126
1127    #[test]
1128    fn background_policy_is_complete_and_only_lag_one_is_deterministic() {
1129        let config = CuConfig::deserialize_ron(
1130            r#"(
1131                runtime: (thread_pools: [(id: "vision", threads: 1)]),
1132                tasks: [(id: "src", type: "Source"),
1133                    (id: "detector", type: "Detector", background: (pool: "vision")),
1134                    (id: "sink", type: "Sink")],
1135                cnx: [(src: "src", dst: "detector", msg: "u32"),
1136                    (src: "detector", dst: "sink", msg: "u32")],
1137            )"#,
1138        )
1139        .unwrap();
1140        let plan = CuPlan::from_config(&config).unwrap();
1141        assert_eq!(plan.missions["default"].nondeterminism().len(), 1);
1142        let mut deterministic = plan.clone();
1143        deterministic
1144            .missions
1145            .get_mut("default")
1146            .unwrap()
1147            .background[0]
1148            .result = CuPlanBackgroundResult::Lag { lag: 1 };
1149        deterministic.validate(&config).unwrap();
1150        assert!(
1151            deterministic.missions["default"]
1152                .nondeterminism()
1153                .is_empty()
1154        );
1155
1156        for mutation in 0..5 {
1157            let mut invalid = plan.clone();
1158            let entries = &mut invalid.missions.get_mut("default").unwrap().background;
1159            match mutation {
1160                0 => entries.clear(),
1161                1 => entries[0].pool = "missing".into(),
1162                2 => entries[0].max_running = 0,
1163                3 => entries[0].max_running = 2,
1164                _ => entries[0].result = CuPlanBackgroundResult::Lag { lag: 2 },
1165            }
1166            assert!(invalid.validate(&config).is_err(), "mutation {mutation}");
1167        }
1168    }
1169
1170    #[test]
1171    fn missions_and_unknown_schema_fields_are_rejected() {
1172        let config = CuConfig::deserialize_ron(
1173            r#"(
1174                missions: [(id: "alpha"), (id: "beta")],
1175                tasks: [(id: "src", type: "Source", kind: source)],
1176            )"#,
1177        )
1178        .unwrap();
1179        let mut plan = CuPlan::from_config(&config).unwrap();
1180        plan.missions.remove("alpha");
1181        assert!(plan.validate(&config).is_err());
1182        assert!(CuPlan::deserialize_ron("(version: 1, missions: {})").is_err());
1183        assert!(CuPlan::deserialize_ron("(missions: {}, unknown: 0)").is_err());
1184    }
1185}