Skip to main content

cu29_runtime/
distributed_replay.rs

1//! Discovery, validation, planning, and causal execution helpers for
2//! distributed deterministic replay.
3//!
4//! The distributed replay flow is:
5//! - discover Copper logs and recover runtime identity from lifecycle metadata
6//! - validate those logs against a strict multi-Copper topology
7//! - register the generated replayable app type for each subsystem
8//! - build one replay session per `(instance_id, subsystem_id)` assignment
9//! - stitch sessions together through recorded message provenance
10//! - replay the fleet in a stable causal order
11
12use crate::app::{
13    CuDistributedReplayApplication, CuRecordedReplayApplication, CuSimApplication, Subsystem,
14};
15use crate::config::{MultiCopperConfig, read_configuration_str, read_multi_configuration};
16use crate::copperlist::CopperList;
17use crate::curuntime::{
18    KeyFrame, RuntimeLifecycleConfigSource, RuntimeLifecycleEvent, RuntimeLifecycleRecord,
19    RuntimeLifecycleStackInfo,
20};
21use crate::debug::{
22    SectionIndexEntry, build_read_logger, decode_copperlists, index_log, read_section_at,
23};
24use crate::simulation::recorded_copperlist_timestamp;
25use bincode::config::standard;
26use bincode::decode_from_std_read;
27use bincode::error::DecodeError;
28use cu29_clock::{RobotClock, RobotClockMock};
29use cu29_traits::{CopperListTuple, CuError, CuResult, ErasedCuStampedDataSet, UnifiedLogType};
30use cu29_unifiedlog::memmap::MmapSectionStorage;
31use cu29_unifiedlog::{
32    NoopLogger, NoopSectionStorage, SectionStorage, UnifiedLogWrite, UnifiedLogger,
33    UnifiedLoggerBuilder, UnifiedLoggerIOReader, UnifiedLoggerRead, UnifiedLoggerWrite,
34};
35use std::any::type_name;
36use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
37use std::fmt::{Debug, Display, Formatter, Result as FmtResult};
38use std::fs;
39use std::io::Read;
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, Mutex};
42
43/// One discovered Copper log that can participate in distributed replay.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct DistributedReplayLog {
46    pub base_path: PathBuf,
47    pub stack: RuntimeLifecycleStackInfo,
48    pub config_source: RuntimeLifecycleConfigSource,
49    pub effective_config_ron: String,
50    pub mission: Option<String>,
51}
52
53impl DistributedReplayLog {
54    /// Discover a single Copper log from either its base path (`foo.copper`) or
55    /// one of its slab paths (`foo_0.copper`, `foo_1.copper`, ...).
56    pub fn discover(path: impl AsRef<Path>) -> CuResult<Self> {
57        let requested_path = path.as_ref();
58        let normalized_path = normalize_candidate_log_base(requested_path);
59        match Self::discover_from_base_path(requested_path) {
60            Ok(log) => Ok(log),
61            Err(_) if normalized_path != requested_path => {
62                Self::discover_from_base_path(&normalized_path)
63            }
64            Err(err) => Err(err),
65        }
66    }
67
68    fn discover_from_base_path(base_path: &Path) -> CuResult<Self> {
69        let UnifiedLogger::Read(read_logger) = UnifiedLoggerBuilder::new()
70            .file_base_name(base_path)
71            .build()
72            .map_err(|err| {
73                CuError::new_with_cause(
74                    &format!(
75                        "Failed to open Copper log '{}' for distributed replay discovery",
76                        base_path.display()
77                    ),
78                    err,
79                )
80            })?
81        else {
82            return Err(CuError::from(
83                "Expected a readable unified logger during distributed replay discovery",
84            ));
85        };
86
87        let mut reader = UnifiedLoggerIOReader::new(read_logger, UnifiedLogType::RuntimeLifecycle);
88        let mut instantiated: Option<(
89            RuntimeLifecycleConfigSource,
90            String,
91            RuntimeLifecycleStackInfo,
92        )> = None;
93        let mut mission = None;
94
95        while let Some(record) =
96            read_next_entry::<RuntimeLifecycleRecord>(&mut reader).map_err(|err| {
97                CuError::from(format!(
98                    "Failed to decode runtime lifecycle for '{}': {err}",
99                    base_path.display()
100                ))
101            })?
102        {
103            match record.event {
104                RuntimeLifecycleEvent::Instantiated {
105                    config_source,
106                    effective_config_ron,
107                    stack,
108                } if instantiated.is_none() => {
109                    instantiated = Some((config_source, effective_config_ron, stack));
110                }
111                RuntimeLifecycleEvent::MissionStarted {
112                    mission: started_mission,
113                } if mission.is_none() => {
114                    mission = Some(started_mission);
115                }
116                _ => {}
117            }
118
119            if instantiated.is_some() && mission.is_some() {
120                break;
121            }
122        }
123
124        let Some((config_source, effective_config_ron, stack)) = instantiated else {
125            return Err(CuError::from(format!(
126                "Copper log '{}' has no RuntimeLifecycle::Instantiated record",
127                base_path.display()
128            )));
129        };
130
131        Ok(Self {
132            base_path: base_path.to_path_buf(),
133            stack,
134            config_source,
135            effective_config_ron,
136            mission,
137        })
138    }
139
140    #[inline]
141    pub fn instance_id(&self) -> u32 {
142        self.stack.instance_id
143    }
144
145    #[inline]
146    pub fn subsystem_code(&self) -> u16 {
147        self.stack.subsystem_code
148    }
149
150    #[inline]
151    pub fn subsystem_id(&self) -> Option<&str> {
152        self.stack.subsystem_id.as_deref()
153    }
154}
155
156/// Discovery error recorded for one log candidate.
157#[derive(Debug, Clone)]
158pub struct DistributedReplayDiscoveryFailure {
159    pub candidate_path: PathBuf,
160    pub error: String,
161}
162
163impl Display for DistributedReplayDiscoveryFailure {
164    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
165        write!(
166            f,
167            "{}: {}",
168            self.candidate_path.display(),
169            self.error.as_str()
170        )
171    }
172}
173
174/// Result of scanning one or more paths for distributed replay logs.
175#[derive(Debug, Clone, Default)]
176pub struct DistributedReplayCatalog {
177    pub logs: Vec<DistributedReplayLog>,
178    pub failures: Vec<DistributedReplayDiscoveryFailure>,
179}
180
181impl DistributedReplayCatalog {
182    /// Discover logs from a list of files and/or directories.
183    ///
184    /// Directories are traversed recursively. Any physical slab file
185    /// (`*_0.copper`, `*_1.copper`, ...) is normalized back to its base log path.
186    pub fn discover<I, P>(inputs: I) -> CuResult<Self>
187    where
188        I: IntoIterator<Item = P>,
189        P: AsRef<Path>,
190    {
191        let mut candidates = BTreeSet::new();
192        for input in inputs {
193            collect_candidate_base_paths(input.as_ref(), &mut candidates)?;
194        }
195
196        let mut logs = Vec::new();
197        let mut failures = Vec::new();
198
199        for candidate in candidates {
200            match DistributedReplayLog::discover(&candidate) {
201                Ok(log) => logs.push(log),
202                Err(err) => failures.push(DistributedReplayDiscoveryFailure {
203                    candidate_path: candidate,
204                    error: err.to_string(),
205                }),
206            }
207        }
208
209        logs.sort_by(|left, right| {
210            (
211                left.instance_id(),
212                left.subsystem_code(),
213                left.subsystem_id(),
214                left.base_path.as_os_str(),
215            )
216                .cmp(&(
217                    right.instance_id(),
218                    right.subsystem_code(),
219                    right.subsystem_id(),
220                    right.base_path.as_os_str(),
221                ))
222        });
223        failures.sort_by(|left, right| left.candidate_path.cmp(&right.candidate_path));
224
225        Ok(Self { logs, failures })
226    }
227
228    /// Convenience wrapper for recursive discovery rooted at one directory.
229    pub fn discover_under(root: impl AsRef<Path>) -> CuResult<Self> {
230        Self::discover([root])
231    }
232}
233
234type DistributedReplaySessionFactory = fn(
235    &DistributedReplayAssignment,
236    &DistributedReplaySessionConfig,
237) -> CuResult<DistributedReplaySessionBuild>;
238
239const DEFAULT_SECTION_CACHE_CAP: usize = 8;
240const DEFAULT_REPLAY_LOG_SIZE_BYTES: usize = 64 * 1024 * 1024;
241
242#[derive(Debug, Clone, Default)]
243struct DistributedReplaySessionConfig {
244    output_root: Option<PathBuf>,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
248struct DistributedReplayOriginKey {
249    instance_id: u32,
250    subsystem_code: u16,
251    cl_id: u64,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Hash)]
255pub struct DistributedReplayCursor {
256    pub instance_id: u32,
257    pub subsystem_id: String,
258    pub cl_id: u64,
259    subsystem_code: u16,
260}
261
262impl DistributedReplayCursor {
263    #[inline]
264    fn new(instance_id: u32, subsystem_id: String, subsystem_code: u16, cl_id: u64) -> Self {
265        Self {
266            instance_id,
267            subsystem_id,
268            cl_id,
269            subsystem_code,
270        }
271    }
272
273    #[inline]
274    pub fn subsystem_code(&self) -> u16 {
275        self.subsystem_code
276    }
277}
278
279#[derive(Debug, Clone)]
280struct DistributedReplayNodeDescriptor {
281    cursor: DistributedReplayCursor,
282    origin_key: DistributedReplayOriginKey,
283    incoming_origins: BTreeSet<DistributedReplayOriginKey>,
284}
285
286#[derive(Debug, Clone)]
287struct DistributedReplayGraphNode {
288    cursor: DistributedReplayCursor,
289    session_index: usize,
290    outgoing: Vec<usize>,
291    initial_dependencies: usize,
292    remaining_dependencies: usize,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
296struct DistributedReplayReadyNode {
297    instance_id: u32,
298    subsystem_code: u16,
299    cl_id: u64,
300    node_index: usize,
301}
302
303struct DistributedReplaySessionBuild {
304    session: Box<dyn DistributedReplaySession>,
305    nodes: Vec<DistributedReplayNodeDescriptor>,
306    output_log_path: Option<PathBuf>,
307}
308
309trait DistributedReplaySession {
310    fn goto_cl(&mut self, cl_id: u64) -> CuResult<()>;
311    fn shutdown(&mut self) -> CuResult<()>;
312}
313
314#[derive(Debug, Clone)]
315struct RecordedReplayCachedSection<P: CopperListTuple> {
316    entries: Vec<Arc<CopperList<P>>>,
317}
318
319struct RecordedReplaySession<App, P, S, L>
320where
321    App: CuDistributedReplayApplication<S, L>,
322    P: CopperListTuple,
323    S: SectionStorage,
324    L: UnifiedLogWrite<S> + 'static,
325{
326    assignment: DistributedReplayAssignment,
327    app: App,
328    clock_mock: RobotClockMock,
329    log_reader: UnifiedLoggerRead,
330    sections: Vec<SectionIndexEntry>,
331    total_entries: usize,
332    keyframes: Vec<KeyFrame>,
333    started: bool,
334    current_idx: Option<usize>,
335    last_keyframe: Option<u64>,
336    cache: HashMap<usize, RecordedReplayCachedSection<P>>,
337    cache_order: VecDeque<usize>,
338    cache_cap: usize,
339    phantom: std::marker::PhantomData<(S, L)>,
340}
341
342impl<App, P, S, L> RecordedReplaySession<App, P, S, L>
343where
344    App: CuDistributedReplayApplication<S, L>
345        + CuRecordedReplayApplication<S, L, RecordedDataSet = P>,
346    P: CopperListTuple + 'static,
347    S: SectionStorage,
348    L: UnifiedLogWrite<S> + 'static,
349{
350    fn from_log(
351        assignment: DistributedReplayAssignment,
352        app: App,
353        clock_mock: RobotClockMock,
354        log_base: &Path,
355    ) -> CuResult<Self> {
356        crate::logcodec::set_effective_config_ron::<P>(&assignment.log.effective_config_ron);
357        let (sections, keyframes, total_entries) =
358            index_log::<P, _>(log_base, &recorded_copperlist_timestamp::<P>)?;
359        let log_reader = build_read_logger(log_base)?;
360        Ok(Self {
361            assignment,
362            app,
363            clock_mock,
364            log_reader,
365            sections,
366            total_entries,
367            keyframes,
368            started: false,
369            current_idx: None,
370            last_keyframe: None,
371            cache: HashMap::new(),
372            cache_order: VecDeque::new(),
373            cache_cap: DEFAULT_SECTION_CACHE_CAP,
374            phantom: std::marker::PhantomData,
375        })
376    }
377
378    fn describe_nodes(&mut self) -> CuResult<Vec<DistributedReplayNodeDescriptor>> {
379        let mut nodes = Vec::with_capacity(self.total_entries);
380        for idx in 0..self.total_entries {
381            let (copperlist, _) = self.copperlist_at(idx)?;
382            let cursor = DistributedReplayCursor::new(
383                self.assignment.instance_id,
384                self.assignment.subsystem_id.clone(),
385                self.assignment.log.subsystem_code(),
386                copperlist.id,
387            );
388            nodes.push(DistributedReplayNodeDescriptor {
389                origin_key: DistributedReplayOriginKey {
390                    instance_id: cursor.instance_id,
391                    subsystem_code: cursor.subsystem_code(),
392                    cl_id: cursor.cl_id,
393                },
394                incoming_origins: copperlist_origins(copperlist.as_ref()),
395                cursor,
396            });
397        }
398        Ok(nodes)
399    }
400
401    // Framework replay engine: drives the raw (app-deprecated) lifecycle on purpose.
402    #[allow(deprecated)]
403    fn ensure_started(&mut self) -> CuResult<()> {
404        if self.started {
405            return Ok(());
406        }
407        let mut noop = |_step: App::Step<'_>| crate::simulation::SimOverride::ExecuteByRuntime;
408        <App as CuSimApplication<S, L>>::start_all_tasks(&mut self.app, &mut noop)?;
409        self.started = true;
410        Ok(())
411    }
412
413    fn nearest_keyframe(&self, target_cl_id: u64) -> Option<KeyFrame> {
414        self.keyframes
415            .iter()
416            .filter(|keyframe| keyframe.culistid <= target_cl_id)
417            .max_by_key(|keyframe| keyframe.culistid)
418            .cloned()
419    }
420
421    fn restore_keyframe(&mut self, keyframe: &KeyFrame) -> CuResult<()> {
422        <App as CuSimApplication<S, L>>::restore_keyframe(&mut self.app, keyframe)?;
423        self.clock_mock.set_value(keyframe.timestamp.as_nanos());
424        self.last_keyframe = Some(keyframe.culistid);
425        Ok(())
426    }
427
428    fn find_section_for_index(&self, idx: usize) -> Option<usize> {
429        self.sections
430            .binary_search_by(|section| {
431                if idx < section.start_idx {
432                    std::cmp::Ordering::Greater
433                } else if idx >= section.start_idx + section.len {
434                    std::cmp::Ordering::Less
435                } else {
436                    std::cmp::Ordering::Equal
437                }
438            })
439            .ok()
440    }
441
442    fn find_section_for_cl_id(&self, cl_id: u64) -> Option<usize> {
443        self.sections
444            .binary_search_by(|section| {
445                if cl_id < section.first_id {
446                    std::cmp::Ordering::Greater
447                } else if cl_id > section.last_id {
448                    std::cmp::Ordering::Less
449                } else {
450                    std::cmp::Ordering::Equal
451                }
452            })
453            .ok()
454    }
455
456    fn touch_cache(&mut self, key: usize) {
457        if let Some(position) = self.cache_order.iter().position(|entry| *entry == key) {
458            self.cache_order.remove(position);
459        }
460        self.cache_order.push_back(key);
461        while self.cache_order.len() > self.cache_cap {
462            if let Some(oldest) = self.cache_order.pop_front() {
463                self.cache.remove(&oldest);
464            }
465        }
466    }
467
468    fn load_section(&mut self, section_idx: usize) -> CuResult<&RecordedReplayCachedSection<P>> {
469        if self.cache.contains_key(&section_idx) {
470            self.touch_cache(section_idx);
471            return Ok(self.cache.get(&section_idx).expect("cache entry exists"));
472        }
473
474        let entry = &self.sections[section_idx];
475        let (header, data) = read_section_at(&mut self.log_reader, entry.pos)?;
476        if header.entry_type != UnifiedLogType::CopperList {
477            return Err(CuError::from(
478                "Section type mismatch while loading distributed replay copperlists",
479            ));
480        }
481        let (entries, _) = decode_copperlists::<P, _>(&data, &recorded_copperlist_timestamp::<P>)?;
482        self.cache
483            .insert(section_idx, RecordedReplayCachedSection { entries });
484        self.touch_cache(section_idx);
485        Ok(self.cache.get(&section_idx).expect("cache entry exists"))
486    }
487
488    fn copperlist_at(&mut self, idx: usize) -> CuResult<(Arc<CopperList<P>>, Option<KeyFrame>)> {
489        let section_idx = self
490            .find_section_for_index(idx)
491            .ok_or_else(|| CuError::from("Distributed replay index is outside the log"))?;
492        let start_idx = self.sections[section_idx].start_idx;
493        let section = self.load_section(section_idx)?;
494        let local_idx = idx - start_idx;
495        let copperlist = section
496            .entries
497            .get(local_idx)
498            .ok_or_else(|| CuError::from("Corrupt distributed replay section index"))?
499            .clone();
500        let keyframe = self
501            .keyframes
502            .iter()
503            .find(|keyframe| keyframe.culistid == copperlist.id)
504            .cloned();
505        Ok((copperlist, keyframe))
506    }
507
508    fn index_for_cl_id(&mut self, cl_id: u64) -> CuResult<usize> {
509        let section_idx = self
510            .find_section_for_cl_id(cl_id)
511            .ok_or_else(|| CuError::from("Requested CopperList id is not present in the log"))?;
512        let start_idx = self.sections[section_idx].start_idx;
513        let section = self.load_section(section_idx)?;
514        for (offset, copperlist) in section.entries.iter().enumerate() {
515            if copperlist.id == cl_id {
516                return Ok(start_idx + offset);
517            }
518        }
519        Err(CuError::from(
520            "Requested CopperList id is missing from its indexed log section",
521        ))
522    }
523
524    fn replay_range(
525        &mut self,
526        start_idx: usize,
527        end_idx: usize,
528        replay_keyframe: Option<&KeyFrame>,
529    ) -> CuResult<()> {
530        for idx in start_idx..=end_idx {
531            let (copperlist, keyframe) = self.copperlist_at(idx)?;
532            let keyframe = replay_keyframe
533                .filter(|candidate| candidate.culistid == copperlist.id)
534                .or(keyframe
535                    .as_ref()
536                    .filter(|candidate| candidate.culistid == copperlist.id));
537            let expected = if idx > 0 {
538                self.copperlist_at(idx - 1)?
539                    .0
540                    .id
541                    .checked_add(1)
542                    .ok_or_else(|| CuError::from("Replay CopperList id overflow"))?
543            } else {
544                0
545            };
546            crate::continuity::validate_replay_continuity(
547                expected,
548                copperlist.id,
549                keyframe.map(|frame| frame.culistid),
550            )?;
551            <App as CuRecordedReplayApplication<S, L>>::replay_recorded_copperlist(
552                &mut self.app,
553                &self.clock_mock,
554                copperlist.as_ref(),
555                keyframe,
556            )?;
557            self.current_idx = Some(idx);
558        }
559        Ok(())
560    }
561
562    fn goto_index(&mut self, target_idx: usize) -> CuResult<()> {
563        self.ensure_started()?;
564        if target_idx >= self.total_entries {
565            return Err(CuError::from(
566                "Distributed replay target is outside the log",
567            ));
568        }
569
570        let (target_copperlist, _) = self.copperlist_at(target_idx)?;
571        let target_cl_id = target_copperlist.id;
572
573        let replay_start_idx;
574        let replay_keyframe;
575
576        if let Some(current_idx) = self.current_idx {
577            if current_idx == target_idx {
578                return Ok(());
579            }
580
581            if target_idx > current_idx {
582                replay_start_idx = current_idx + 1;
583                replay_keyframe = None;
584            } else {
585                let keyframe = self.nearest_keyframe(target_cl_id).ok_or_else(|| {
586                    CuError::from("No keyframe is available to rewind distributed replay")
587                })?;
588                self.restore_keyframe(&keyframe)?;
589                replay_start_idx = self.index_for_cl_id(keyframe.culistid)?;
590                replay_keyframe = Some(keyframe);
591            }
592        } else {
593            let keyframe = self.nearest_keyframe(target_cl_id).ok_or_else(|| {
594                CuError::from("No keyframe is available to initialize distributed replay")
595            })?;
596            self.restore_keyframe(&keyframe)?;
597            replay_start_idx = self.index_for_cl_id(keyframe.culistid)?;
598            replay_keyframe = Some(keyframe);
599        }
600
601        self.replay_range(replay_start_idx, target_idx, replay_keyframe.as_ref())
602    }
603}
604
605impl<App, P, S, L> DistributedReplaySession for RecordedReplaySession<App, P, S, L>
606where
607    App: CuDistributedReplayApplication<S, L>
608        + CuRecordedReplayApplication<S, L, RecordedDataSet = P>,
609    P: CopperListTuple + 'static,
610    S: SectionStorage,
611    L: UnifiedLogWrite<S> + 'static,
612{
613    fn goto_cl(&mut self, cl_id: u64) -> CuResult<()> {
614        let target_idx = self.index_for_cl_id(cl_id)?;
615        self.goto_index(target_idx)
616    }
617
618    // Framework replay engine: drives the raw (app-deprecated) lifecycle on purpose.
619    #[allow(deprecated)]
620    fn shutdown(&mut self) -> CuResult<()> {
621        if !self.started {
622            return Ok(());
623        }
624
625        let mut noop = |_step: App::Step<'_>| crate::simulation::SimOverride::ExecuteByRuntime;
626        <App as CuSimApplication<S, L>>::stop_all_tasks(&mut self.app, &mut noop)?;
627        self.started = false;
628        Ok(())
629    }
630}
631
632/// One typed subsystem registration provided to the distributed replay builder.
633#[derive(Clone)]
634pub struct DistributedReplayAppRegistration {
635    pub subsystem: Subsystem,
636    pub app_type_name: &'static str,
637    session_factory: DistributedReplaySessionFactory,
638}
639
640impl Debug for DistributedReplayAppRegistration {
641    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
642        f.debug_struct("DistributedReplayAppRegistration")
643            .field("subsystem", &self.subsystem)
644            .field("app_type_name", &self.app_type_name)
645            .finish()
646    }
647}
648
649impl PartialEq for DistributedReplayAppRegistration {
650    fn eq(&self, other: &Self) -> bool {
651        self.subsystem == other.subsystem && self.app_type_name == other.app_type_name
652    }
653}
654
655impl Eq for DistributedReplayAppRegistration {}
656
657/// One validated log assignment for a subsystem instance.
658#[derive(Debug, Clone, PartialEq, Eq)]
659pub struct DistributedReplayAssignment {
660    pub instance_id: u32,
661    pub subsystem_id: String,
662    pub log: DistributedReplayLog,
663    pub registration: DistributedReplayAppRegistration,
664}
665
666/// Validated replay plan produced by [`DistributedReplayBuilder`].
667#[derive(Debug, Clone)]
668pub struct DistributedReplayPlan {
669    pub multi_config_path: PathBuf,
670    pub multi_config: MultiCopperConfig,
671    pub catalog: DistributedReplayCatalog,
672    pub selected_instances: Vec<u32>,
673    pub mission: Option<String>,
674    pub registrations: Vec<DistributedReplayAppRegistration>,
675    pub assignments: Vec<DistributedReplayAssignment>,
676}
677
678impl DistributedReplayPlan {
679    #[inline]
680    pub fn builder(multi_config_path: impl AsRef<Path>) -> CuResult<DistributedReplayBuilder> {
681        DistributedReplayBuilder::new(multi_config_path)
682    }
683
684    #[inline]
685    pub fn assignment(
686        &self,
687        instance_id: u32,
688        subsystem_id: &str,
689    ) -> Option<&DistributedReplayAssignment> {
690        self.assignments.iter().find(|assignment| {
691            assignment.instance_id == instance_id && assignment.subsystem_id == subsystem_id
692        })
693    }
694
695    /// Build a causal distributed replay engine from this validated plan.
696    pub fn start(self) -> CuResult<DistributedReplayEngine> {
697        DistributedReplayEngine::new(self, DistributedReplaySessionConfig::default())
698    }
699
700    /// Build a causal distributed replay engine and persist replayed logs under `output_root`.
701    pub fn start_recording_logs_under(
702        self,
703        output_root: impl AsRef<Path>,
704    ) -> CuResult<DistributedReplayEngine> {
705        DistributedReplayEngine::new(
706            self,
707            DistributedReplaySessionConfig {
708                output_root: Some(output_root.as_ref().to_path_buf()),
709            },
710        )
711    }
712}
713
714/// Aggregated validation diagnostics emitted while constructing a distributed replay plan.
715#[derive(Debug, Clone, Default)]
716pub struct DistributedReplayValidationError {
717    pub issues: Vec<String>,
718}
719
720impl DistributedReplayValidationError {
721    fn push(&mut self, issue: impl Into<String>) {
722        self.issues.push(issue.into());
723    }
724
725    fn is_empty(&self) -> bool {
726        self.issues.is_empty()
727    }
728}
729
730impl Display for DistributedReplayValidationError {
731    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
732        writeln!(f, "Distributed replay validation failed:")?;
733        for issue in &self.issues {
734            writeln!(f, " - {issue}")?;
735        }
736        Ok(())
737    }
738}
739
740/// Builder for a validated distributed replay plan.
741#[derive(Debug, Clone)]
742pub struct DistributedReplayBuilder {
743    multi_config_path: PathBuf,
744    multi_config: MultiCopperConfig,
745    discovery_inputs: Vec<PathBuf>,
746    catalog: Option<DistributedReplayCatalog>,
747    registrations: BTreeMap<String, DistributedReplayAppRegistration>,
748    selected_instances: Option<BTreeSet<u32>>,
749}
750
751impl DistributedReplayBuilder {
752    /// Load a strict multi-Copper config and start building a distributed replay plan.
753    pub fn new(multi_config_path: impl AsRef<Path>) -> CuResult<Self> {
754        let multi_config_path = multi_config_path.as_ref().to_path_buf();
755        let multi_config = read_multi_configuration(&multi_config_path.to_string_lossy())?;
756        Ok(Self {
757            multi_config_path,
758            multi_config,
759            discovery_inputs: Vec::new(),
760            catalog: None,
761            registrations: BTreeMap::new(),
762            selected_instances: None,
763        })
764    }
765
766    /// Replace the discovered catalog explicitly.
767    pub fn with_catalog(mut self, catalog: DistributedReplayCatalog) -> Self {
768        self.catalog = Some(catalog);
769        self
770    }
771
772    /// Discover logs from files and/or directories.
773    ///
774    /// Directories are walked recursively by [`DistributedReplayCatalog`].
775    pub fn discover_logs<I, P>(mut self, inputs: I) -> CuResult<Self>
776    where
777        I: IntoIterator<Item = P>,
778        P: AsRef<Path>,
779    {
780        self.discovery_inputs
781            .extend(inputs.into_iter().map(|path| path.as_ref().to_path_buf()));
782        self.catalog = Some(DistributedReplayCatalog::discover(
783            self.discovery_inputs.iter().collect::<Vec<_>>(),
784        )?);
785        Ok(self)
786    }
787
788    /// Convenience wrapper for recursive discovery under one root directory.
789    pub fn discover_logs_under(self, root: impl AsRef<Path>) -> CuResult<Self> {
790        self.discover_logs([root.as_ref().to_path_buf()])
791    }
792
793    /// Restrict plan construction to a subset of instance ids.
794    pub fn instances<I>(mut self, instances: I) -> Self
795    where
796        I: IntoIterator<Item = u32>,
797    {
798        self.selected_instances = Some(instances.into_iter().collect());
799        self
800    }
801
802    /// Register the generated app type expected for one subsystem.
803    pub fn register<App>(mut self, subsystem_id: &str) -> CuResult<Self>
804    where
805        App: CuDistributedReplayApplication<NoopSectionStorage, NoopLogger>
806            + CuDistributedReplayApplication<MmapSectionStorage, UnifiedLoggerWrite>
807            + 'static,
808    {
809        if self.registrations.contains_key(subsystem_id) {
810            return Err(CuError::from(format!(
811                "Subsystem '{}' is already registered for distributed replay",
812                subsystem_id
813            )));
814        }
815
816        let expected_subsystem = self.multi_config.subsystem(subsystem_id).ok_or_else(|| {
817            CuError::from(format!(
818                "Multi-Copper config '{}' does not define subsystem '{}'",
819                self.multi_config_path.display(),
820                subsystem_id
821            ))
822        })?;
823
824        let registered_subsystem = App::subsystem();
825        let Some(registered_subsystem_id) = registered_subsystem.id() else {
826            return Err(CuError::from(format!(
827                "App type '{}' was not generated for a multi-Copper subsystem and cannot be registered for distributed replay",
828                type_name::<App>()
829            )));
830        };
831
832        if registered_subsystem_id != subsystem_id {
833            return Err(CuError::from(format!(
834                "App type '{}' declares subsystem '{}' but was registered as '{}'",
835                type_name::<App>(),
836                registered_subsystem_id,
837                subsystem_id
838            )));
839        }
840
841        let registered_subsystem_code = registered_subsystem.code();
842        if registered_subsystem_code != expected_subsystem.subsystem_code {
843            return Err(CuError::from(format!(
844                "App type '{}' declares subsystem code {} for '{}' but multi-Copper config '{}' expects {}",
845                type_name::<App>(),
846                registered_subsystem_code,
847                subsystem_id,
848                self.multi_config_path.display(),
849                expected_subsystem.subsystem_code
850            )));
851        }
852
853        self.registrations.insert(
854            subsystem_id.to_string(),
855            DistributedReplayAppRegistration {
856                subsystem: registered_subsystem,
857                app_type_name: type_name::<App>(),
858                session_factory: build_distributed_replay_session::<App>,
859            },
860        );
861        Ok(self)
862    }
863
864    /// Validate discovery + registrations and prepare a typed replay plan.
865    pub fn build(self) -> CuResult<DistributedReplayPlan> {
866        let catalog = match self.catalog {
867            Some(catalog) => catalog,
868            None if self.discovery_inputs.is_empty() => DistributedReplayCatalog::default(),
869            None => DistributedReplayCatalog::discover(
870                self.discovery_inputs.iter().collect::<Vec<_>>(),
871            )?,
872        };
873
874        let mut validation = DistributedReplayValidationError::default();
875
876        for failure in &catalog.failures {
877            validation.push(format!(
878                "discovery failure for '{}': {}",
879                failure.candidate_path.display(),
880                failure.error
881            ));
882        }
883
884        let subsystem_map: BTreeMap<_, _> = self
885            .multi_config
886            .subsystems
887            .iter()
888            .map(|subsystem| (subsystem.id.clone(), subsystem))
889            .collect();
890
891        for subsystem in subsystem_map.keys() {
892            if !self.registrations.contains_key(subsystem) {
893                validation.push(format!(
894                    "missing app registration for subsystem '{}'",
895                    subsystem
896                ));
897            }
898        }
899
900        let mut discovered_instances = BTreeSet::new();
901        let mut logs_by_target: BTreeMap<(u32, String), Vec<DistributedReplayLog>> =
902            BTreeMap::new();
903
904        for log in &catalog.logs {
905            let Some(subsystem_id) = log.subsystem_id() else {
906                validation.push(format!(
907                    "discovered log '{}' is missing subsystem_id runtime metadata",
908                    log.base_path.display()
909                ));
910                continue;
911            };
912
913            let Some(expected_subsystem) = subsystem_map.get(subsystem_id) else {
914                validation.push(format!(
915                    "discovered log '{}' belongs to subsystem '{}' which is not present in multi-Copper config '{}'",
916                    log.base_path.display(),
917                    subsystem_id,
918                    self.multi_config_path.display()
919                ));
920                continue;
921            };
922
923            if log.subsystem_code() != expected_subsystem.subsystem_code {
924                validation.push(format!(
925                    "discovered log '{}' reports subsystem code {} for '{}' but multi-Copper config '{}' expects {}",
926                    log.base_path.display(),
927                    log.subsystem_code(),
928                    subsystem_id,
929                    self.multi_config_path.display(),
930                    expected_subsystem.subsystem_code
931                ));
932            }
933
934            discovered_instances.insert(log.instance_id());
935            logs_by_target
936                .entry((log.instance_id(), subsystem_id.to_string()))
937                .or_default()
938                .push(log.clone());
939        }
940
941        for ((instance_id, subsystem_id), logs) in &logs_by_target {
942            if logs.len() > 1 {
943                validation.push(format!(
944                    "found {} logs for instance {} subsystem '{}': {}",
945                    logs.len(),
946                    instance_id,
947                    subsystem_id,
948                    join_log_paths(logs)
949                ));
950            }
951        }
952
953        let selected_instances: Vec<u32> =
954            if let Some(selected_instances) = &self.selected_instances {
955                let mut selected_instances: Vec<_> = selected_instances.iter().copied().collect();
956                selected_instances.sort_unstable();
957                for instance_id in &selected_instances {
958                    if !discovered_instances.contains(instance_id) {
959                        validation.push(format!(
960                            "selected instance {} has no discovered logs",
961                            instance_id
962                        ));
963                    }
964                }
965                selected_instances
966            } else {
967                discovered_instances.iter().copied().collect()
968            };
969
970        if selected_instances.is_empty() {
971            validation.push("no instances selected for distributed replay");
972        }
973
974        for instance_id in &selected_instances {
975            for subsystem in &self.multi_config.subsystems {
976                if !logs_by_target.contains_key(&(*instance_id, subsystem.id.clone())) {
977                    validation.push(format!(
978                        "missing log for instance {} subsystem '{}'",
979                        instance_id, subsystem.id
980                    ));
981                }
982            }
983        }
984
985        let mut known_missions = BTreeSet::new();
986        for instance_id in &selected_instances {
987            for subsystem in &self.multi_config.subsystems {
988                if let Some(logs) = logs_by_target.get(&(*instance_id, subsystem.id.clone()))
989                    && let Some(log) = logs.first()
990                    && let Some(mission) = &log.mission
991                {
992                    known_missions.insert(mission.clone());
993                }
994            }
995        }
996        if known_missions.len() > 1 {
997            validation.push(format!(
998                "selected logs disagree on mission: {}",
999                known_missions.into_iter().collect::<Vec<_>>().join(", ")
1000            ));
1001        }
1002
1003        if !validation.is_empty() {
1004            return Err(CuError::from(validation.to_string()));
1005        }
1006
1007        let mission = selected_instances
1008            .iter()
1009            .flat_map(|instance_id| {
1010                self.multi_config.subsystems.iter().filter_map(|subsystem| {
1011                    logs_by_target
1012                        .get(&(*instance_id, subsystem.id.clone()))
1013                        .and_then(|logs| logs.first())
1014                        .and_then(|log| log.mission.clone())
1015                })
1016            })
1017            .next();
1018
1019        let mut registrations: Vec<_> = self.registrations.into_values().collect();
1020        registrations.sort_by(|left, right| left.subsystem.id().cmp(&right.subsystem.id()));
1021
1022        let mut assignments = Vec::new();
1023        for instance_id in &selected_instances {
1024            for subsystem in &self.multi_config.subsystems {
1025                let log = logs_by_target
1026                    .get(&(*instance_id, subsystem.id.clone()))
1027                    .and_then(|logs| logs.first())
1028                    .expect("validated distributed replay plan is missing a log")
1029                    .clone();
1030                let registration = registrations
1031                    .iter()
1032                    .find(|registration| registration.subsystem.id() == Some(subsystem.id.as_str()))
1033                    .expect("validated distributed replay plan is missing a registration")
1034                    .clone();
1035                assignments.push(DistributedReplayAssignment {
1036                    instance_id: *instance_id,
1037                    subsystem_id: subsystem.id.clone(),
1038                    log,
1039                    registration,
1040                });
1041            }
1042        }
1043        assignments.sort_by(|left, right| {
1044            (
1045                left.instance_id,
1046                left.registration.subsystem.code(),
1047                left.subsystem_id.as_str(),
1048            )
1049                .cmp(&(
1050                    right.instance_id,
1051                    right.registration.subsystem.code(),
1052                    right.subsystem_id.as_str(),
1053                ))
1054        });
1055
1056        Ok(DistributedReplayPlan {
1057            multi_config_path: self.multi_config_path,
1058            multi_config: self.multi_config,
1059            catalog,
1060            selected_instances,
1061            mission,
1062            registrations,
1063            assignments,
1064        })
1065    }
1066}
1067
1068fn build_distributed_replay_session<App>(
1069    assignment: &DistributedReplayAssignment,
1070    session_config: &DistributedReplaySessionConfig,
1071) -> CuResult<DistributedReplaySessionBuild>
1072where
1073    App: CuDistributedReplayApplication<NoopSectionStorage, NoopLogger>
1074        + CuDistributedReplayApplication<MmapSectionStorage, UnifiedLoggerWrite>
1075        + 'static,
1076{
1077    let config = read_configuration_str(assignment.log.effective_config_ron.clone(), None)
1078        .map_err(|err| {
1079            CuError::from(format!(
1080                "Failed to parse recorded effective config from '{}': {err}",
1081                assignment.log.base_path.display()
1082            ))
1083        })?;
1084    let (clock, clock_mock) = RobotClock::mock();
1085
1086    if let Some(output_root) = &session_config.output_root {
1087        let output_log_path = replay_output_log_path(output_root, assignment)?;
1088        let logger = build_replay_output_logger(
1089            &output_log_path,
1090            replay_output_log_size_bytes(assignment, &config),
1091        )?;
1092        let app = <App as CuDistributedReplayApplication<
1093            MmapSectionStorage,
1094            UnifiedLoggerWrite,
1095        >>::build_distributed_replay(
1096            clock.clone(), logger, assignment.instance_id, Some(config)
1097        )?;
1098        let mut session = RecordedReplaySession::<
1099            App,
1100            <App as CuRecordedReplayApplication<
1101                MmapSectionStorage,
1102                UnifiedLoggerWrite,
1103            >>::RecordedDataSet,
1104            MmapSectionStorage,
1105            UnifiedLoggerWrite,
1106        >::from_log(assignment.clone(), app, clock_mock, &assignment.log.base_path)?;
1107        let nodes = session.describe_nodes()?;
1108        return Ok(DistributedReplaySessionBuild {
1109            session: Box::new(session),
1110            nodes,
1111            output_log_path: Some(output_log_path),
1112        });
1113    }
1114
1115    let logger = Arc::new(Mutex::new(NoopLogger::new()));
1116    let app = <App as CuDistributedReplayApplication<NoopSectionStorage, NoopLogger>>::build_distributed_replay(
1117        clock,
1118        logger,
1119        assignment.instance_id,
1120        Some(config),
1121    )?;
1122    let mut session = RecordedReplaySession::<
1123        App,
1124        <App as CuRecordedReplayApplication<NoopSectionStorage, NoopLogger>>::RecordedDataSet,
1125        NoopSectionStorage,
1126        NoopLogger,
1127    >::from_log(
1128        assignment.clone(),
1129        app,
1130        clock_mock,
1131        &assignment.log.base_path,
1132    )?;
1133    let nodes = session.describe_nodes()?;
1134    Ok(DistributedReplaySessionBuild {
1135        session: Box::new(session),
1136        nodes,
1137        output_log_path: None,
1138    })
1139}
1140
1141fn replay_output_log_path(
1142    output_root: &Path,
1143    assignment: &DistributedReplayAssignment,
1144) -> CuResult<PathBuf> {
1145    let file_name = assignment
1146        .log
1147        .base_path
1148        .file_name()
1149        .ok_or_else(|| {
1150            CuError::from(format!(
1151                "Replay assignment log '{}' has no file name",
1152                assignment.log.base_path.display()
1153            ))
1154        })?
1155        .to_owned();
1156    Ok(output_root.join(file_name))
1157}
1158
1159fn build_replay_output_logger(
1160    path: &Path,
1161    preallocated_size: usize,
1162) -> CuResult<Arc<Mutex<UnifiedLoggerWrite>>> {
1163    if let Some(parent) = path.parent() {
1164        fs::create_dir_all(parent).map_err(|err| {
1165            CuError::new_with_cause(
1166                &format!(
1167                    "Failed to create replay log directory '{}'",
1168                    parent.display()
1169                ),
1170                err,
1171            )
1172        })?;
1173    }
1174    let UnifiedLogger::Write(writer) = UnifiedLoggerBuilder::new()
1175        .write(true)
1176        .create(true)
1177        .file_base_name(path)
1178        .preallocated_size(preallocated_size)
1179        .build()
1180        .map_err(|err| {
1181            CuError::new_with_cause(
1182                &format!("Failed to create replay log '{}'", path.display()),
1183                err,
1184            )
1185        })?
1186    else {
1187        return Err(CuError::from(format!(
1188            "Expected writable replay logger for '{}'",
1189            path.display()
1190        )));
1191    };
1192    Ok(Arc::new(Mutex::new(writer)))
1193}
1194
1195fn replay_output_log_size_bytes(
1196    assignment: &DistributedReplayAssignment,
1197    config: &crate::config::CuConfig,
1198) -> usize {
1199    if let Some(slab_zero) = slab_zero_path(&assignment.log.base_path)
1200        && let Ok(metadata) = fs::metadata(slab_zero)
1201        && let Ok(size) = usize::try_from(metadata.len())
1202    {
1203        return size.max(DEFAULT_REPLAY_LOG_SIZE_BYTES);
1204    }
1205
1206    config
1207        .logging
1208        .as_ref()
1209        .and_then(|logging| logging.slab_size_mib)
1210        .and_then(|size_mib| usize::try_from(size_mib).ok())
1211        .and_then(|size_mib| size_mib.checked_mul(1024 * 1024))
1212        .unwrap_or(DEFAULT_REPLAY_LOG_SIZE_BYTES)
1213}
1214
1215fn copperlist_origins<P: CopperListTuple>(
1216    copperlist: &CopperList<P>,
1217) -> BTreeSet<DistributedReplayOriginKey> {
1218    <CopperList<P> as ErasedCuStampedDataSet>::cumsgs(copperlist)
1219        .into_iter()
1220        .filter_map(|msg| msg.metadata().origin())
1221        .map(|origin| DistributedReplayOriginKey {
1222            instance_id: origin.instance_id,
1223            subsystem_code: origin.subsystem_code,
1224            cl_id: origin.cl_id,
1225        })
1226        .collect()
1227}
1228
1229#[derive(Default)]
1230struct DistributedReplayEngineState {
1231    sessions: Vec<Box<dyn DistributedReplaySession>>,
1232    nodes: Vec<DistributedReplayGraphNode>,
1233    node_lookup: BTreeMap<(u32, String, u64), usize>,
1234    output_log_paths: BTreeMap<(u32, String), PathBuf>,
1235    ready: BTreeSet<DistributedReplayReadyNode>,
1236    frontier: Vec<Option<DistributedReplayCursor>>,
1237}
1238
1239/// One causal distributed replay engine built from a validated plan.
1240pub struct DistributedReplayEngine {
1241    plan: DistributedReplayPlan,
1242    session_config: DistributedReplaySessionConfig,
1243    sessions: Vec<Box<dyn DistributedReplaySession>>,
1244    nodes: Vec<DistributedReplayGraphNode>,
1245    node_lookup: BTreeMap<(u32, String, u64), usize>,
1246    output_log_paths: BTreeMap<(u32, String), PathBuf>,
1247    ready: BTreeSet<DistributedReplayReadyNode>,
1248    frontier: Vec<Option<DistributedReplayCursor>>,
1249    executed: Vec<bool>,
1250    executed_count: usize,
1251}
1252
1253impl DistributedReplayEngine {
1254    fn new(
1255        plan: DistributedReplayPlan,
1256        session_config: DistributedReplaySessionConfig,
1257    ) -> CuResult<Self> {
1258        let state = Self::build_state(&plan, &session_config)?;
1259        let executed = vec![false; state.nodes.len()];
1260        Ok(Self {
1261            plan,
1262            session_config,
1263            sessions: state.sessions,
1264            nodes: state.nodes,
1265            node_lookup: state.node_lookup,
1266            output_log_paths: state.output_log_paths,
1267            ready: state.ready,
1268            frontier: state.frontier,
1269            executed,
1270            executed_count: 0,
1271        })
1272    }
1273
1274    fn build_state(
1275        plan: &DistributedReplayPlan,
1276        session_config: &DistributedReplaySessionConfig,
1277    ) -> CuResult<DistributedReplayEngineState> {
1278        let mut sessions = Vec::with_capacity(plan.assignments.len());
1279        let mut pending_nodes = Vec::new();
1280        let mut session_nodes = Vec::with_capacity(plan.assignments.len());
1281        let mut output_log_paths = BTreeMap::new();
1282
1283        for assignment in &plan.assignments {
1284            let build = (assignment.registration.session_factory)(assignment, session_config)?;
1285            let session_index = sessions.len();
1286            let mut node_indices = Vec::with_capacity(build.nodes.len());
1287            for node in build.nodes {
1288                let pending_index = pending_nodes.len();
1289                pending_nodes.push((session_index, node));
1290                node_indices.push(pending_index);
1291            }
1292            if let Some(output_log_path) = build.output_log_path {
1293                let replaced = output_log_paths.insert(
1294                    (assignment.instance_id, assignment.subsystem_id.clone()),
1295                    output_log_path,
1296                );
1297                if replaced.is_some() {
1298                    return Err(CuError::from(format!(
1299                        "Duplicate replay output log assignment for instance {} subsystem '{}'",
1300                        assignment.instance_id, assignment.subsystem_id
1301                    )));
1302                }
1303            }
1304            sessions.push(build.session);
1305            session_nodes.push(node_indices);
1306        }
1307
1308        let mut nodes = Vec::with_capacity(pending_nodes.len());
1309        let mut origin_lookup = BTreeMap::new();
1310        let mut node_lookup = BTreeMap::new();
1311
1312        for (node_index, (session_index, descriptor)) in pending_nodes.iter().enumerate() {
1313            if origin_lookup
1314                .insert(descriptor.origin_key.clone(), node_index)
1315                .is_some()
1316            {
1317                return Err(CuError::from(format!(
1318                    "Duplicate replay node detected for instance {} subsystem code {} CopperList {}",
1319                    descriptor.origin_key.instance_id,
1320                    descriptor.origin_key.subsystem_code,
1321                    descriptor.origin_key.cl_id
1322                )));
1323            }
1324
1325            if node_lookup
1326                .insert(
1327                    (
1328                        descriptor.cursor.instance_id,
1329                        descriptor.cursor.subsystem_id.clone(),
1330                        descriptor.cursor.cl_id,
1331                    ),
1332                    node_index,
1333                )
1334                .is_some()
1335            {
1336                return Err(CuError::from(format!(
1337                    "Duplicate replay cursor detected for instance {} subsystem '{}' CopperList {}",
1338                    descriptor.cursor.instance_id,
1339                    descriptor.cursor.subsystem_id,
1340                    descriptor.cursor.cl_id
1341                )));
1342            }
1343
1344            nodes.push(DistributedReplayGraphNode {
1345                cursor: descriptor.cursor.clone(),
1346                session_index: *session_index,
1347                outgoing: Vec::new(),
1348                initial_dependencies: 0,
1349                remaining_dependencies: 0,
1350            });
1351        }
1352
1353        let mut edges = BTreeSet::new();
1354
1355        for node_indices in &session_nodes {
1356            for pair in node_indices.windows(2) {
1357                let from = pair[0];
1358                let to = pair[1];
1359                if edges.insert((from, to)) {
1360                    nodes[from].outgoing.push(to);
1361                    nodes[to].initial_dependencies += 1;
1362                }
1363            }
1364        }
1365
1366        for (target_index, (_, descriptor)) in pending_nodes.iter().enumerate() {
1367            for origin in &descriptor.incoming_origins {
1368                let source_index = origin_lookup.get(origin).copied().ok_or_else(|| {
1369                    CuError::from(format!(
1370                        "Unresolved recorded provenance edge into instance {} subsystem '{}' CopperList {} from instance {} subsystem code {} CopperList {}",
1371                        descriptor.cursor.instance_id,
1372                        descriptor.cursor.subsystem_id,
1373                        descriptor.cursor.cl_id,
1374                        origin.instance_id,
1375                        origin.subsystem_code,
1376                        origin.cl_id
1377                    ))
1378                })?;
1379                if source_index == target_index {
1380                    return Err(CuError::from(format!(
1381                        "Recorded provenance on instance {} subsystem '{}' CopperList {} points to itself",
1382                        descriptor.cursor.instance_id,
1383                        descriptor.cursor.subsystem_id,
1384                        descriptor.cursor.cl_id
1385                    )));
1386                }
1387                if edges.insert((source_index, target_index)) {
1388                    nodes[source_index].outgoing.push(target_index);
1389                    nodes[target_index].initial_dependencies += 1;
1390                }
1391            }
1392        }
1393
1394        let mut ready = BTreeSet::new();
1395        for (node_index, node) in nodes.iter_mut().enumerate() {
1396            node.remaining_dependencies = node.initial_dependencies;
1397            if node.remaining_dependencies == 0 {
1398                ready.insert(DistributedReplayReadyNode {
1399                    instance_id: node.cursor.instance_id,
1400                    subsystem_code: node.cursor.subsystem_code(),
1401                    cl_id: node.cursor.cl_id,
1402                    node_index,
1403                });
1404            }
1405        }
1406
1407        if !nodes.is_empty() && ready.is_empty() {
1408            return Err(CuError::from(
1409                "Distributed replay graph has no causally ready starting point",
1410            ));
1411        }
1412
1413        Ok(DistributedReplayEngineState {
1414            frontier: vec![None; sessions.len()],
1415            sessions,
1416            nodes,
1417            node_lookup,
1418            output_log_paths,
1419            ready,
1420        })
1421    }
1422
1423    fn shutdown_sessions(sessions: &mut Vec<Box<dyn DistributedReplaySession>>) -> CuResult<()> {
1424        for session in sessions.iter_mut() {
1425            session.shutdown()?;
1426        }
1427        Ok(())
1428    }
1429
1430    fn ready_key(&self, node_index: usize) -> DistributedReplayReadyNode {
1431        let node = &self.nodes[node_index];
1432        DistributedReplayReadyNode {
1433            instance_id: node.cursor.instance_id,
1434            subsystem_code: node.cursor.subsystem_code(),
1435            cl_id: node.cursor.cl_id,
1436            node_index,
1437        }
1438    }
1439
1440    /// Reset all replay sessions and graph execution state back to the beginning.
1441    pub fn reset(&mut self) -> CuResult<()> {
1442        Self::shutdown_sessions(&mut self.sessions)?;
1443        let state = Self::build_state(&self.plan, &self.session_config)?;
1444        self.sessions = state.sessions;
1445        self.nodes = state.nodes;
1446        self.node_lookup = state.node_lookup;
1447        self.output_log_paths = state.output_log_paths;
1448        self.ready = state.ready;
1449        self.frontier = state.frontier;
1450        self.executed = vec![false; self.nodes.len()];
1451        self.executed_count = 0;
1452        Ok(())
1453    }
1454
1455    /// Replay the next causally ready CopperList, if any.
1456    pub fn step_causal(&mut self) -> CuResult<Option<DistributedReplayCursor>> {
1457        let Some(next_ready) = self.ready.iter().next().copied() else {
1458            if self.executed_count == self.nodes.len() {
1459                return Ok(None);
1460            }
1461            return Err(CuError::from(
1462                "Distributed replay is deadlocked: no causally ready CopperList remains",
1463            ));
1464        };
1465        self.ready.remove(&next_ready);
1466
1467        let cursor = self.nodes[next_ready.node_index].cursor.clone();
1468        let session_index = self.nodes[next_ready.node_index].session_index;
1469        self.sessions[session_index].goto_cl(cursor.cl_id)?;
1470        self.executed[next_ready.node_index] = true;
1471        self.executed_count += 1;
1472        self.frontier[session_index] = Some(cursor.clone());
1473
1474        let outgoing = self.nodes[next_ready.node_index].outgoing.clone();
1475        for dependent in outgoing {
1476            let node = &mut self.nodes[dependent];
1477            node.remaining_dependencies = node.remaining_dependencies.saturating_sub(1);
1478            if node.remaining_dependencies == 0 {
1479                self.ready.insert(self.ready_key(dependent));
1480            }
1481        }
1482
1483        Ok(Some(cursor))
1484    }
1485
1486    /// Replay the entire selected fleet to completion.
1487    pub fn run_all(&mut self) -> CuResult<()> {
1488        while self.step_causal()?.is_some() {}
1489        Ok(())
1490    }
1491
1492    /// Rebuild the replay from scratch and advance until the target CopperList is reached.
1493    pub fn goto(&mut self, instance_id: u32, subsystem_id: &str, cl_id: u64) -> CuResult<()> {
1494        let target = self
1495            .node_lookup
1496            .get(&(instance_id, subsystem_id.to_string(), cl_id))
1497            .copied()
1498            .ok_or_else(|| {
1499                CuError::from(format!(
1500                    "Distributed replay target instance {} subsystem '{}' CopperList {} does not exist",
1501                    instance_id, subsystem_id, cl_id
1502                ))
1503            })?;
1504        self.reset()?;
1505        while !self.executed[target] {
1506            let Some(_) = self.step_causal()? else {
1507                return Err(CuError::from(format!(
1508                    "Distributed replay exhausted before reaching instance {} subsystem '{}' CopperList {}",
1509                    instance_id, subsystem_id, cl_id
1510                )));
1511            };
1512        }
1513        Ok(())
1514    }
1515
1516    /// Return the latest executed CopperList cursor for each replay session.
1517    pub fn current_frontier(&self) -> Vec<DistributedReplayCursor> {
1518        self.frontier
1519            .iter()
1520            .filter_map(|cursor| cursor.clone())
1521            .collect()
1522    }
1523
1524    pub fn output_log_path(&self, instance_id: u32, subsystem_id: &str) -> Option<&Path> {
1525        self.output_log_paths
1526            .get(&(instance_id, subsystem_id.to_string()))
1527            .map(PathBuf::as_path)
1528    }
1529
1530    #[inline]
1531    pub fn total_nodes(&self) -> usize {
1532        self.nodes.len()
1533    }
1534
1535    #[inline]
1536    pub fn executed_nodes(&self) -> usize {
1537        self.executed_count
1538    }
1539}
1540
1541fn join_log_paths(logs: &[DistributedReplayLog]) -> String {
1542    logs.iter()
1543        .map(|log| log.base_path.display().to_string())
1544        .collect::<Vec<_>>()
1545        .join(", ")
1546}
1547
1548fn collect_candidate_base_paths(path: &Path, out: &mut BTreeSet<PathBuf>) -> CuResult<()> {
1549    if path.is_dir() {
1550        let mut entries = fs::read_dir(path)
1551            .map_err(|err| {
1552                CuError::new_with_cause(
1553                    &format!(
1554                        "Failed to read directory '{}' during distributed replay discovery",
1555                        path.display()
1556                    ),
1557                    err,
1558                )
1559            })?
1560            .collect::<Result<Vec<_>, _>>()
1561            .map_err(|err| {
1562                CuError::new_with_cause(
1563                    &format!(
1564                        "Failed to enumerate directory '{}' during distributed replay discovery",
1565                        path.display()
1566                    ),
1567                    err,
1568                )
1569            })?;
1570        entries.sort_by_key(|entry| entry.path());
1571        for entry in entries {
1572            collect_candidate_base_paths(&entry.path(), out)?;
1573        }
1574        return Ok(());
1575    }
1576
1577    if path
1578        .extension()
1579        .and_then(|ext| ext.to_str())
1580        .is_some_and(|ext| ext == "copper")
1581    {
1582        out.insert(normalize_candidate_log_base(path));
1583    }
1584
1585    Ok(())
1586}
1587
1588fn normalize_candidate_log_base(path: &Path) -> PathBuf {
1589    let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
1590        return path.to_path_buf();
1591    };
1592    let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1593        return path.to_path_buf();
1594    };
1595    let Some((base_stem, slab_suffix)) = stem.rsplit_once('_') else {
1596        return path.to_path_buf();
1597    };
1598
1599    if slab_suffix.is_empty() || !slab_suffix.chars().all(|c| c.is_ascii_digit()) {
1600        return path.to_path_buf();
1601    }
1602
1603    let mut normalized = path.to_path_buf();
1604    normalized.set_file_name(format!("{base_stem}.{extension}"));
1605    if slab_zero_path(&normalized).is_some_and(|slab_zero| slab_zero.exists()) {
1606        normalized
1607    } else {
1608        path.to_path_buf()
1609    }
1610}
1611
1612fn slab_zero_path(base_path: &Path) -> Option<PathBuf> {
1613    let extension = base_path.extension()?.to_str()?;
1614    let stem = base_path.file_stem()?.to_str()?;
1615    let mut slab_zero = base_path.to_path_buf();
1616    slab_zero.set_file_name(format!("{stem}_0.{extension}"));
1617    Some(slab_zero)
1618}
1619
1620fn read_next_entry<T: bincode::Decode<()>>(src: &mut impl Read) -> CuResult<Option<T>> {
1621    match decode_from_std_read::<T, _, _>(src, standard()) {
1622        Ok(entry) => Ok(Some(entry)),
1623        Err(DecodeError::UnexpectedEnd { .. }) => Ok(None),
1624        Err(DecodeError::Io { inner, .. }) if inner.kind() == std::io::ErrorKind::UnexpectedEof => {
1625            Ok(None)
1626        }
1627        Err(err) => Err(CuError::new_with_cause(
1628            "Failed to decode bincode entry during distributed replay discovery",
1629            err,
1630        )),
1631    }
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636    use super::*;
1637    use crate::app::{
1638        CuDistributedReplayApplication, CuRecordedReplayApplication, CuSimApplication,
1639        CuSubsystemMetadata,
1640    };
1641    use crate::config::CuConfig;
1642    use crate::copperlist::CopperList;
1643    use crate::curuntime::KeyFrame;
1644    use crate::simulation::SimOverride;
1645    use bincode::{Decode, Encode};
1646    use cu29_clock::CuTime;
1647    use cu29_traits::{ErasedCuStampedData, ErasedCuStampedDataSet, MatchingTasks, WriteStream};
1648    use cu29_unifiedlog::memmap::MmapSectionStorage;
1649    use cu29_unifiedlog::stream_write;
1650    use serde::Serialize;
1651    use std::sync::{Arc, Mutex};
1652    use tempfile::TempDir;
1653
1654    fn write_runtime_lifecycle_log(
1655        base_path: &Path,
1656        stack: RuntimeLifecycleStackInfo,
1657        mission: Option<&str>,
1658    ) -> CuResult<()> {
1659        if let Some(parent) = base_path.parent() {
1660            fs::create_dir_all(parent).map_err(|err| {
1661                CuError::new_with_cause(
1662                    &format!("Failed to create test log directory '{}'", parent.display()),
1663                    err,
1664                )
1665            })?;
1666        }
1667
1668        let UnifiedLogger::Write(writer) = UnifiedLoggerBuilder::new()
1669            .write(true)
1670            .create(true)
1671            .preallocated_size(256 * 1024)
1672            .file_base_name(base_path)
1673            .build()
1674            .map_err(|err| {
1675                CuError::new_with_cause(
1676                    &format!("Failed to create test log '{}'", base_path.display()),
1677                    err,
1678                )
1679            })?
1680        else {
1681            return Err(CuError::from("Expected writable unified logger in test"));
1682        };
1683
1684        let logger = Arc::new(Mutex::new(writer));
1685        let mut stream = stream_write::<RuntimeLifecycleRecord, MmapSectionStorage>(
1686            logger.clone(),
1687            UnifiedLogType::RuntimeLifecycle,
1688            4096,
1689        )?;
1690        stream.log(&RuntimeLifecycleRecord {
1691            timestamp: CuTime::default(),
1692            event: RuntimeLifecycleEvent::Instantiated {
1693                config_source: RuntimeLifecycleConfigSource::ExternalFile,
1694                effective_config_ron: "(runtime: ())".to_string(),
1695                stack,
1696            },
1697        })?;
1698        if let Some(mission) = mission {
1699            stream.log(&RuntimeLifecycleRecord {
1700                timestamp: CuTime::from_nanos(1),
1701                event: RuntimeLifecycleEvent::MissionStarted {
1702                    mission: mission.to_string(),
1703                },
1704            })?;
1705        }
1706        drop(stream);
1707        drop(logger);
1708        Ok(())
1709    }
1710
1711    fn test_stack(
1712        subsystem_id: &str,
1713        subsystem_code: u16,
1714        instance_id: u32,
1715    ) -> RuntimeLifecycleStackInfo {
1716        RuntimeLifecycleStackInfo {
1717            app_name: "demo".to_string(),
1718            app_version: "0.1.0".to_string(),
1719            git_commit: Some("abc123".to_string()),
1720            git_dirty: Some(false),
1721            subsystem_id: Some(subsystem_id.to_string()),
1722            subsystem_code,
1723            instance_id,
1724        }
1725    }
1726
1727    fn write_multi_config_fixture(temp_dir: &TempDir, subsystem_ids: &[&str]) -> CuResult<PathBuf> {
1728        for subsystem_id in subsystem_ids {
1729            let subsystem_config = temp_dir.path().join(format!("{subsystem_id}_config.ron"));
1730            fs::write(&subsystem_config, "(tasks: [], cnx: [])").map_err(|err| {
1731                CuError::new_with_cause(
1732                    &format!(
1733                        "Failed to write subsystem config '{}'",
1734                        subsystem_config.display()
1735                    ),
1736                    err,
1737                )
1738            })?;
1739        }
1740
1741        let subsystem_entries = subsystem_ids
1742            .iter()
1743            .map(|subsystem_id| {
1744                format!(
1745                    r#"(
1746            id: "{subsystem_id}",
1747            config: "{subsystem_id}_config.ron",
1748        )"#
1749                )
1750            })
1751            .collect::<Vec<_>>()
1752            .join(",\n");
1753
1754        let multi_config = format!(
1755            "(\n    subsystems: [\n{entries}\n    ],\n    interconnects: [],\n)\n",
1756            entries = subsystem_entries
1757        );
1758        let multi_config_path = temp_dir.path().join("multi_copper.ron");
1759        fs::write(&multi_config_path, multi_config).map_err(|err| {
1760            CuError::new_with_cause(
1761                &format!(
1762                    "Failed to write multi-Copper config '{}'",
1763                    multi_config_path.display()
1764                ),
1765                err,
1766            )
1767        })?;
1768        Ok(multi_config_path)
1769    }
1770
1771    #[derive(Debug, Default, Encode, Decode, Serialize)]
1772    struct DummyRecordedDataSet;
1773
1774    impl ErasedCuStampedDataSet for DummyRecordedDataSet {
1775        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
1776            Vec::new()
1777        }
1778    }
1779
1780    impl MatchingTasks for DummyRecordedDataSet {
1781        fn get_all_task_ids() -> &'static [&'static str] {
1782            &[]
1783        }
1784    }
1785
1786    macro_rules! impl_registered_test_app {
1787        ($name:ident, $subsystem_id:expr, $subsystem_code:expr) => {
1788            struct $name;
1789
1790            impl CuSubsystemMetadata for $name {
1791                fn subsystem() -> Subsystem {
1792                    Subsystem::new(Some($subsystem_id), $subsystem_code)
1793                }
1794            }
1795
1796            impl<S: SectionStorage + 'static, L: UnifiedLogWrite<S> + 'static>
1797                CuSimApplication<S, L> for $name
1798            {
1799                type Step<'z> = ();
1800
1801                fn get_original_config() -> String {
1802                    "(tasks: [], cnx: [])".to_string()
1803                }
1804
1805                fn start_all_tasks(
1806                    &mut self,
1807                    _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1808                ) -> CuResult<()> {
1809                    Ok(())
1810                }
1811
1812                fn run_one_iteration(
1813                    &mut self,
1814                    _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1815                ) -> CuResult<()> {
1816                    Ok(())
1817                }
1818
1819                fn run(
1820                    &mut self,
1821                    _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1822                ) -> CuResult<()> {
1823                    Ok(())
1824                }
1825
1826                fn stop_all_tasks(
1827                    &mut self,
1828                    _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1829                ) -> CuResult<()> {
1830                    Ok(())
1831                }
1832
1833                fn restore_keyframe(&mut self, _freezer: &KeyFrame) -> CuResult<()> {
1834                    Ok(())
1835                }
1836            }
1837
1838            impl<S: SectionStorage + 'static, L: UnifiedLogWrite<S> + 'static>
1839                CuRecordedReplayApplication<S, L> for $name
1840            {
1841                type RecordedDataSet = DummyRecordedDataSet;
1842
1843                fn replay_recorded_copperlist(
1844                    &mut self,
1845                    _clock_mock: &RobotClockMock,
1846                    _copperlist: &CopperList<Self::RecordedDataSet>,
1847                    _keyframe: Option<&KeyFrame>,
1848                ) -> CuResult<()> {
1849                    Ok(())
1850                }
1851            }
1852
1853            impl<S: SectionStorage + 'static, L: UnifiedLogWrite<S> + 'static>
1854                CuDistributedReplayApplication<S, L> for $name
1855            {
1856                fn build_distributed_replay(
1857                    _clock: RobotClock,
1858                    _unified_logger: Arc<Mutex<L>>,
1859                    _instance_id: u32,
1860                    _config_override: Option<CuConfig>,
1861                ) -> CuResult<Self> {
1862                    Ok(Self)
1863                }
1864            }
1865        };
1866    }
1867
1868    impl_registered_test_app!(PingRegisteredApp, "ping", 0);
1869    impl_registered_test_app!(PongRegisteredApp, "pong", 1);
1870    impl_registered_test_app!(PingWrongCodeApp, "ping", 99);
1871
1872    struct FakeReplaySession;
1873
1874    impl DistributedReplaySession for FakeReplaySession {
1875        fn goto_cl(&mut self, _cl_id: u64) -> CuResult<()> {
1876            Ok(())
1877        }
1878
1879        fn shutdown(&mut self) -> CuResult<()> {
1880            Ok(())
1881        }
1882    }
1883
1884    fn fake_registration(
1885        subsystem_id: &'static str,
1886        subsystem_code: u16,
1887        session_factory: DistributedReplaySessionFactory,
1888    ) -> DistributedReplayAppRegistration {
1889        DistributedReplayAppRegistration {
1890            subsystem: Subsystem::new(Some(subsystem_id), subsystem_code),
1891            app_type_name: "fake",
1892            session_factory,
1893        }
1894    }
1895
1896    fn fake_assignment(
1897        instance_id: u32,
1898        subsystem_id: &'static str,
1899        subsystem_code: u16,
1900        session_factory: DistributedReplaySessionFactory,
1901    ) -> DistributedReplayAssignment {
1902        DistributedReplayAssignment {
1903            instance_id,
1904            subsystem_id: subsystem_id.to_string(),
1905            log: DistributedReplayLog {
1906                base_path: PathBuf::from(format!("{subsystem_id}_{instance_id}.copper")),
1907                stack: test_stack(subsystem_id, subsystem_code, instance_id),
1908                config_source: RuntimeLifecycleConfigSource::ExternalFile,
1909                effective_config_ron: "(tasks: [], cnx: [])".to_string(),
1910                mission: Some("default".to_string()),
1911            },
1912            registration: fake_registration(subsystem_id, subsystem_code, session_factory),
1913        }
1914    }
1915
1916    fn fake_plan(assignments: Vec<DistributedReplayAssignment>) -> DistributedReplayPlan {
1917        let mut registrations: Vec<_> = assignments
1918            .iter()
1919            .map(|assignment| assignment.registration.clone())
1920            .collect();
1921        registrations.sort_by(|left, right| left.subsystem.id().cmp(&right.subsystem.id()));
1922        let mut selected_instances: Vec<_> = assignments
1923            .iter()
1924            .map(|assignment| assignment.instance_id)
1925            .collect::<BTreeSet<_>>()
1926            .into_iter()
1927            .collect();
1928        selected_instances.sort_unstable();
1929        DistributedReplayPlan {
1930            multi_config_path: PathBuf::from("fake_multi.ron"),
1931            multi_config: MultiCopperConfig {
1932                subsystems: Vec::new(),
1933                interconnects: Vec::new(),
1934                instance_overrides_root: None,
1935            },
1936            catalog: DistributedReplayCatalog::default(),
1937            selected_instances,
1938            mission: Some("default".to_string()),
1939            registrations,
1940            assignments,
1941        }
1942    }
1943
1944    fn fake_ping_session(
1945        assignment: &DistributedReplayAssignment,
1946        _session_config: &DistributedReplaySessionConfig,
1947    ) -> CuResult<DistributedReplaySessionBuild> {
1948        Ok(DistributedReplaySessionBuild {
1949            session: Box::new(FakeReplaySession),
1950            nodes: vec![
1951                DistributedReplayNodeDescriptor {
1952                    cursor: DistributedReplayCursor::new(
1953                        assignment.instance_id,
1954                        assignment.subsystem_id.clone(),
1955                        assignment.log.subsystem_code(),
1956                        0,
1957                    ),
1958                    origin_key: DistributedReplayOriginKey {
1959                        instance_id: assignment.instance_id,
1960                        subsystem_code: assignment.log.subsystem_code(),
1961                        cl_id: 0,
1962                    },
1963                    incoming_origins: BTreeSet::new(),
1964                },
1965                DistributedReplayNodeDescriptor {
1966                    cursor: DistributedReplayCursor::new(
1967                        assignment.instance_id,
1968                        assignment.subsystem_id.clone(),
1969                        assignment.log.subsystem_code(),
1970                        1,
1971                    ),
1972                    origin_key: DistributedReplayOriginKey {
1973                        instance_id: assignment.instance_id,
1974                        subsystem_code: assignment.log.subsystem_code(),
1975                        cl_id: 1,
1976                    },
1977                    incoming_origins: BTreeSet::new(),
1978                },
1979            ],
1980            output_log_path: None,
1981        })
1982    }
1983
1984    fn fake_pong_session(
1985        assignment: &DistributedReplayAssignment,
1986        _session_config: &DistributedReplaySessionConfig,
1987    ) -> CuResult<DistributedReplaySessionBuild> {
1988        Ok(DistributedReplaySessionBuild {
1989            session: Box::new(FakeReplaySession),
1990            nodes: vec![
1991                DistributedReplayNodeDescriptor {
1992                    cursor: DistributedReplayCursor::new(
1993                        assignment.instance_id,
1994                        assignment.subsystem_id.clone(),
1995                        assignment.log.subsystem_code(),
1996                        0,
1997                    ),
1998                    origin_key: DistributedReplayOriginKey {
1999                        instance_id: assignment.instance_id,
2000                        subsystem_code: assignment.log.subsystem_code(),
2001                        cl_id: 0,
2002                    },
2003                    incoming_origins: BTreeSet::from([DistributedReplayOriginKey {
2004                        instance_id: assignment.instance_id,
2005                        subsystem_code: 0,
2006                        cl_id: 0,
2007                    }]),
2008                },
2009                DistributedReplayNodeDescriptor {
2010                    cursor: DistributedReplayCursor::new(
2011                        assignment.instance_id,
2012                        assignment.subsystem_id.clone(),
2013                        assignment.log.subsystem_code(),
2014                        1,
2015                    ),
2016                    origin_key: DistributedReplayOriginKey {
2017                        instance_id: assignment.instance_id,
2018                        subsystem_code: assignment.log.subsystem_code(),
2019                        cl_id: 1,
2020                    },
2021                    incoming_origins: BTreeSet::from([DistributedReplayOriginKey {
2022                        instance_id: assignment.instance_id,
2023                        subsystem_code: 0,
2024                        cl_id: 1,
2025                    }]),
2026                },
2027            ],
2028            output_log_path: None,
2029        })
2030    }
2031
2032    fn fake_bad_pong_session(
2033        assignment: &DistributedReplayAssignment,
2034        _session_config: &DistributedReplaySessionConfig,
2035    ) -> CuResult<DistributedReplaySessionBuild> {
2036        Ok(DistributedReplaySessionBuild {
2037            session: Box::new(FakeReplaySession),
2038            nodes: vec![DistributedReplayNodeDescriptor {
2039                cursor: DistributedReplayCursor::new(
2040                    assignment.instance_id,
2041                    assignment.subsystem_id.clone(),
2042                    assignment.log.subsystem_code(),
2043                    0,
2044                ),
2045                origin_key: DistributedReplayOriginKey {
2046                    instance_id: assignment.instance_id,
2047                    subsystem_code: assignment.log.subsystem_code(),
2048                    cl_id: 0,
2049                },
2050                incoming_origins: BTreeSet::from([DistributedReplayOriginKey {
2051                    instance_id: assignment.instance_id,
2052                    subsystem_code: 0,
2053                    cl_id: 99,
2054                }]),
2055            }],
2056            output_log_path: None,
2057        })
2058    }
2059
2060    const STRESS_SUBSYSTEMS: [(&str, u16); 4] =
2061        [("sense", 0), ("plan", 1), ("control", 2), ("telemetry", 3)];
2062
2063    fn stress_origins_for(
2064        subsystem_id: &str,
2065        instance_id: u32,
2066        cl_id: u64,
2067    ) -> BTreeSet<DistributedReplayOriginKey> {
2068        match subsystem_id {
2069            "sense" => BTreeSet::new(),
2070            "plan" => BTreeSet::from([DistributedReplayOriginKey {
2071                instance_id,
2072                subsystem_code: 0,
2073                cl_id,
2074            }]),
2075            "control" => BTreeSet::from([DistributedReplayOriginKey {
2076                instance_id,
2077                subsystem_code: 1,
2078                cl_id,
2079            }]),
2080            "telemetry" => BTreeSet::from([
2081                DistributedReplayOriginKey {
2082                    instance_id,
2083                    subsystem_code: 0,
2084                    cl_id,
2085                },
2086                DistributedReplayOriginKey {
2087                    instance_id,
2088                    subsystem_code: 2,
2089                    cl_id,
2090                },
2091            ]),
2092            _ => panic!("unexpected synthetic stress subsystem '{subsystem_id}'"),
2093        }
2094    }
2095
2096    fn build_stress_session(
2097        assignment: &DistributedReplayAssignment,
2098        _session_config: &DistributedReplaySessionConfig,
2099        cl_count: u64,
2100    ) -> CuResult<DistributedReplaySessionBuild> {
2101        let subsystem_code = assignment.log.subsystem_code();
2102        let nodes = (0..cl_count)
2103            .map(|cl_id| DistributedReplayNodeDescriptor {
2104                cursor: DistributedReplayCursor::new(
2105                    assignment.instance_id,
2106                    assignment.subsystem_id.clone(),
2107                    subsystem_code,
2108                    cl_id,
2109                ),
2110                origin_key: DistributedReplayOriginKey {
2111                    instance_id: assignment.instance_id,
2112                    subsystem_code,
2113                    cl_id,
2114                },
2115                incoming_origins: stress_origins_for(
2116                    &assignment.subsystem_id,
2117                    assignment.instance_id,
2118                    cl_id,
2119                ),
2120            })
2121            .collect();
2122        Ok(DistributedReplaySessionBuild {
2123            session: Box::new(FakeReplaySession),
2124            nodes,
2125            output_log_path: None,
2126        })
2127    }
2128
2129    fn stress_session_ci(
2130        assignment: &DistributedReplayAssignment,
2131        session_config: &DistributedReplaySessionConfig,
2132    ) -> CuResult<DistributedReplaySessionBuild> {
2133        build_stress_session(assignment, session_config, 24)
2134    }
2135
2136    fn stress_session_goto(
2137        assignment: &DistributedReplayAssignment,
2138        session_config: &DistributedReplaySessionConfig,
2139    ) -> CuResult<DistributedReplaySessionBuild> {
2140        build_stress_session(assignment, session_config, 32)
2141    }
2142
2143    fn stress_session_heavy(
2144        assignment: &DistributedReplayAssignment,
2145        session_config: &DistributedReplaySessionConfig,
2146    ) -> CuResult<DistributedReplaySessionBuild> {
2147        build_stress_session(assignment, session_config, 96)
2148    }
2149
2150    fn stress_plan(
2151        instance_count: u32,
2152        session_factory: DistributedReplaySessionFactory,
2153    ) -> DistributedReplayPlan {
2154        let assignments = (1..=instance_count)
2155            .flat_map(|instance_id| {
2156                STRESS_SUBSYSTEMS
2157                    .into_iter()
2158                    .map(move |(subsystem_id, subsystem_code)| {
2159                        fake_assignment(instance_id, subsystem_id, subsystem_code, session_factory)
2160                    })
2161            })
2162            .collect();
2163        fake_plan(assignments)
2164    }
2165
2166    fn collect_engine_order(
2167        engine: &mut DistributedReplayEngine,
2168    ) -> CuResult<Vec<DistributedReplayCursor>> {
2169        let mut order = Vec::new();
2170        while let Some(cursor) = engine.step_causal()? {
2171            order.push(cursor);
2172        }
2173        Ok(order)
2174    }
2175
2176    fn assert_stress_order_is_topological(
2177        order: &[DistributedReplayCursor],
2178        instance_count: u32,
2179        cl_count: u64,
2180    ) {
2181        let expected_len = instance_count as usize * STRESS_SUBSYSTEMS.len() * cl_count as usize;
2182        assert_eq!(order.len(), expected_len);
2183
2184        let positions: BTreeMap<_, _> = order
2185            .iter()
2186            .enumerate()
2187            .map(|(idx, cursor)| {
2188                (
2189                    (
2190                        cursor.instance_id,
2191                        cursor.subsystem_id.clone(),
2192                        cursor.cl_id,
2193                    ),
2194                    idx,
2195                )
2196            })
2197            .collect();
2198        assert_eq!(positions.len(), expected_len);
2199
2200        for instance_id in 1..=instance_count {
2201            for (subsystem_id, _) in STRESS_SUBSYSTEMS {
2202                for cl_id in 1..cl_count {
2203                    let previous = positions
2204                        .get(&(instance_id, subsystem_id.to_string(), cl_id - 1))
2205                        .expect("previous local node missing");
2206                    let current = positions
2207                        .get(&(instance_id, subsystem_id.to_string(), cl_id))
2208                        .expect("current local node missing");
2209                    assert!(
2210                        previous < current,
2211                        "local order violated for instance {instance_id} subsystem '{subsystem_id}' cl {cl_id}"
2212                    );
2213                }
2214            }
2215
2216            for cl_id in 0..cl_count {
2217                let sense = positions
2218                    .get(&(instance_id, "sense".to_string(), cl_id))
2219                    .expect("sense node missing");
2220                let plan = positions
2221                    .get(&(instance_id, "plan".to_string(), cl_id))
2222                    .expect("plan node missing");
2223                let control = positions
2224                    .get(&(instance_id, "control".to_string(), cl_id))
2225                    .expect("control node missing");
2226                let telemetry = positions
2227                    .get(&(instance_id, "telemetry".to_string(), cl_id))
2228                    .expect("telemetry node missing");
2229                assert!(sense < plan);
2230                assert!(plan < control);
2231                assert!(sense < telemetry);
2232                assert!(control < telemetry);
2233            }
2234        }
2235    }
2236
2237    #[test]
2238    fn discovers_single_log_identity_from_runtime_lifecycle() -> CuResult<()> {
2239        let temp_dir = TempDir::new()
2240            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2241        let base_path = temp_dir.path().join("logs/ping.copper");
2242        write_runtime_lifecycle_log(&base_path, test_stack("ping", 7, 42), Some("default"))?;
2243
2244        let discovered = DistributedReplayLog::discover(&base_path)?;
2245        assert_eq!(discovered.base_path, base_path);
2246        assert_eq!(discovered.subsystem_id(), Some("ping"));
2247        assert_eq!(discovered.subsystem_code(), 7);
2248        assert_eq!(discovered.instance_id(), 42);
2249        assert_eq!(discovered.mission.as_deref(), Some("default"));
2250        Ok(())
2251    }
2252
2253    #[test]
2254    fn catalog_discovery_normalizes_slab_paths_and_deduplicates_candidates() -> CuResult<()> {
2255        let temp_dir = TempDir::new()
2256            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2257        let base_path = temp_dir.path().join("logs/pong.copper");
2258        let slab_zero_path = temp_dir.path().join("logs/pong_0.copper");
2259        write_runtime_lifecycle_log(&base_path, test_stack("pong", 3, 9), Some("default"))?;
2260
2261        let catalog = DistributedReplayCatalog::discover([base_path.clone(), slab_zero_path])?;
2262        assert!(
2263            catalog.failures.is_empty(),
2264            "unexpected failures: {:?}",
2265            catalog.failures
2266        );
2267        assert_eq!(catalog.logs.len(), 1);
2268        assert_eq!(catalog.logs[0].base_path, base_path);
2269        assert_eq!(catalog.logs[0].subsystem_id(), Some("pong"));
2270        Ok(())
2271    }
2272
2273    #[test]
2274    fn catalog_discovery_walks_directories_using_physical_slab_files() -> CuResult<()> {
2275        let temp_dir = TempDir::new()
2276            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2277        let ping_base = temp_dir.path().join("logs/ping.copper");
2278        let pong_base = temp_dir.path().join("logs/pong.copper");
2279        write_runtime_lifecycle_log(&ping_base, test_stack("ping", 0, 1), Some("alpha"))?;
2280        write_runtime_lifecycle_log(&pong_base, test_stack("pong", 1, 1), Some("alpha"))?;
2281
2282        let catalog = DistributedReplayCatalog::discover_under(temp_dir.path())?;
2283        assert!(
2284            catalog.failures.is_empty(),
2285            "unexpected failures: {:?}",
2286            catalog.failures
2287        );
2288        assert_eq!(catalog.logs.len(), 2);
2289        assert_eq!(catalog.logs[0].subsystem_id(), Some("ping"));
2290        assert_eq!(catalog.logs[1].subsystem_id(), Some("pong"));
2291        assert_eq!(catalog.logs[0].base_path, ping_base);
2292        assert_eq!(catalog.logs[1].base_path, pong_base);
2293        Ok(())
2294    }
2295
2296    #[test]
2297    fn catalog_reports_invalid_logs_without_aborting_scan() -> CuResult<()> {
2298        let temp_dir = TempDir::new()
2299            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2300        let good_base = temp_dir.path().join("logs/good.copper");
2301        write_runtime_lifecycle_log(&good_base, test_stack("good", 2, 5), Some("beta"))?;
2302
2303        let bad_slab = temp_dir.path().join("logs/bad_0.copper");
2304        if let Some(parent) = bad_slab.parent() {
2305            fs::create_dir_all(parent).map_err(|err| {
2306                CuError::new_with_cause(
2307                    &format!("Failed to create bad log dir '{}'", parent.display()),
2308                    err,
2309                )
2310            })?;
2311        }
2312        fs::write(&bad_slab, b"not a copper log").map_err(|err| {
2313            CuError::new_with_cause(
2314                &format!("Failed to create bad log '{}'", bad_slab.display()),
2315                err,
2316            )
2317        })?;
2318
2319        let catalog = DistributedReplayCatalog::discover_under(temp_dir.path())?;
2320        assert_eq!(catalog.logs.len(), 1);
2321        assert_eq!(catalog.failures.len(), 1);
2322        assert_eq!(catalog.logs[0].subsystem_id(), Some("good"));
2323        assert_eq!(
2324            catalog.failures[0].candidate_path,
2325            temp_dir.path().join("logs/bad.copper")
2326        );
2327        Ok(())
2328    }
2329
2330    #[test]
2331    fn builder_builds_validated_plan_for_selected_instances() -> CuResult<()> {
2332        let temp_dir = TempDir::new()
2333            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2334        let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2335        let logs_root = temp_dir.path().join("logs");
2336
2337        write_runtime_lifecycle_log(
2338            &logs_root.join("instance1_ping.copper"),
2339            test_stack("ping", 0, 1),
2340            Some("default"),
2341        )?;
2342        write_runtime_lifecycle_log(
2343            &logs_root.join("instance1_pong.copper"),
2344            test_stack("pong", 1, 1),
2345            Some("default"),
2346        )?;
2347        write_runtime_lifecycle_log(
2348            &logs_root.join("instance2_ping.copper"),
2349            test_stack("ping", 0, 2),
2350            Some("default"),
2351        )?;
2352        write_runtime_lifecycle_log(
2353            &logs_root.join("instance2_pong.copper"),
2354            test_stack("pong", 1, 2),
2355            Some("default"),
2356        )?;
2357
2358        let plan = DistributedReplayPlan::builder(&multi_config_path)?
2359            .discover_logs_under(&logs_root)?
2360            .register::<PingRegisteredApp>("ping")?
2361            .register::<PongRegisteredApp>("pong")?
2362            .instances([2])
2363            .build()?;
2364
2365        assert_eq!(plan.selected_instances, vec![2]);
2366        assert_eq!(plan.mission.as_deref(), Some("default"));
2367        assert_eq!(plan.assignments.len(), 2);
2368        assert_eq!(
2369            plan.assignment(2, "ping").unwrap().log.base_path,
2370            logs_root.join("instance2_ping.copper")
2371        );
2372        assert_eq!(
2373            plan.assignment(2, "pong").unwrap().log.base_path,
2374            logs_root.join("instance2_pong.copper")
2375        );
2376        Ok(())
2377    }
2378
2379    #[test]
2380    fn register_rejects_subsystem_code_mismatch() -> CuResult<()> {
2381        let temp_dir = TempDir::new()
2382            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2383        let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2384
2385        let err = DistributedReplayPlan::builder(&multi_config_path)?
2386            .register::<PingWrongCodeApp>("ping")
2387            .unwrap_err();
2388        assert!(err.to_string().contains("declares subsystem code 99"));
2389        Ok(())
2390    }
2391
2392    #[test]
2393    fn build_reports_missing_logs_and_missing_registrations() -> CuResult<()> {
2394        let temp_dir = TempDir::new()
2395            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2396        let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2397        let logs_root = temp_dir.path().join("logs");
2398
2399        write_runtime_lifecycle_log(
2400            &logs_root.join("instance1_ping.copper"),
2401            test_stack("ping", 0, 1),
2402            Some("default"),
2403        )?;
2404
2405        let err = DistributedReplayPlan::builder(&multi_config_path)?
2406            .discover_logs_under(&logs_root)?
2407            .register::<PingRegisteredApp>("ping")?
2408            .build()
2409            .unwrap_err();
2410        let err_text = err.to_string();
2411        assert!(err_text.contains("missing app registration for subsystem 'pong'"));
2412        assert!(err_text.contains("missing log for instance 1 subsystem 'pong'"));
2413        Ok(())
2414    }
2415
2416    #[test]
2417    fn build_reports_duplicate_logs_for_one_target() -> CuResult<()> {
2418        let temp_dir = TempDir::new()
2419            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2420        let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2421        let logs_root = temp_dir.path().join("logs");
2422
2423        write_runtime_lifecycle_log(
2424            &logs_root.join("instance1_ping_a.copper"),
2425            test_stack("ping", 0, 1),
2426            Some("default"),
2427        )?;
2428        write_runtime_lifecycle_log(
2429            &logs_root.join("instance1_ping_b.copper"),
2430            test_stack("ping", 0, 1),
2431            Some("default"),
2432        )?;
2433        write_runtime_lifecycle_log(
2434            &logs_root.join("instance1_pong.copper"),
2435            test_stack("pong", 1, 1),
2436            Some("default"),
2437        )?;
2438
2439        let err = DistributedReplayPlan::builder(&multi_config_path)?
2440            .discover_logs_under(&logs_root)?
2441            .register::<PingRegisteredApp>("ping")?
2442            .register::<PongRegisteredApp>("pong")?
2443            .build()
2444            .unwrap_err();
2445        assert!(
2446            err.to_string()
2447                .contains("found 2 logs for instance 1 subsystem 'ping'")
2448        );
2449        Ok(())
2450    }
2451
2452    #[test]
2453    fn build_reports_mission_mismatch_across_selected_logs() -> CuResult<()> {
2454        let temp_dir = TempDir::new()
2455            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2456        let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2457        let logs_root = temp_dir.path().join("logs");
2458
2459        write_runtime_lifecycle_log(
2460            &logs_root.join("instance1_ping.copper"),
2461            test_stack("ping", 0, 1),
2462            Some("default"),
2463        )?;
2464        write_runtime_lifecycle_log(
2465            &logs_root.join("instance1_pong.copper"),
2466            test_stack("pong", 1, 1),
2467            Some("recovery"),
2468        )?;
2469
2470        let err = DistributedReplayPlan::builder(&multi_config_path)?
2471            .discover_logs_under(&logs_root)?
2472            .register::<PingRegisteredApp>("ping")?
2473            .register::<PongRegisteredApp>("pong")?
2474            .build()
2475            .unwrap_err();
2476        assert!(
2477            err.to_string()
2478                .contains("selected logs disagree on mission: default, recovery")
2479        );
2480        Ok(())
2481    }
2482
2483    #[test]
2484    fn engine_steps_in_stable_causal_order() -> CuResult<()> {
2485        let plan = fake_plan(vec![
2486            fake_assignment(1, "ping", 0, fake_ping_session),
2487            fake_assignment(1, "pong", 1, fake_pong_session),
2488        ]);
2489
2490        let mut engine = plan.start()?;
2491        let mut order = Vec::new();
2492        while let Some(cursor) = engine.step_causal()? {
2493            order.push((cursor.subsystem_id, cursor.cl_id));
2494        }
2495
2496        assert_eq!(
2497            order,
2498            vec![
2499                ("ping".to_string(), 0),
2500                ("ping".to_string(), 1),
2501                ("pong".to_string(), 0),
2502                ("pong".to_string(), 1),
2503            ]
2504        );
2505        assert_eq!(engine.executed_nodes(), 4);
2506        Ok(())
2507    }
2508
2509    #[test]
2510    fn engine_goto_rebuilds_and_replays_to_target() -> CuResult<()> {
2511        let plan = fake_plan(vec![
2512            fake_assignment(1, "ping", 0, fake_ping_session),
2513            fake_assignment(1, "pong", 1, fake_pong_session),
2514        ]);
2515
2516        let mut engine = plan.start()?;
2517        engine.run_all()?;
2518        engine.goto(1, "pong", 0)?;
2519
2520        assert_eq!(engine.executed_nodes(), 3);
2521        let frontier = engine.current_frontier();
2522        assert_eq!(frontier.len(), 2);
2523        assert!(frontier.iter().any(|cursor| {
2524            cursor.instance_id == 1 && cursor.subsystem_id == "ping" && cursor.cl_id == 1
2525        }));
2526        assert!(frontier.iter().any(|cursor| {
2527            cursor.instance_id == 1 && cursor.subsystem_id == "pong" && cursor.cl_id == 0
2528        }));
2529        Ok(())
2530    }
2531
2532    #[test]
2533    fn engine_reports_unresolved_recorded_provenance() -> CuResult<()> {
2534        let plan = fake_plan(vec![
2535            fake_assignment(1, "ping", 0, fake_ping_session),
2536            fake_assignment(1, "pong", 1, fake_bad_pong_session),
2537        ]);
2538
2539        let err = match plan.start() {
2540            Ok(_) => return Err(CuError::from("expected distributed replay startup failure")),
2541            Err(err) => err,
2542        };
2543        assert!(
2544            err.to_string()
2545                .contains("Unresolved recorded provenance edge")
2546        );
2547        Ok(())
2548    }
2549
2550    #[test]
2551    fn engine_run_all_scales_across_many_identical_instances() -> CuResult<()> {
2552        let mut engine = stress_plan(6, stress_session_ci).start()?;
2553        let order = collect_engine_order(&mut engine)?;
2554
2555        assert_stress_order_is_topological(&order, 6, 24);
2556        assert_eq!(engine.executed_nodes(), 6 * STRESS_SUBSYSTEMS.len() * 24);
2557
2558        let frontier = engine.current_frontier();
2559        assert_eq!(frontier.len(), 6 * STRESS_SUBSYSTEMS.len());
2560        for instance_id in 1..=6 {
2561            for (subsystem_id, _) in STRESS_SUBSYSTEMS {
2562                assert!(frontier.iter().any(|cursor| {
2563                    cursor.instance_id == instance_id
2564                        && cursor.subsystem_id == subsystem_id
2565                        && cursor.cl_id == 23
2566                }));
2567            }
2568        }
2569        Ok(())
2570    }
2571
2572    #[test]
2573    fn engine_goto_matches_manual_replay_on_large_graph() -> CuResult<()> {
2574        let plan = stress_plan(5, stress_session_goto);
2575        let mut manual = plan.clone().start()?;
2576
2577        let (expected_steps, expected_frontier) = {
2578            let mut expected_steps = 0usize;
2579            loop {
2580                let Some(cursor) = manual.step_causal()? else {
2581                    return Err(CuError::from(
2582                        "manual distributed replay exhausted before reaching stress target",
2583                    ));
2584                };
2585                expected_steps += 1;
2586                if cursor.instance_id == 4 && cursor.subsystem_id == "control" && cursor.cl_id == 17
2587                {
2588                    break (expected_steps, manual.current_frontier());
2589                }
2590            }
2591        };
2592
2593        let mut via_goto = plan.start()?;
2594        via_goto.goto(4, "control", 17)?;
2595
2596        assert_eq!(via_goto.executed_nodes(), expected_steps);
2597        assert_eq!(via_goto.current_frontier(), expected_frontier);
2598        Ok(())
2599    }
2600
2601    #[test]
2602    #[ignore = "stress"]
2603    fn engine_heavy_stress_run_all_completes() -> CuResult<()> {
2604        let mut engine = stress_plan(12, stress_session_heavy).start()?;
2605        engine.run_all()?;
2606
2607        let expected = 12 * STRESS_SUBSYSTEMS.len() * 96;
2608        assert_eq!(engine.executed_nodes(), expected);
2609        assert_eq!(
2610            engine.current_frontier().len(),
2611            12 * STRESS_SUBSYSTEMS.len()
2612        );
2613        Ok(())
2614    }
2615}