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