Skip to main content

netrun_sim/
net.rs

1//! Runtime state and operations for flow-based development networks.
2//!
3//! This module provides the [`NetSim`] type which tracks the runtime state of a network,
4//! including packet locations, epoch lifecycles, and provides actions to control packet flow.
5//!
6//! All mutations to the network state go through [`NetSim::do_action`] which accepts a
7//! [`NetAction`] and returns a [`NetActionResponse`] containing any events that occurred.
8
9use crate::_utils::get_utc_now;
10use crate::graph::{
11    Edge, Graph, MaxSalvos, NodeName, PacketCount, Port, PortName, PortRef, PortSlotSpec, PortType,
12    SalvoConditionName, SalvoConditionTerm, evaluate_salvo_condition,
13};
14use indexmap::IndexSet;
15use std::collections::{HashMap, HashSet};
16use ulid::Ulid;
17
18/// Unique identifier for a packet (ULID).
19pub type PacketID = Ulid;
20
21/// Unique identifier for an epoch (ULID).
22pub type EpochID = Ulid;
23
24/// Where a packet is located in the network.
25///
26/// Packets move through these locations as they flow through the network:
27/// - Start outside the net or get created inside an epoch
28/// - Move to edges, then to input ports
29/// - Get consumed into epochs via salvo conditions
30/// - Can be loaded into output ports and sent back to edges
31#[derive(Debug, PartialEq, Eq, Hash, Clone)]
32pub enum PacketLocation {
33    /// Inside an epoch (either startable or running).
34    Node(EpochID),
35    /// Waiting at a node's input port.
36    InputPort(NodeName, PortName),
37    /// Loaded into an epoch's output port, ready to be sent.
38    OutputPort(EpochID, PortName),
39    /// In transit on an edge between nodes.
40    Edge(Edge),
41    /// External to the network (not yet injected or already extracted).
42    OutsideNet,
43}
44
45/// A unit that flows through the network.
46#[derive(Debug)]
47pub struct Packet {
48    /// Unique identifier for this packet.
49    pub id: PacketID,
50    /// Current location of this packet.
51    pub location: PacketLocation,
52}
53
54/// A collection of packets that enter or exit a node together.
55///
56/// Salvos are created when salvo conditions are satisfied:
57/// - Input salvos are created when packets at input ports trigger an epoch
58/// - Output salvos are created when packets at output ports are sent out
59#[derive(Debug, Clone)]
60pub struct Salvo {
61    /// The name of the salvo condition that was triggered.
62    pub salvo_condition: SalvoConditionName,
63    /// The packets in this salvo, paired with their port names.
64    pub packets: Vec<(PortName, PacketID)>,
65}
66
67/// The lifecycle state of an epoch.
68#[derive(Debug, Clone, PartialEq, Eq)]
69#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int))]
70pub enum EpochState {
71    /// Epoch is created but not yet started. External code must call StartEpoch.
72    Startable,
73    /// Epoch is actively running. Packets can be created, loaded, and sent.
74    Running,
75    /// Epoch has completed. No further operations are allowed.
76    Finished,
77}
78
79#[cfg(feature = "python")]
80#[pyo3::pymethods]
81impl EpochState {
82    fn __repr__(&self) -> String {
83        match self {
84            EpochState::Startable => "EpochState.Startable".to_string(),
85            EpochState::Running => "EpochState.Running".to_string(),
86            EpochState::Finished => "EpochState.Finished".to_string(),
87        }
88    }
89}
90
91/// An execution instance of a node.
92///
93/// A single node can have multiple simultaneous epochs. Each epoch tracks
94/// which packets entered (in_salvo), which have been sent out (out_salvos),
95/// and its current lifecycle state.
96#[derive(Debug, Clone)]
97pub struct Epoch {
98    /// Unique identifier for this epoch.
99    pub id: EpochID,
100    /// The node this epoch is executing on.
101    pub node_name: NodeName,
102    /// The salvo of packets that triggered this epoch.
103    pub in_salvo: Salvo,
104    /// Salvos that have been sent out from this epoch.
105    pub out_salvos: Vec<Salvo>,
106    /// Current lifecycle state.
107    pub state: EpochState,
108    /// Packets that were sent to unconnected output ports (moved to OutsideNet).
109    pub orphaned_packets: Vec<OrphanedPacketInfo>,
110}
111
112/// Information about a packet that was sent to an unconnected output port.
113///
114/// When `send_output_salvo` is called and a port has no connected edge,
115/// the packet is moved to `OutsideNet` and tracked here.
116#[derive(Debug, Clone)]
117pub struct OrphanedPacketInfo {
118    /// The packet that was orphaned.
119    pub packet_id: PacketID,
120    /// The output port name the packet was sent from.
121    pub from_port: PortName,
122    /// The salvo condition that triggered the send.
123    pub salvo_condition: SalvoConditionName,
124}
125
126impl Epoch {
127    /// Returns the timestamp when this epoch was created (milliseconds since Unix epoch).
128    pub fn start_time(&self) -> u64 {
129        self.id.timestamp_ms()
130    }
131}
132
133/// Timestamp in milliseconds (UTC).
134pub type EventUTC = i128;
135
136/// An action that can be performed on the network.
137///
138/// All mutations to [`NetSim`] state must go through these actions via [`NetSim::do_action`].
139/// This ensures all operations are tracked and produce appropriate events.
140#[derive(Debug, Clone)]
141pub enum NetAction {
142    /// Execute one step of automatic packet flow.
143    ///
144    /// A single step performs one full iteration of the flow loop:
145    /// 1. Move all movable packets from edges to input ports
146    /// 2. Trigger all satisfied input salvo conditions
147    ///
148    /// Returns `StepResult { made_progress }` indicating whether any progress was made.
149    /// If no progress was made, the network is "blocked".
150    RunStep,
151    /// Create a new packet, optionally inside an epoch.
152    /// If `None`, packet is created outside the network.
153    CreatePacket(Option<EpochID>),
154    /// Consume a packet (normal removal from the network).
155    ConsumePacket(PacketID),
156    /// Destroy a packet (abnormal removal, e.g., due to error or cancellation).
157    DestroyPacket(PacketID),
158    /// Transition a startable epoch to running state.
159    StartEpoch(EpochID),
160    /// Complete a running epoch. Fails if epoch still contains packets.
161    FinishEpoch(EpochID),
162    /// Cancel an epoch and destroy all packets inside it.
163    CancelEpoch(EpochID),
164    /// Manually create an epoch with specified packets.
165    /// Bypasses the normal salvo condition triggering mechanism.
166    /// The epoch is created in Startable state - call StartEpoch to begin execution.
167    CreateEpoch(NodeName, Salvo),
168    /// Move a packet from inside an epoch to one of its output ports.
169    LoadPacketIntoOutputPort(PacketID, PortName),
170    /// Send packets from output ports onto edges according to a salvo condition.
171    SendOutputSalvo(EpochID, SalvoConditionName),
172    /// Transport a packet to a new location.
173    /// Restrictions:
174    /// - Cannot move packets into or out of Running epochs (only Startable allowed)
175    /// - Input ports are checked for capacity
176    TransportPacketToLocation(PacketID, PacketLocation),
177}
178
179/// Errors that can occur when undoing a NetAction
180#[derive(Debug, thiserror::Error)]
181pub enum UndoError {
182    /// Cannot undo because the expected state does not match
183    #[error("state mismatch: {0}")]
184    StateMismatch(String),
185
186    /// Cannot undo because a required entity was not found
187    #[error("entity not found: {0}")]
188    NotFound(String),
189
190    /// Cannot undo because the action type is not undoable
191    #[error("action not undoable: {0}")]
192    NotUndoable(String),
193
194    /// Internal error during undo
195    #[error("internal error: {0}")]
196    InternalError(String),
197}
198
199/// Errors that can occur when performing a NetAction
200#[derive(Debug, thiserror::Error)]
201pub enum NetActionError {
202    /// Packet with the given ID was not found in the network
203    #[error("packet not found: {packet_id}")]
204    PacketNotFound { packet_id: PacketID },
205
206    /// Epoch with the given ID was not found
207    #[error("epoch not found: {epoch_id}")]
208    EpochNotFound { epoch_id: EpochID },
209
210    /// Epoch exists but is not in Running state
211    #[error("epoch {epoch_id} is not running")]
212    EpochNotRunning { epoch_id: EpochID },
213
214    /// Epoch exists but is not in Startable state
215    #[error("epoch {epoch_id} is not startable")]
216    EpochNotStartable { epoch_id: EpochID },
217
218    /// Cannot finish epoch because it still contains packets
219    #[error("cannot finish epoch {epoch_id}: epoch still contains packets")]
220    CannotFinishNonEmptyEpoch { epoch_id: EpochID },
221
222    /// Cannot finish epoch because output port still has unsent packets
223    #[error("cannot finish epoch {epoch_id}: output port '{port_name}' has unsent packets")]
224    UnsentOutputSalvo {
225        epoch_id: EpochID,
226        port_name: PortName,
227    },
228
229    /// Packet is not inside any epoch (not at a Node location)
230    #[error("packet {packet_id} is not inside any epoch")]
231    PacketNotInAnyNode {
232        packet_id: PacketID,
233    },
234
235    /// Output port does not exist on the node
236    #[error("output port '{port_name}' not found on node for epoch {epoch_id}")]
237    OutputPortNotFound {
238        port_name: PortName,
239        epoch_id: EpochID,
240    },
241
242    /// Output salvo condition does not exist on the node
243    #[error("output salvo condition '{condition_name}' not found on node for epoch {epoch_id}")]
244    OutputSalvoConditionNotFound {
245        condition_name: SalvoConditionName,
246        epoch_id: EpochID,
247    },
248
249    /// Maximum number of output salvos reached for this condition
250    #[error("max output salvos reached for condition '{condition_name}' on epoch {epoch_id}")]
251    MaxOutputSalvosReached {
252        condition_name: SalvoConditionName,
253        epoch_id: EpochID,
254    },
255
256    /// Output salvo condition is not satisfied
257    #[error("salvo condition '{condition_name}' not met for epoch {epoch_id}")]
258    SalvoConditionNotMet {
259        condition_name: SalvoConditionName,
260        epoch_id: EpochID,
261    },
262
263    /// Output port has reached its capacity
264    #[error("output port '{port_name}' is full for epoch {epoch_id}")]
265    OutputPortFull {
266        port_name: PortName,
267        epoch_id: EpochID,
268    },
269
270    /// Node with the given name was not found in the graph
271    #[error("node not found: '{node_name}'")]
272    NodeNotFound { node_name: NodeName },
273
274    /// Packet is not at the expected input port
275    #[error("packet {packet_id} is not at input port '{port_name}' of node '{node_name}'")]
276    PacketNotAtInputPort {
277        packet_id: PacketID,
278        port_name: PortName,
279        node_name: NodeName,
280    },
281
282    /// Input port does not exist on the node
283    #[error("input port '{port_name}' not found on node '{node_name}'")]
284    InputPortNotFound {
285        port_name: PortName,
286        node_name: NodeName,
287    },
288
289    /// Input port has reached its capacity
290    #[error("input port '{port_name}' on node '{node_name}' is full")]
291    InputPortFull {
292        port_name: PortName,
293        node_name: NodeName,
294    },
295
296    /// Cannot move packet out of a running epoch
297    #[error("cannot move packet {packet_id} out of running epoch {epoch_id}")]
298    CannotMovePacketFromRunningEpoch {
299        packet_id: PacketID,
300        epoch_id: EpochID,
301    },
302
303    /// Cannot move packet into a running epoch
304    #[error("cannot move packet {packet_id} into running epoch {epoch_id}")]
305    CannotMovePacketIntoRunningEpoch {
306        packet_id: PacketID,
307        epoch_id: EpochID,
308    },
309
310    /// Edge does not exist in the graph
311    #[error("edge not found: {edge}")]
312    EdgeNotFound { edge: Edge },
313}
314
315/// An event that occurred during a network action.
316///
317/// Events provide a complete audit trail of all state changes in the network.
318/// Each event includes a timestamp and relevant identifiers.
319/// Events contain all information needed to undo the operation.
320#[derive(Debug, Clone)]
321pub enum NetEvent {
322    /// A new packet was created.
323    PacketCreated(EventUTC, PacketID),
324    /// A packet was consumed (normal removal from the network).
325    /// Includes the packet's location before consumption for undo support.
326    PacketConsumed(EventUTC, PacketID, PacketLocation),
327    /// A packet was destroyed (abnormal removal, e.g., epoch cancellation).
328    /// Includes the packet's location before destruction for undo support.
329    PacketDestroyed(EventUTC, PacketID, PacketLocation),
330    /// A new epoch was created (in Startable state).
331    EpochCreated(EventUTC, EpochID),
332    /// An epoch transitioned from Startable to Running.
333    EpochStarted(EventUTC, EpochID),
334    /// An epoch completed successfully.
335    /// Includes the full epoch state for undo support.
336    EpochFinished(EventUTC, Epoch),
337    /// An epoch was cancelled.
338    /// Includes the full epoch state for undo support.
339    EpochCancelled(EventUTC, Epoch),
340    /// A packet moved from one location to another.
341    /// Includes the index in the source location for perfect undo restoration.
342    /// (timestamp, packet_id, from_location, to_location, from_index)
343    PacketMoved(EventUTC, PacketID, PacketLocation, PacketLocation, usize),
344    /// An input salvo condition was triggered, creating an epoch.
345    InputSalvoTriggered(EventUTC, EpochID, SalvoConditionName),
346    /// An output salvo condition was triggered, sending packets.
347    OutputSalvoTriggered(EventUTC, EpochID, SalvoConditionName),
348    /// A packet was sent to an unconnected output port and moved to OutsideNet.
349    /// (timestamp, packet_id, epoch_id, node_name, port_name, salvo_condition)
350    PacketOrphaned(
351        EventUTC,
352        PacketID,
353        EpochID,
354        NodeName,
355        PortName,
356        SalvoConditionName,
357    ),
358}
359
360/// Data returned by a successful network action.
361#[derive(Debug, Clone)]
362pub enum NetActionResponseData {
363    /// Result of RunStep: whether any progress was made.
364    StepResult {
365        /// True if any packets moved or epochs were created.
366        /// False if the network is blocked.
367        made_progress: bool,
368    },
369    /// A packet ID (returned by CreatePacket).
370    Packet(PacketID),
371    /// The created epoch in Startable state (returned by CreateEpoch).
372    CreatedEpoch(Epoch),
373    /// The started epoch (returned by StartEpoch).
374    StartedEpoch(Epoch),
375    /// The finished epoch (returned by FinishEpoch).
376    FinishedEpoch(Epoch),
377    /// The cancelled epoch and IDs of destroyed packets (returned by CancelEpoch).
378    CancelledEpoch(Epoch, Vec<PacketID>),
379    /// No specific data (returned by ConsumePacket, DestroyPacket, etc.).
380    None,
381}
382
383/// The result of performing a network action.
384#[derive(Debug)]
385pub enum NetActionResponse {
386    /// Action succeeded, with optional data and a list of events that occurred.
387    Success(NetActionResponseData, Vec<NetEvent>),
388    /// Action failed with an error.
389    Error(NetActionError),
390}
391
392/// The runtime state of a flow-based network.
393///
394/// A `NetSim` is created from a [`Graph`] and tracks:
395/// - All packets and their locations
396/// - All epochs and their states
397/// - Which epochs are startable
398///
399/// All mutations must go through [`NetSim::do_action`] to ensure proper event tracking.
400#[derive(Debug)]
401pub struct NetSim {
402    /// The graph topology this network is running on.
403    pub graph: Graph,
404    _packets: HashMap<PacketID, Packet>,
405    _packets_by_location: HashMap<PacketLocation, IndexSet<PacketID>>,
406    _epochs: HashMap<EpochID, Epoch>,
407    _startable_epochs: HashSet<EpochID>,
408    _node_to_epochs: HashMap<NodeName, Vec<EpochID>>,
409}
410
411impl NetSim {
412    /// Creates a new net simulation from a Graph.
413    ///
414    /// Initializes packet location tracking for all edges and input ports.
415    pub fn new(graph: Graph) -> Self {
416        let errors = graph.validate();
417        if !errors.is_empty() {
418            let msgs: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
419            panic!("Cannot create NetSim with invalid graph:\n  - {}", msgs.join("\n  - "));
420        }
421
422        let mut packets_by_location: HashMap<PacketLocation, IndexSet<PacketID>> = HashMap::new();
423
424        // Initialize empty packet sets for all edges
425        for edge in graph.edges() {
426            packets_by_location.insert(PacketLocation::Edge(edge.clone()), IndexSet::new());
427        }
428
429        // Initialize empty packet sets for all input ports
430        for (node_name, node) in graph.nodes() {
431            for port_name in node.in_ports.keys() {
432                packets_by_location.insert(
433                    PacketLocation::InputPort(node_name.clone(), port_name.clone()),
434                    IndexSet::new(),
435                );
436            }
437        }
438
439        // Initialize OutsideNet location for packets created outside the network
440        packets_by_location.insert(PacketLocation::OutsideNet, IndexSet::new());
441
442        // Note: Output port locations are created per-epoch when epochs are created
443        // Note: Node locations (inside epochs) are created when epochs are created
444
445        NetSim {
446            graph,
447            _packets: HashMap::new(),
448            _packets_by_location: packets_by_location,
449            _epochs: HashMap::new(),
450            _startable_epochs: HashSet::new(),
451            _node_to_epochs: HashMap::new(),
452        }
453    }
454
455    fn move_packet(&mut self, packet_id: &PacketID, new_location: PacketLocation) {
456        let packet = self._packets.get_mut(packet_id).unwrap();
457        let packets_at_old_location = self
458            ._packets_by_location
459            .get_mut(&packet.location)
460            .expect("Packet location has no entry in self._packets_by_location.");
461        packets_at_old_location.shift_remove(packet_id);
462        packet.location = new_location;
463        if !self
464            ._packets_by_location
465            .get_mut(&packet.location)
466            .expect("Packet location has no entry in self._packets_by_location")
467            .insert(*packet_id)
468        {
469            panic!("Attempted to move packet to a location that already contains it.");
470        }
471    }
472
473    // NetActions
474
475    /// Helper: Try to trigger an input salvo condition for a node.
476    /// Returns (triggered: bool, events: Vec<NetEvent>).
477    fn try_trigger_input_salvo(&mut self, node_name: &NodeName) -> (bool, Vec<NetEvent>) {
478        let mut events: Vec<NetEvent> = Vec::new();
479
480        let node = match self.graph.nodes().get(node_name) {
481            Some(n) => n,
482            None => return (false, events),
483        };
484
485        let in_port_names: Vec<PortName> = node.in_ports.keys().cloned().collect();
486        let in_ports_clone: HashMap<PortName, Port> = node.in_ports.clone();
487
488        // Collect salvo condition data
489        struct SalvoConditionData {
490            name: SalvoConditionName,
491            ports: HashMap<PortName, PacketCount>,
492            term: SalvoConditionTerm,
493        }
494
495        let salvo_conditions: Vec<SalvoConditionData> = node
496            .in_salvo_conditions
497            .iter()
498            .map(|(name, cond)| SalvoConditionData {
499                name: name.clone(),
500                ports: cond.ports.clone(),
501                term: cond.term.clone(),
502            })
503            .collect();
504
505        // Check salvo conditions in order - first satisfied one wins
506        for salvo_cond_data in salvo_conditions {
507            // Calculate packet counts for all input ports
508            let port_packet_counts: HashMap<PortName, u64> = in_port_names
509                .iter()
510                .map(|port_name| {
511                    let count = self
512                        ._packets_by_location
513                        .get(&PacketLocation::InputPort(
514                            node_name.clone(),
515                            port_name.clone(),
516                        ))
517                        .map(|packets| packets.len() as u64)
518                        .unwrap_or(0);
519                    (port_name.clone(), count)
520                })
521                .collect();
522
523            // Check if salvo condition is satisfied
524            if evaluate_salvo_condition(&salvo_cond_data.term, &port_packet_counts, &in_ports_clone)
525            {
526                // Create a new epoch
527                let epoch_id = Ulid::new();
528
529                // Collect packets from the ports listed in salvo_condition.ports
530                // Store (packet_id, port_name) for each packet to move
531                let mut salvo_packets: Vec<(PortName, PacketID)> = Vec::new();
532                let mut packets_to_move: Vec<(PacketID, PortName)> = Vec::new();
533
534                for (port_name, packet_count) in &salvo_cond_data.ports {
535                    let port_location =
536                        PacketLocation::InputPort(node_name.clone(), port_name.clone());
537                    if let Some(packet_ids) = self._packets_by_location.get(&port_location) {
538                        let take_count = match packet_count {
539                            PacketCount::All => packet_ids.len(),
540                            PacketCount::Count(n) => std::cmp::min(*n as usize, packet_ids.len()),
541                        };
542                        for pid in packet_ids.iter().take(take_count) {
543                            salvo_packets.push((port_name.clone(), *pid));
544                            packets_to_move.push((*pid, port_name.clone()));
545                        }
546                    }
547                }
548
549                // Create the salvo
550                let in_salvo = Salvo {
551                    salvo_condition: salvo_cond_data.name.clone(),
552                    packets: salvo_packets,
553                };
554
555                // Create the epoch
556                let epoch = Epoch {
557                    id: epoch_id,
558                    node_name: node_name.clone(),
559                    in_salvo,
560                    out_salvos: Vec::new(),
561                    state: EpochState::Startable,
562                    orphaned_packets: Vec::new(),
563                };
564
565                // Register the epoch
566                self._epochs.insert(epoch_id, epoch);
567                self._startable_epochs.insert(epoch_id);
568                self._node_to_epochs
569                    .entry(node_name.clone())
570                    .or_default()
571                    .push(epoch_id);
572
573                // Create a location entry for packets inside the epoch
574                let epoch_location = PacketLocation::Node(epoch_id);
575                self._packets_by_location
576                    .insert(epoch_location.clone(), IndexSet::new());
577
578                // Create output port location entries for this epoch
579                let node = self
580                    .graph
581                    .nodes()
582                    .get(node_name)
583                    .expect("Node not found for epoch creation");
584                for out_port_name in node.out_ports.keys() {
585                    let output_port_location =
586                        PacketLocation::OutputPort(epoch_id, out_port_name.clone());
587                    self._packets_by_location
588                        .insert(output_port_location, IndexSet::new());
589                }
590
591                // Emit events in logical order:
592                // 1. InputSalvoTriggered - the condition was met, triggering epoch creation
593                // 2. EpochCreated - the epoch is created as a result
594                // 3. PacketMoved - packets move into the newly created epoch
595                events.push(NetEvent::InputSalvoTriggered(
596                    get_utc_now(),
597                    epoch_id,
598                    salvo_cond_data.name.clone(),
599                ));
600                events.push(NetEvent::EpochCreated(get_utc_now(), epoch_id));
601
602                // Move packets from input ports into the epoch
603                for (pid, port_name) in &packets_to_move {
604                    let from_location =
605                        PacketLocation::InputPort(node_name.clone(), port_name.clone());
606
607                    // Get the fresh index of this packet before moving (indices shift after each move)
608                    let from_index = self
609                        ._packets_by_location
610                        .get(&from_location)
611                        .and_then(|packets| packets.get_index_of(pid))
612                        .expect("Packet should exist at from_location");
613
614                    self.move_packet(pid, epoch_location.clone());
615                    events.push(NetEvent::PacketMoved(
616                        get_utc_now(),
617                        *pid,
618                        from_location,
619                        epoch_location.clone(),
620                        from_index,
621                    ));
622                }
623
624                // Only one salvo condition can trigger per node per call
625                return (true, events);
626            }
627        }
628
629        (false, events)
630    }
631
632    fn run_step(&mut self) -> NetActionResponse {
633        let mut all_events: Vec<NetEvent> = Vec::new();
634        let mut made_progress = false;
635
636        // Phase 1: Move packets from edges to input ports
637        // Collect all edge locations and their first packet (FIFO)
638        // We need to extract data before mutating to avoid borrow issues
639        struct EdgeMoveCandidate {
640            packet_id: PacketID,
641            from_location: PacketLocation,
642            from_index: usize,
643            input_port_location: PacketLocation,
644            can_move: bool,
645        }
646
647        let mut edge_candidates: Vec<EdgeMoveCandidate> = Vec::new();
648
649        // Iterate through all edge locations in _packets_by_location
650        for (location, packets) in &self._packets_by_location {
651            if let PacketLocation::Edge(edge_ref) = location {
652                // Get the first packet (FIFO order)
653                if let Some(first_packet_id) = packets.first() {
654                    let target_node_name = edge_ref.target.node_name.clone();
655                    let target_port_name = edge_ref.target.port_name.clone();
656
657                    // Check if the target input port has space
658                    let node = self
659                        .graph
660                        .nodes()
661                        .get(&target_node_name)
662                        .expect("Edge targets a non-existent node");
663                    let port = node
664                        .in_ports
665                        .get(&target_port_name)
666                        .expect("Edge targets a non-existent input port");
667
668                    let input_port_location = PacketLocation::InputPort(
669                        target_node_name.clone(),
670                        target_port_name.clone(),
671                    );
672                    let current_count = self
673                        ._packets_by_location
674                        .get(&input_port_location)
675                        .map(|packets| packets.len() as u64)
676                        .unwrap_or(0);
677
678                    let can_move = match port.slots_spec {
679                        PortSlotSpec::Infinite => true,
680                        PortSlotSpec::Finite(max_slots) => current_count < max_slots,
681                    };
682
683                    edge_candidates.push(EdgeMoveCandidate {
684                        packet_id: *first_packet_id,
685                        from_location: location.clone(),
686                        from_index: 0, // First packet is always at index 0
687                        input_port_location,
688                        can_move,
689                    });
690                }
691            }
692        }
693
694        // Phase 1: Move packets from edges to input ports
695        for candidate in edge_candidates {
696            if !candidate.can_move {
697                continue;
698            }
699
700            // Move the packet to the input port
701            self.move_packet(&candidate.packet_id, candidate.input_port_location.clone());
702            all_events.push(NetEvent::PacketMoved(
703                get_utc_now(),
704                candidate.packet_id,
705                candidate.from_location,
706                candidate.input_port_location.clone(),
707                candidate.from_index,
708            ));
709            made_progress = true;
710        }
711
712        // Phase 2: Check salvo conditions for all nodes with packets at input ports
713        let mut nodes_with_input_packets: Vec<NodeName> = Vec::new();
714        for (location, packets) in &self._packets_by_location {
715            if let PacketLocation::InputPort(node_name, _) = location
716                && !packets.is_empty()
717                && !nodes_with_input_packets.contains(node_name)
718            {
719                nodes_with_input_packets.push(node_name.clone());
720            }
721        }
722
723        for node_name in nodes_with_input_packets {
724            let (triggered, events) = self.try_trigger_input_salvo(&node_name);
725            all_events.extend(events);
726            if triggered {
727                made_progress = true;
728            }
729        }
730
731        NetActionResponse::Success(
732            NetActionResponseData::StepResult { made_progress },
733            all_events,
734        )
735    }
736
737    fn create_packet(&mut self, maybe_epoch_id: &Option<EpochID>) -> NetActionResponse {
738        // Check that epoch_id exists and is running
739        if let Some(epoch_id) = maybe_epoch_id {
740            if !self._epochs.contains_key(epoch_id) {
741                return NetActionResponse::Error(NetActionError::EpochNotFound {
742                    epoch_id: *epoch_id,
743                });
744            }
745            if !matches!(self._epochs[epoch_id].state, EpochState::Running) {
746                return NetActionResponse::Error(NetActionError::EpochNotRunning {
747                    epoch_id: *epoch_id,
748                });
749            }
750        }
751
752        let packet_location = match maybe_epoch_id {
753            Some(epoch_id) => PacketLocation::Node(*epoch_id),
754            None => PacketLocation::OutsideNet,
755        };
756
757        let packet = Packet {
758            id: Ulid::new(),
759            location: packet_location.clone(),
760        };
761
762        let packet_id = packet.id;
763        self._packets.insert(packet.id, packet);
764
765        // Add packet to location index
766        self._packets_by_location
767            .entry(packet_location)
768            .or_default()
769            .insert(packet_id);
770
771        NetActionResponse::Success(
772            NetActionResponseData::Packet(packet_id),
773            vec![NetEvent::PacketCreated(get_utc_now(), packet_id)],
774        )
775    }
776
777    fn consume_packet(&mut self, packet_id: &PacketID) -> NetActionResponse {
778        if !self._packets.contains_key(packet_id) {
779            return NetActionResponse::Error(NetActionError::PacketNotFound {
780                packet_id: *packet_id,
781            });
782        }
783
784        let location = self._packets[packet_id].location.clone();
785
786        if let Some(packets) = self._packets_by_location.get_mut(&location) {
787            if packets.shift_remove(packet_id) {
788                self._packets.remove(packet_id);
789                NetActionResponse::Success(
790                    NetActionResponseData::None,
791                    vec![NetEvent::PacketConsumed(
792                        get_utc_now(),
793                        *packet_id,
794                        location,
795                    )],
796                )
797            } else {
798                panic!(
799                    "Packet with ID {} not found in location {:?}",
800                    packet_id, location
801                );
802            }
803        } else {
804            panic!("Packet location {:?} not found", location);
805        }
806    }
807
808    fn destroy_packet(&mut self, packet_id: &PacketID) -> NetActionResponse {
809        if !self._packets.contains_key(packet_id) {
810            return NetActionResponse::Error(NetActionError::PacketNotFound {
811                packet_id: *packet_id,
812            });
813        }
814
815        let location = self._packets[packet_id].location.clone();
816
817        if let Some(packets) = self._packets_by_location.get_mut(&location) {
818            if packets.shift_remove(packet_id) {
819                self._packets.remove(packet_id);
820                NetActionResponse::Success(
821                    NetActionResponseData::None,
822                    vec![NetEvent::PacketDestroyed(
823                        get_utc_now(),
824                        *packet_id,
825                        location,
826                    )],
827                )
828            } else {
829                panic!(
830                    "Packet with ID {} not found in location {:?}",
831                    packet_id, location
832                );
833            }
834        } else {
835            panic!("Packet location {:?} not found", location);
836        }
837    }
838
839    fn start_epoch(&mut self, epoch_id: &EpochID) -> NetActionResponse {
840        if let Some(epoch) = self._epochs.get_mut(epoch_id) {
841            if !self._startable_epochs.contains(epoch_id) {
842                return NetActionResponse::Error(NetActionError::EpochNotStartable {
843                    epoch_id: *epoch_id,
844                });
845            }
846            debug_assert!(
847                matches!(epoch.state, EpochState::Startable),
848                "Epoch state is not Startable but was in net._startable_epochs."
849            );
850            epoch.state = EpochState::Running;
851            self._startable_epochs.remove(epoch_id);
852            NetActionResponse::Success(
853                NetActionResponseData::StartedEpoch(epoch.clone()),
854                vec![NetEvent::EpochStarted(get_utc_now(), *epoch_id)],
855            )
856        } else {
857            NetActionResponse::Error(NetActionError::EpochNotFound {
858                epoch_id: *epoch_id,
859            })
860        }
861    }
862
863    fn finish_epoch(&mut self, epoch_id: &EpochID) -> NetActionResponse {
864        // Check if epoch exists
865        let epoch = if let Some(epoch) = self._epochs.get(epoch_id) {
866            epoch.clone()
867        } else {
868            return NetActionResponse::Error(NetActionError::EpochNotFound {
869                epoch_id: *epoch_id,
870            });
871        };
872
873        // Check if epoch is running
874        if epoch.state != EpochState::Running {
875            return NetActionResponse::Error(NetActionError::EpochNotRunning {
876                epoch_id: *epoch_id,
877            });
878        }
879
880        // No packets may remain inside the epoch
881        let epoch_loc = PacketLocation::Node(*epoch_id);
882        if let Some(packets) = self._packets_by_location.get(&epoch_loc) {
883            if !packets.is_empty() {
884                return NetActionResponse::Error(NetActionError::CannotFinishNonEmptyEpoch {
885                    epoch_id: *epoch_id,
886                });
887            }
888        } else {
889            panic!("Epoch {} not found in location {:?}", epoch_id, epoch_loc);
890        }
891
892        // No packets may remain in output ports (unsent salvos)
893        let node = self
894            .graph
895            .nodes()
896            .get(&epoch.node_name)
897            .expect("Epoch references non-existent node");
898        for port_name in node.out_ports.keys() {
899            let output_port_loc = PacketLocation::OutputPort(*epoch_id, port_name.clone());
900            if let Some(packets) = self._packets_by_location.get(&output_port_loc)
901                && !packets.is_empty()
902            {
903                return NetActionResponse::Error(NetActionError::UnsentOutputSalvo {
904                    epoch_id: *epoch_id,
905                    port_name: port_name.clone(),
906                });
907            }
908        }
909
910        // All checks passed - finish the epoch
911        // Clone epoch state before modifying for the event (captures Running state)
912        let epoch_before_finish = self._epochs[epoch_id].clone();
913
914        let mut epoch = self._epochs.remove(epoch_id).unwrap();
915        epoch.state = EpochState::Finished;
916
917        // Clean up location entries
918        self._packets_by_location.remove(&epoch_loc);
919        for port_name in node.out_ports.keys() {
920            let output_port_loc = PacketLocation::OutputPort(*epoch_id, port_name.clone());
921            self._packets_by_location.remove(&output_port_loc);
922        }
923
924        // Remove from _node_to_epochs
925        if let Some(epoch_ids) = self._node_to_epochs.get_mut(&epoch.node_name) {
926            epoch_ids.retain(|id| id != epoch_id);
927        }
928
929        NetActionResponse::Success(
930            NetActionResponseData::FinishedEpoch(epoch),
931            vec![NetEvent::EpochFinished(get_utc_now(), epoch_before_finish)],
932        )
933    }
934
935    fn cancel_epoch(&mut self, epoch_id: &EpochID) -> NetActionResponse {
936        // Check if epoch exists and capture it for the event
937        let epoch_for_event = if let Some(epoch) = self._epochs.get(epoch_id) {
938            epoch.clone()
939        } else {
940            return NetActionResponse::Error(NetActionError::EpochNotFound {
941                epoch_id: *epoch_id,
942            });
943        };
944
945        let mut events: Vec<NetEvent> = Vec::new();
946        let mut destroyed_packets: Vec<PacketID> = Vec::new();
947
948        // Collect packets inside the epoch (Node location)
949        let epoch_location = PacketLocation::Node(*epoch_id);
950        if let Some(packet_ids) = self._packets_by_location.get(&epoch_location) {
951            destroyed_packets.extend(packet_ids.iter().cloned());
952        }
953
954        // Collect packets in the epoch's output ports
955        let node = self
956            .graph
957            .nodes()
958            .get(&epoch_for_event.node_name)
959            .expect("Epoch references non-existent node");
960        for port_name in node.out_ports.keys() {
961            let output_port_location = PacketLocation::OutputPort(*epoch_id, port_name.clone());
962            if let Some(packet_ids) = self._packets_by_location.get(&output_port_location) {
963                destroyed_packets.extend(packet_ids.iter().cloned());
964            }
965        }
966
967        // Remove packets from _packets and _packets_by_location, emit events with location
968        for packet_id in &destroyed_packets {
969            let packet = self
970                ._packets
971                .remove(packet_id)
972                .expect("Packet in location map not found in packets map");
973            let packet_location = packet.location.clone();
974            if let Some(packets_at_location) = self._packets_by_location.get_mut(&packet_location) {
975                packets_at_location.shift_remove(packet_id);
976            }
977            events.push(NetEvent::PacketDestroyed(
978                get_utc_now(),
979                *packet_id,
980                packet_location,
981            ));
982        }
983
984        // Remove output port location entries for this epoch
985        for port_name in node.out_ports.keys() {
986            let output_port_location = PacketLocation::OutputPort(*epoch_id, port_name.clone());
987            self._packets_by_location.remove(&output_port_location);
988        }
989
990        // Remove the epoch's node location entry
991        self._packets_by_location.remove(&epoch_location);
992
993        // Update _startable_epochs if epoch was startable
994        self._startable_epochs.remove(epoch_id);
995
996        // Update _node_to_epochs
997        if let Some(epoch_ids) = self._node_to_epochs.get_mut(&epoch_for_event.node_name) {
998            epoch_ids.retain(|id| id != epoch_id);
999        }
1000
1001        // Remove epoch from _epochs
1002        let epoch = self._epochs.remove(epoch_id).expect("Epoch should exist");
1003
1004        events.push(NetEvent::EpochCancelled(get_utc_now(), epoch_for_event));
1005
1006        NetActionResponse::Success(
1007            NetActionResponseData::CancelledEpoch(epoch, destroyed_packets),
1008            events,
1009        )
1010    }
1011
1012    fn create_epoch(&mut self, node_name: &NodeName, salvo: &Salvo) -> NetActionResponse {
1013        // Validate node exists
1014        let node = match self.graph.nodes().get(node_name) {
1015            Some(node) => node,
1016            None => {
1017                return NetActionResponse::Error(NetActionError::NodeNotFound {
1018                    node_name: node_name.clone(),
1019                });
1020            }
1021        };
1022
1023        // Validate all packets in salvo
1024        for (port_name, packet_id) in &salvo.packets {
1025            // Validate input port exists
1026            if !node.in_ports.contains_key(port_name) {
1027                return NetActionResponse::Error(NetActionError::InputPortNotFound {
1028                    port_name: port_name.clone(),
1029                    node_name: node_name.clone(),
1030                });
1031            }
1032
1033            // Validate packet exists
1034            let packet = match self._packets.get(packet_id) {
1035                Some(packet) => packet,
1036                None => {
1037                    return NetActionResponse::Error(NetActionError::PacketNotFound {
1038                        packet_id: *packet_id,
1039                    });
1040                }
1041            };
1042
1043            // Validate packet is at the input port of this node
1044            let expected_location = PacketLocation::InputPort(node_name.clone(), port_name.clone());
1045            if packet.location != expected_location {
1046                return NetActionResponse::Error(NetActionError::PacketNotAtInputPort {
1047                    packet_id: *packet_id,
1048                    port_name: port_name.clone(),
1049                    node_name: node_name.clone(),
1050                });
1051            }
1052        }
1053
1054        let mut events: Vec<NetEvent> = Vec::new();
1055
1056        // Create the epoch in Startable state
1057        let epoch_id = Ulid::new();
1058        let epoch = Epoch {
1059            id: epoch_id,
1060            node_name: node_name.clone(),
1061            in_salvo: salvo.clone(),
1062            out_salvos: Vec::new(),
1063            state: EpochState::Startable,
1064            orphaned_packets: Vec::new(),
1065        };
1066
1067        // Register the epoch
1068        self._epochs.insert(epoch_id, epoch.clone());
1069        self._startable_epochs.insert(epoch_id);
1070        self._node_to_epochs
1071            .entry(node_name.clone())
1072            .or_default()
1073            .push(epoch_id);
1074
1075        // Create location entry for packets inside the epoch
1076        let epoch_location = PacketLocation::Node(epoch_id);
1077        self._packets_by_location
1078            .insert(epoch_location.clone(), IndexSet::new());
1079
1080        // Create output port location entries for this epoch
1081        for port_name in node.out_ports.keys() {
1082            let output_port_location = PacketLocation::OutputPort(epoch_id, port_name.clone());
1083            self._packets_by_location
1084                .insert(output_port_location, IndexSet::new());
1085        }
1086
1087        events.push(NetEvent::EpochCreated(get_utc_now(), epoch_id));
1088
1089        // Move packets from input ports into the epoch
1090        for (port_name, packet_id) in &salvo.packets {
1091            let from_location = PacketLocation::InputPort(node_name.clone(), port_name.clone());
1092
1093            // Get the index of this packet in its source location before moving
1094            let from_index = self
1095                ._packets_by_location
1096                .get(&from_location)
1097                .and_then(|packets| packets.get_index_of(packet_id))
1098                .expect("Packet should exist at from_location");
1099
1100            self.move_packet(packet_id, epoch_location.clone());
1101            events.push(NetEvent::PacketMoved(
1102                get_utc_now(),
1103                *packet_id,
1104                from_location,
1105                epoch_location.clone(),
1106                from_index,
1107            ));
1108        }
1109
1110        NetActionResponse::Success(NetActionResponseData::CreatedEpoch(epoch), events)
1111    }
1112
1113    fn load_packet_into_output_port(
1114        &mut self,
1115        packet_id: &PacketID,
1116        port_name: &str,
1117    ) -> NetActionResponse {
1118        let (epoch_id, old_location) = if let Some(packet) = self._packets.get(packet_id) {
1119            if let PacketLocation::Node(epoch_id) = packet.location {
1120                (epoch_id, packet.location.clone())
1121            } else {
1122                return NetActionResponse::Error(NetActionError::PacketNotInAnyNode {
1123                    packet_id: *packet_id,
1124                });
1125            }
1126        } else {
1127            return NetActionResponse::Error(NetActionError::PacketNotFound {
1128                packet_id: *packet_id,
1129            });
1130        };
1131
1132        let node_name = self
1133            ._epochs
1134            .get(&epoch_id)
1135            .expect("The epoch id in the location of a packet could not be found.")
1136            .node_name
1137            .clone();
1138        let node = self
1139            .graph
1140            .nodes()
1141            .get(&node_name)
1142            .expect("Packet located in a non-existing node (yet the node has an epoch).");
1143
1144        if !node.out_ports.contains_key(port_name) {
1145            return NetActionResponse::Error(NetActionError::OutputPortNotFound {
1146                port_name: port_name.to_string(),
1147                epoch_id,
1148            });
1149        }
1150
1151        let port = node.out_ports.get(port_name).unwrap();
1152        let output_port_location = PacketLocation::OutputPort(epoch_id, port_name.to_string());
1153        let port_packets = self
1154            ._packets_by_location
1155            .get(&output_port_location)
1156            .expect("No entry in NetSim._packets_by_location found for output port.");
1157
1158        // Check if the output port is full
1159        if let PortSlotSpec::Finite(num_slots) = port.slots_spec
1160            && port_packets.len() as u64 >= num_slots
1161        {
1162            return NetActionResponse::Error(NetActionError::OutputPortFull {
1163                port_name: port_name.to_string(),
1164                epoch_id,
1165            });
1166        }
1167
1168        // Get the index before moving
1169        let from_index = self
1170            ._packets_by_location
1171            .get(&old_location)
1172            .and_then(|packets| packets.get_index_of(packet_id))
1173            .expect("Packet should exist at old_location");
1174
1175        let new_location = output_port_location;
1176        self.move_packet(packet_id, new_location.clone());
1177        NetActionResponse::Success(
1178            NetActionResponseData::None,
1179            vec![NetEvent::PacketMoved(
1180                get_utc_now(),
1181                *packet_id,
1182                old_location,
1183                new_location,
1184                from_index,
1185            )],
1186        )
1187    }
1188
1189    fn send_output_salvo(
1190        &mut self,
1191        epoch_id: &EpochID,
1192        salvo_condition_name: &SalvoConditionName,
1193    ) -> NetActionResponse {
1194        // Get epoch
1195        let epoch = if let Some(epoch) = self._epochs.get(epoch_id) {
1196            epoch
1197        } else {
1198            return NetActionResponse::Error(NetActionError::EpochNotFound {
1199                epoch_id: *epoch_id,
1200            });
1201        };
1202
1203        // Get node and capture node_name early to avoid borrow issues
1204        let node = self
1205            .graph
1206            .nodes()
1207            .get(&epoch.node_name)
1208            .expect("Node associated with epoch could not be found.");
1209        let node_name = node.name.clone();
1210
1211        // Get salvo condition
1212        let salvo_condition =
1213            if let Some(salvo_condition) = node.out_salvo_conditions.get(salvo_condition_name) {
1214                salvo_condition
1215            } else {
1216                return NetActionResponse::Error(NetActionError::OutputSalvoConditionNotFound {
1217                    condition_name: salvo_condition_name.clone(),
1218                    epoch_id: *epoch_id,
1219                });
1220            };
1221
1222        // Check if max salvos reached for this specific condition
1223        if let MaxSalvos::Finite(max) = salvo_condition.max_salvos {
1224            let condition_salvo_count = epoch
1225                .out_salvos
1226                .iter()
1227                .filter(|s| s.salvo_condition == *salvo_condition_name)
1228                .count() as u64;
1229            if condition_salvo_count >= max {
1230                return NetActionResponse::Error(NetActionError::MaxOutputSalvosReached {
1231                    condition_name: salvo_condition_name.clone(),
1232                    epoch_id: *epoch_id,
1233                });
1234            }
1235        }
1236
1237        // Check that the salvo condition is met
1238        let port_packet_counts: HashMap<PortName, u64> = node
1239            .out_ports
1240            .keys()
1241            .map(|port_name| {
1242                let count = self
1243                    ._packets_by_location
1244                    .get(&PacketLocation::OutputPort(*epoch_id, port_name.clone()))
1245                    .map(|packets| packets.len() as u64)
1246                    .unwrap_or(0);
1247                (port_name.clone(), count)
1248            })
1249            .collect();
1250        if !evaluate_salvo_condition(&salvo_condition.term, &port_packet_counts, &node.out_ports) {
1251            return NetActionResponse::Error(NetActionError::SalvoConditionNotMet {
1252                condition_name: salvo_condition_name.clone(),
1253                epoch_id: *epoch_id,
1254            });
1255        }
1256
1257        // Get the locations to send packets to
1258        // Tuple: (packet_id, port_name, from_location, to_location, is_orphaned)
1259        let mut packets_to_move: Vec<(
1260            PacketID,
1261            PortName,
1262            PacketLocation,
1263            PacketLocation,
1264            bool,
1265        )> = Vec::new();
1266        for (port_name, packet_count) in &salvo_condition.ports {
1267            let from_location = PacketLocation::OutputPort(*epoch_id, port_name.clone());
1268            let packets = self
1269                ._packets_by_location
1270                .get(&from_location)
1271                .unwrap_or_else(|| {
1272                    panic!(
1273                        "Output port '{}' of node '{}' does not have an entry in self._packets_by_location",
1274                        port_name,
1275                        node_name
1276                    )
1277                })
1278                .clone();
1279
1280            // Check if there's an edge connected to this output port
1281            let (to_location, is_orphaned) = if let Some(edge_ref) =
1282                self.graph.get_edge_by_tail(&PortRef {
1283                    node_name: node_name.clone(),
1284                    port_type: PortType::Output,
1285                    port_name: port_name.clone(),
1286                }) {
1287                // Connected: send to edge
1288                (PacketLocation::Edge(edge_ref.clone()), false)
1289            } else {
1290                // Unconnected: send to OutsideNet (orphaned)
1291                (PacketLocation::OutsideNet, true)
1292            };
1293
1294            let take_count = match packet_count {
1295                PacketCount::All => packets.len(),
1296                PacketCount::Count(n) => std::cmp::min(*n as usize, packets.len()),
1297            };
1298            for packet_id in packets.into_iter().take(take_count) {
1299                packets_to_move.push((
1300                    packet_id,
1301                    port_name.clone(),
1302                    from_location.clone(),
1303                    to_location.clone(),
1304                    is_orphaned,
1305                ));
1306            }
1307        }
1308
1309        // Create a Salvo and add it to the epoch
1310        let salvo = Salvo {
1311            salvo_condition: salvo_condition_name.clone(),
1312            packets: packets_to_move
1313                .iter()
1314                .map(|(packet_id, port_name, _, _, _)| (port_name.clone(), *packet_id))
1315                .collect(),
1316        };
1317        self._epochs
1318            .get_mut(epoch_id)
1319            .unwrap()
1320            .out_salvos
1321            .push(salvo);
1322
1323        // Move packets and track orphaned ones
1324        let mut net_events = Vec::new();
1325        let mut orphaned_infos: Vec<OrphanedPacketInfo> = Vec::new();
1326
1327        for (packet_id, port_name, from_location, to_location, is_orphaned) in
1328            packets_to_move
1329        {
1330            if is_orphaned {
1331                // Emit PacketOrphaned event for unconnected port
1332                net_events.push(NetEvent::PacketOrphaned(
1333                    get_utc_now(),
1334                    packet_id,
1335                    *epoch_id,
1336                    node_name.clone(),
1337                    port_name.clone(),
1338                    salvo_condition_name.clone(),
1339                ));
1340                orphaned_infos.push(OrphanedPacketInfo {
1341                    packet_id,
1342                    from_port: port_name,
1343                    salvo_condition: salvo_condition_name.clone(),
1344                });
1345            } else {
1346                // Get the fresh index of this packet before moving (indices shift after each move)
1347                let from_index = self
1348                    ._packets_by_location
1349                    .get(&from_location)
1350                    .and_then(|packets| packets.get_index_of(&packet_id))
1351                    .expect("Packet should exist at from_location");
1352
1353                // Emit PacketMoved event for connected port
1354                net_events.push(NetEvent::PacketMoved(
1355                    get_utc_now(),
1356                    packet_id,
1357                    from_location,
1358                    to_location.clone(),
1359                    from_index,
1360                ));
1361            }
1362            self.move_packet(&packet_id, to_location);
1363        }
1364
1365        // Add orphaned packets to the epoch
1366        if !orphaned_infos.is_empty() {
1367            self._epochs
1368                .get_mut(epoch_id)
1369                .unwrap()
1370                .orphaned_packets
1371                .extend(orphaned_infos);
1372        }
1373
1374        // Emit OutputSalvoTriggered event
1375        net_events.push(NetEvent::OutputSalvoTriggered(
1376            get_utc_now(),
1377            *epoch_id,
1378            salvo_condition_name.clone(),
1379        ));
1380
1381        NetActionResponse::Success(NetActionResponseData::None, net_events)
1382    }
1383
1384    fn transport_packet_to_location(
1385        &mut self,
1386        packet_id: &PacketID,
1387        destination: &PacketLocation,
1388    ) -> NetActionResponse {
1389        // Validate packet exists
1390        let packet = if let Some(p) = self._packets.get(packet_id) {
1391            p
1392        } else {
1393            return NetActionResponse::Error(NetActionError::PacketNotFound {
1394                packet_id: *packet_id,
1395            });
1396        };
1397        let current_location = packet.location.clone();
1398
1399        // Check if moving FROM a running epoch
1400        match &current_location {
1401            PacketLocation::Node(epoch_id) => {
1402                if let Some(epoch) = self._epochs.get(epoch_id)
1403                    && epoch.state == EpochState::Running
1404                {
1405                    return NetActionResponse::Error(
1406                        NetActionError::CannotMovePacketFromRunningEpoch {
1407                            packet_id: *packet_id,
1408                            epoch_id: *epoch_id,
1409                        },
1410                    );
1411                }
1412            }
1413            PacketLocation::OutputPort(epoch_id, _) => {
1414                if let Some(epoch) = self._epochs.get(epoch_id)
1415                    && epoch.state == EpochState::Running
1416                {
1417                    return NetActionResponse::Error(
1418                        NetActionError::CannotMovePacketFromRunningEpoch {
1419                            packet_id: *packet_id,
1420                            epoch_id: *epoch_id,
1421                        },
1422                    );
1423                }
1424            }
1425            _ => {}
1426        }
1427
1428        // Check if moving TO a running epoch
1429        match destination {
1430            PacketLocation::Node(epoch_id) => {
1431                if let Some(epoch) = self._epochs.get(epoch_id) {
1432                    if epoch.state == EpochState::Running {
1433                        return NetActionResponse::Error(
1434                            NetActionError::CannotMovePacketIntoRunningEpoch {
1435                                packet_id: *packet_id,
1436                                epoch_id: *epoch_id,
1437                            },
1438                        );
1439                    }
1440                } else {
1441                    return NetActionResponse::Error(NetActionError::EpochNotFound {
1442                        epoch_id: *epoch_id,
1443                    });
1444                }
1445            }
1446            PacketLocation::OutputPort(epoch_id, port_name) => {
1447                if let Some(epoch) = self._epochs.get(epoch_id) {
1448                    if epoch.state == EpochState::Running {
1449                        return NetActionResponse::Error(
1450                            NetActionError::CannotMovePacketIntoRunningEpoch {
1451                                packet_id: *packet_id,
1452                                epoch_id: *epoch_id,
1453                            },
1454                        );
1455                    }
1456                    // Check that output port exists on the node
1457                    let node = self
1458                        .graph
1459                        .nodes()
1460                        .get(&epoch.node_name)
1461                        .expect("Node associated with epoch could not be found.");
1462                    if !node.out_ports.contains_key(port_name) {
1463                        return NetActionResponse::Error(NetActionError::OutputPortNotFound {
1464                            port_name: port_name.clone(),
1465                            epoch_id: *epoch_id,
1466                        });
1467                    }
1468                } else {
1469                    return NetActionResponse::Error(NetActionError::EpochNotFound {
1470                        epoch_id: *epoch_id,
1471                    });
1472                }
1473            }
1474            PacketLocation::InputPort(node_name, port_name) => {
1475                // Check node exists
1476                let node = if let Some(n) = self.graph.nodes().get(node_name) {
1477                    n
1478                } else {
1479                    return NetActionResponse::Error(NetActionError::NodeNotFound {
1480                        node_name: node_name.clone(),
1481                    });
1482                };
1483                // Check port exists
1484                let port = if let Some(p) = node.in_ports.get(port_name) {
1485                    p
1486                } else {
1487                    return NetActionResponse::Error(NetActionError::InputPortNotFound {
1488                        port_name: port_name.clone(),
1489                        node_name: node_name.clone(),
1490                    });
1491                };
1492                // Check capacity
1493                let current_count = self
1494                    ._packets_by_location
1495                    .get(destination)
1496                    .map(|s| s.len())
1497                    .unwrap_or(0);
1498                let is_full = match &port.slots_spec {
1499                    PortSlotSpec::Infinite => false,
1500                    PortSlotSpec::Finite(capacity) => current_count >= *capacity as usize,
1501                };
1502                if is_full {
1503                    return NetActionResponse::Error(NetActionError::InputPortFull {
1504                        port_name: port_name.clone(),
1505                        node_name: node_name.clone(),
1506                    });
1507                }
1508            }
1509            PacketLocation::Edge(edge) => {
1510                // Check edge exists in graph
1511                if !self.graph.edges().contains(edge) {
1512                    return NetActionResponse::Error(NetActionError::EdgeNotFound {
1513                        edge: edge.clone(),
1514                    });
1515                }
1516            }
1517            PacketLocation::OutsideNet => {
1518                // Always allowed
1519            }
1520        }
1521
1522        // Get the index before moving
1523        let from_index = self
1524            ._packets_by_location
1525            .get(&current_location)
1526            .and_then(|packets| packets.get_index_of(packet_id))
1527            .expect("Packet should exist at current_location");
1528
1529        // Move the packet
1530        self.move_packet(packet_id, destination.clone());
1531
1532        NetActionResponse::Success(
1533            NetActionResponseData::None,
1534            vec![NetEvent::PacketMoved(
1535                get_utc_now(),
1536                *packet_id,
1537                current_location,
1538                destination.clone(),
1539                from_index,
1540            )],
1541        )
1542    }
1543
1544    /// Perform an action on the network.
1545    ///
1546    /// This is the primary way to mutate the network state. All actions produce
1547    /// a response containing either success data and events, or an error.
1548    ///
1549    /// # Example
1550    ///
1551    /// ```
1552    /// use netrun_sim::net::{NetSim, NetAction, NetActionResponse, NetActionResponseData};
1553    /// use netrun_sim::graph::{Graph, Node, Port, PortSlotSpec};
1554    /// use indexmap::IndexMap;
1555    /// use std::collections::HashMap;
1556    ///
1557    /// let node = Node {
1558    ///     name: "A".to_string(),
1559    ///     in_ports: HashMap::new(),
1560    ///     out_ports: HashMap::new(),
1561    ///     in_salvo_conditions: IndexMap::new(),
1562    ///     out_salvo_conditions: IndexMap::new(),
1563    /// };
1564    /// let graph = Graph::new(vec![node], vec![]);
1565    /// let mut net = NetSim::new(graph);
1566    ///
1567    /// // Create a packet outside the network
1568    /// let response = net.do_action(&NetAction::CreatePacket(None));
1569    /// match response {
1570    ///     NetActionResponse::Success(NetActionResponseData::Packet(id), events) => {
1571    ///         println!("Created packet {}", id);
1572    ///     }
1573    ///     _ => panic!("Expected success"),
1574    /// }
1575    /// ```
1576    pub fn do_action(&mut self, action: &NetAction) -> NetActionResponse {
1577        match action {
1578            NetAction::RunStep => self.run_step(),
1579            NetAction::CreatePacket(maybe_epoch_id) => self.create_packet(maybe_epoch_id),
1580            NetAction::ConsumePacket(packet_id) => self.consume_packet(packet_id),
1581            NetAction::DestroyPacket(packet_id) => self.destroy_packet(packet_id),
1582            NetAction::StartEpoch(epoch_id) => self.start_epoch(epoch_id),
1583            NetAction::FinishEpoch(epoch_id) => self.finish_epoch(epoch_id),
1584            NetAction::CancelEpoch(epoch_id) => self.cancel_epoch(epoch_id),
1585            NetAction::CreateEpoch(node_name, salvo) => self.create_epoch(node_name, salvo),
1586            NetAction::LoadPacketIntoOutputPort(packet_id, port_name) => {
1587                self.load_packet_into_output_port(packet_id, port_name)
1588            }
1589            NetAction::SendOutputSalvo(epoch_id, salvo_condition_name) => {
1590                self.send_output_salvo(epoch_id, salvo_condition_name)
1591            }
1592            NetAction::TransportPacketToLocation(packet_id, location) => {
1593                self.transport_packet_to_location(packet_id, location)
1594            }
1595        }
1596    }
1597
1598    // ========== Public Accessors ==========
1599
1600    /// Get the number of packets at a given location.
1601    pub fn packet_count_at(&self, location: &PacketLocation) -> usize {
1602        self._packets_by_location
1603            .get(location)
1604            .map(|s| s.len())
1605            .unwrap_or(0)
1606    }
1607
1608    /// Get all packets at a given location.
1609    pub fn get_packets_at_location(&self, location: &PacketLocation) -> Vec<PacketID> {
1610        self._packets_by_location
1611            .get(location)
1612            .map(|s| s.iter().cloned().collect())
1613            .unwrap_or_default()
1614    }
1615
1616    /// Get an epoch by ID.
1617    pub fn get_epoch(&self, epoch_id: &EpochID) -> Option<&Epoch> {
1618        self._epochs.get(epoch_id)
1619    }
1620
1621    /// Get all startable epoch IDs.
1622    pub fn get_startable_epochs(&self) -> Vec<EpochID> {
1623        self._startable_epochs.iter().cloned().collect()
1624    }
1625
1626    /// Get a packet by ID.
1627    pub fn get_packet(&self, packet_id: &PacketID) -> Option<&Packet> {
1628        self._packets.get(packet_id)
1629    }
1630
1631    /// Run the network until blocked, returning all events that occurred.
1632    ///
1633    /// This is a convenience method that repeatedly calls `RunStep` until no more
1634    /// progress can be made. Equivalent to:
1635    /// ```ignore
1636    /// while !net.is_blocked() {
1637    ///     net.do_action(&NetAction::RunStep);
1638    /// }
1639    /// ```
1640    pub fn run_until_blocked(&mut self) -> Vec<NetEvent> {
1641        let mut all_events = Vec::new();
1642        while !self.is_blocked() {
1643            if let NetActionResponse::Success(_, events) = self.do_action(&NetAction::RunStep) {
1644                all_events.extend(events);
1645            }
1646        }
1647        all_events
1648    }
1649
1650    /// Check if the network is blocked (no progress can be made by RunStep).
1651    ///
1652    /// Returns true if:
1653    /// - No packets can move from edges to input ports (all destinations full or no packets on edges)
1654    /// - No input salvo conditions can be triggered
1655    pub fn is_blocked(&self) -> bool {
1656        // Check Phase 1: Can any packet move from an edge to an input port?
1657        for (location, packets) in &self._packets_by_location {
1658            if let PacketLocation::Edge(edge_ref) = location {
1659                if packets.is_empty() {
1660                    continue;
1661                }
1662
1663                let target_node_name = &edge_ref.target.node_name;
1664                let target_port_name = &edge_ref.target.port_name;
1665
1666                let node = match self.graph.nodes().get(target_node_name) {
1667                    Some(n) => n,
1668                    None => continue,
1669                };
1670                let port = match node.in_ports.get(target_port_name) {
1671                    Some(p) => p,
1672                    None => continue,
1673                };
1674
1675                let input_port_location =
1676                    PacketLocation::InputPort(target_node_name.clone(), target_port_name.clone());
1677                let current_count = self
1678                    ._packets_by_location
1679                    .get(&input_port_location)
1680                    .map(|p| p.len() as u64)
1681                    .unwrap_or(0);
1682
1683                let can_move = match port.slots_spec {
1684                    PortSlotSpec::Infinite => true,
1685                    PortSlotSpec::Finite(max_slots) => current_count < max_slots,
1686                };
1687
1688                if can_move {
1689                    return false; // Not blocked - a packet can move
1690                }
1691            }
1692        }
1693
1694        // Check Phase 2: Can any salvo condition be triggered?
1695        for (location, packets) in &self._packets_by_location {
1696            if let PacketLocation::InputPort(node_name, _) = location {
1697                if packets.is_empty() {
1698                    continue;
1699                }
1700
1701                // Check if any salvo condition on this node can be triggered
1702                if self.can_trigger_input_salvo(node_name) {
1703                    return false; // Not blocked - a salvo condition can trigger
1704                }
1705            }
1706        }
1707
1708        true // Blocked - no progress possible
1709    }
1710
1711    /// Helper: Check if any input salvo condition can be triggered for a node.
1712    fn can_trigger_input_salvo(&self, node_name: &NodeName) -> bool {
1713        let node = match self.graph.nodes().get(node_name) {
1714            Some(n) => n,
1715            None => return false,
1716        };
1717
1718        let in_port_names: Vec<PortName> = node.in_ports.keys().cloned().collect();
1719
1720        // Calculate packet counts for all input ports
1721        let port_packet_counts: HashMap<PortName, u64> = in_port_names
1722            .iter()
1723            .map(|port_name| {
1724                let count = self
1725                    ._packets_by_location
1726                    .get(&PacketLocation::InputPort(
1727                        node_name.clone(),
1728                        port_name.clone(),
1729                    ))
1730                    .map(|packets| packets.len() as u64)
1731                    .unwrap_or(0);
1732                (port_name.clone(), count)
1733            })
1734            .collect();
1735
1736        // Check if any salvo condition is satisfied
1737        for cond in node.in_salvo_conditions.values() {
1738            if evaluate_salvo_condition(&cond.term, &port_packet_counts, &node.in_ports) {
1739                return true;
1740            }
1741        }
1742
1743        false
1744    }
1745
1746    // ========== Undo Implementation ==========
1747
1748    /// Undo a previously executed action.
1749    ///
1750    /// Takes the original action and the events it produced.
1751    /// Returns `Ok(())` on success, or an error if undo is not possible.
1752    ///
1753    /// # Restrictions
1754    /// - Actions must be undone in reverse order (LIFO)
1755    /// - State may have changed since the action (undo may fail)
1756    ///
1757    /// # Example
1758    /// ```ignore
1759    /// let action = NetAction::CreatePacket(None);
1760    /// let response = net.do_action(&action);
1761    /// if let NetActionResponse::Success(_, events) = response {
1762    ///     // Later, to undo:
1763    ///     net.undo_action(&action, &events)?;
1764    /// }
1765    /// ```
1766    pub fn undo_action(
1767        &mut self,
1768        action: &NetAction,
1769        events: &[NetEvent],
1770    ) -> Result<(), UndoError> {
1771        // Process events in reverse order
1772        for event in events.iter().rev() {
1773            self.undo_event(action, event)?;
1774        }
1775        Ok(())
1776    }
1777
1778    /// Undo a single event.
1779    fn undo_event(&mut self, action: &NetAction, event: &NetEvent) -> Result<(), UndoError> {
1780        match event {
1781            NetEvent::PacketCreated(_, packet_id) => self.undo_packet_created(packet_id),
1782            NetEvent::PacketConsumed(_, packet_id, location) => {
1783                self.undo_packet_consumed(packet_id, location)
1784            }
1785            NetEvent::PacketDestroyed(_, packet_id, location) => {
1786                self.undo_packet_destroyed(packet_id, location)
1787            }
1788            NetEvent::EpochCreated(_, epoch_id) => self.undo_epoch_created(epoch_id),
1789            NetEvent::EpochStarted(_, epoch_id) => self.undo_epoch_started(epoch_id),
1790            NetEvent::EpochFinished(_, epoch) => self.undo_epoch_finished(epoch),
1791            NetEvent::EpochCancelled(_, epoch) => self.undo_epoch_cancelled(epoch),
1792            NetEvent::PacketMoved(_, packet_id, from, to, from_index) => {
1793                self.undo_packet_moved(packet_id, from, to, *from_index)
1794            }
1795            NetEvent::InputSalvoTriggered(_, _, _) => {
1796                // Informational only - no state to undo
1797                Ok(())
1798            }
1799            NetEvent::OutputSalvoTriggered(_, epoch_id, _) => {
1800                // Pop the last out_salvo from the epoch
1801                self.undo_output_salvo_triggered(epoch_id, action)
1802            }
1803            NetEvent::PacketOrphaned(_, packet_id, epoch_id, _, port_name, _) => {
1804                // Move packet back from OutsideNet to output port
1805                self.undo_packet_orphaned(packet_id, epoch_id, port_name)
1806            }
1807        }
1808    }
1809
1810    /// Undo PacketCreated: Remove the packet from the network.
1811    fn undo_packet_created(&mut self, packet_id: &PacketID) -> Result<(), UndoError> {
1812        // Get packet's location
1813        let location = match self._packets.get(packet_id) {
1814            Some(p) => p.location.clone(),
1815            None => {
1816                return Err(UndoError::NotFound(format!(
1817                    "packet {} not found",
1818                    packet_id
1819                )));
1820            }
1821        };
1822
1823        // Remove from location index
1824        if let Some(packets) = self._packets_by_location.get_mut(&location) {
1825            packets.shift_remove(packet_id);
1826        }
1827
1828        // Remove from packets map
1829        self._packets.remove(packet_id);
1830
1831        Ok(())
1832    }
1833
1834    /// Undo PacketConsumed: Recreate the packet at its previous location.
1835    fn undo_packet_consumed(
1836        &mut self,
1837        packet_id: &PacketID,
1838        location: &PacketLocation,
1839    ) -> Result<(), UndoError> {
1840        self.recreate_packet(packet_id, location)
1841    }
1842
1843    /// Undo PacketDestroyed: Recreate the packet at its previous location.
1844    fn undo_packet_destroyed(
1845        &mut self,
1846        packet_id: &PacketID,
1847        location: &PacketLocation,
1848    ) -> Result<(), UndoError> {
1849        self.recreate_packet(packet_id, location)
1850    }
1851
1852    /// Helper: Recreate a packet at a given location.
1853    fn recreate_packet(
1854        &mut self,
1855        packet_id: &PacketID,
1856        location: &PacketLocation,
1857    ) -> Result<(), UndoError> {
1858        // Check packet doesn't already exist
1859        if self._packets.contains_key(packet_id) {
1860            return Err(UndoError::StateMismatch(format!(
1861                "packet {} already exists",
1862                packet_id
1863            )));
1864        }
1865
1866        // Create the packet
1867        let packet = Packet {
1868            id: *packet_id,
1869            location: location.clone(),
1870        };
1871        self._packets.insert(*packet_id, packet);
1872
1873        // Add to location index
1874        self._packets_by_location
1875            .entry(location.clone())
1876            .or_default()
1877            .insert(*packet_id);
1878
1879        Ok(())
1880    }
1881
1882    /// Undo EpochCreated: Remove the epoch from all indices.
1883    fn undo_epoch_created(&mut self, epoch_id: &EpochID) -> Result<(), UndoError> {
1884        // Get epoch info before removing
1885        let epoch = match self._epochs.get(epoch_id) {
1886            Some(e) => e.clone(),
1887            None => {
1888                return Err(UndoError::NotFound(format!("epoch {} not found", epoch_id)));
1889            }
1890        };
1891
1892        // Remove from _epochs
1893        self._epochs.remove(epoch_id);
1894
1895        // Remove from _startable_epochs if present
1896        self._startable_epochs.remove(epoch_id);
1897
1898        // Remove from _node_to_epochs (and clean up empty entries)
1899        if let Some(epoch_ids) = self._node_to_epochs.get_mut(&epoch.node_name) {
1900            epoch_ids.retain(|id| id != epoch_id);
1901            // Remove the entry entirely if empty to restore exact state
1902            if epoch_ids.is_empty() {
1903                self._node_to_epochs.remove(&epoch.node_name);
1904            }
1905        }
1906
1907        // Remove location entries for the epoch
1908        let epoch_location = PacketLocation::Node(*epoch_id);
1909        self._packets_by_location.remove(&epoch_location);
1910
1911        // Remove output port location entries
1912        if let Some(node) = self.graph.nodes().get(&epoch.node_name) {
1913            for port_name in node.out_ports.keys() {
1914                let output_port_location = PacketLocation::OutputPort(*epoch_id, port_name.clone());
1915                self._packets_by_location.remove(&output_port_location);
1916            }
1917        }
1918
1919        Ok(())
1920    }
1921
1922    /// Undo EpochStarted: Change state back to Startable, add to _startable_epochs.
1923    fn undo_epoch_started(&mut self, epoch_id: &EpochID) -> Result<(), UndoError> {
1924        let epoch = match self._epochs.get_mut(epoch_id) {
1925            Some(e) => e,
1926            None => {
1927                return Err(UndoError::NotFound(format!("epoch {} not found", epoch_id)));
1928            }
1929        };
1930
1931        // Verify epoch is in Running state
1932        if epoch.state != EpochState::Running {
1933            return Err(UndoError::StateMismatch(format!(
1934                "epoch {} is not in Running state, cannot undo start",
1935                epoch_id
1936            )));
1937        }
1938
1939        // Change state back to Startable
1940        epoch.state = EpochState::Startable;
1941
1942        // Add back to _startable_epochs
1943        self._startable_epochs.insert(*epoch_id);
1944
1945        Ok(())
1946    }
1947
1948    /// Undo EpochFinished: Restore the epoch from the event.
1949    fn undo_epoch_finished(&mut self, epoch: &Epoch) -> Result<(), UndoError> {
1950        let epoch_id = epoch.id;
1951
1952        // Check epoch doesn't already exist
1953        if self._epochs.contains_key(&epoch_id) {
1954            return Err(UndoError::StateMismatch(format!(
1955                "epoch {} already exists",
1956                epoch_id
1957            )));
1958        }
1959
1960        // Restore the epoch with its original state (from before finish)
1961        // Note: epoch in the event captures state before finish (Running)
1962        self._epochs.insert(epoch_id, epoch.clone());
1963
1964        // Recreate location entries
1965        let epoch_location = PacketLocation::Node(epoch_id);
1966        self._packets_by_location
1967            .insert(epoch_location, IndexSet::new());
1968
1969        // Recreate output port location entries
1970        if let Some(node) = self.graph.nodes().get(&epoch.node_name) {
1971            for port_name in node.out_ports.keys() {
1972                let output_port_location = PacketLocation::OutputPort(epoch_id, port_name.clone());
1973                self._packets_by_location
1974                    .insert(output_port_location, IndexSet::new());
1975            }
1976        }
1977
1978        // Add back to _node_to_epochs
1979        self._node_to_epochs
1980            .entry(epoch.node_name.clone())
1981            .or_default()
1982            .push(epoch_id);
1983
1984        Ok(())
1985    }
1986
1987    /// Undo EpochCancelled: Restore the epoch from the event.
1988    /// Note: Packets are restored via PacketDestroyed events (processed in reverse order).
1989    fn undo_epoch_cancelled(&mut self, epoch: &Epoch) -> Result<(), UndoError> {
1990        let epoch_id = epoch.id;
1991
1992        // Check epoch doesn't already exist
1993        if self._epochs.contains_key(&epoch_id) {
1994            return Err(UndoError::StateMismatch(format!(
1995                "epoch {} already exists",
1996                epoch_id
1997            )));
1998        }
1999
2000        // Restore the epoch with its original state
2001        self._epochs.insert(epoch_id, epoch.clone());
2002
2003        // Recreate location entries
2004        let epoch_location = PacketLocation::Node(epoch_id);
2005        self._packets_by_location
2006            .insert(epoch_location, IndexSet::new());
2007
2008        // Recreate output port location entries
2009        if let Some(node) = self.graph.nodes().get(&epoch.node_name) {
2010            for port_name in node.out_ports.keys() {
2011                let output_port_location = PacketLocation::OutputPort(epoch_id, port_name.clone());
2012                self._packets_by_location
2013                    .insert(output_port_location, IndexSet::new());
2014            }
2015        }
2016
2017        // Add back to _node_to_epochs
2018        self._node_to_epochs
2019            .entry(epoch.node_name.clone())
2020            .or_default()
2021            .push(epoch_id);
2022
2023        // If epoch was startable, add to _startable_epochs
2024        if epoch.state == EpochState::Startable {
2025            self._startable_epochs.insert(epoch_id);
2026        }
2027
2028        Ok(())
2029    }
2030
2031    /// Undo PacketMoved: Move packet back from `to` to `from` at `from_index`.
2032    fn undo_packet_moved(
2033        &mut self,
2034        packet_id: &PacketID,
2035        from: &PacketLocation,
2036        to: &PacketLocation,
2037        from_index: usize,
2038    ) -> Result<(), UndoError> {
2039        // Verify packet exists and is at `to` location
2040        let packet = match self._packets.get(packet_id) {
2041            Some(p) => p,
2042            None => {
2043                return Err(UndoError::NotFound(format!(
2044                    "packet {} not found",
2045                    packet_id
2046                )));
2047            }
2048        };
2049
2050        if packet.location != *to {
2051            return Err(UndoError::StateMismatch(format!(
2052                "packet {} is not at expected location {:?}, found at {:?}",
2053                packet_id, to, packet.location
2054            )));
2055        }
2056
2057        // Remove from `to` location
2058        if let Some(packets) = self._packets_by_location.get_mut(to) {
2059            packets.shift_remove(packet_id);
2060        }
2061
2062        // Insert back into `from` at original index using shift_insert
2063        let packets_at_from = self._packets_by_location.entry(from.clone()).or_default();
2064        packets_at_from.shift_insert(from_index, *packet_id);
2065
2066        // Update packet's location
2067        self._packets.get_mut(packet_id).unwrap().location = from.clone();
2068
2069        Ok(())
2070    }
2071
2072    /// Undo OutputSalvoTriggered: Pop the last out_salvo from the epoch and clear orphaned packets.
2073    fn undo_output_salvo_triggered(
2074        &mut self,
2075        epoch_id: &EpochID,
2076        action: &NetAction,
2077    ) -> Result<(), UndoError> {
2078        // Only pop out_salvo for SendOutputSalvo action
2079        // For RunStep, salvo info isn't stored in out_salvos
2080        if !matches!(action, NetAction::SendOutputSalvo(_, _)) {
2081            return Ok(());
2082        }
2083
2084        let epoch = match self._epochs.get_mut(epoch_id) {
2085            Some(e) => e,
2086            None => {
2087                return Err(UndoError::NotFound(format!("epoch {} not found", epoch_id)));
2088            }
2089        };
2090
2091        // Pop the last out_salvo
2092        if epoch.out_salvos.pop().is_none() {
2093            return Err(UndoError::StateMismatch(format!(
2094                "epoch {} has no out_salvos to pop",
2095                epoch_id
2096            )));
2097        }
2098
2099        // Note: orphaned_packets are removed via undo_packet_orphaned (called for each PacketOrphaned event)
2100
2101        Ok(())
2102    }
2103
2104    /// Undo PacketOrphaned: Move packet back from OutsideNet to output port.
2105    fn undo_packet_orphaned(
2106        &mut self,
2107        packet_id: &PacketID,
2108        epoch_id: &EpochID,
2109        port_name: &PortName,
2110    ) -> Result<(), UndoError> {
2111        // Verify packet exists and is at OutsideNet
2112        let packet = match self._packets.get(packet_id) {
2113            Some(p) => p,
2114            None => {
2115                return Err(UndoError::NotFound(format!(
2116                    "packet {} not found",
2117                    packet_id
2118                )));
2119            }
2120        };
2121
2122        if packet.location != PacketLocation::OutsideNet {
2123            return Err(UndoError::StateMismatch(format!(
2124                "packet {} is not at OutsideNet, found at {:?}",
2125                packet_id, packet.location
2126            )));
2127        }
2128
2129        // Remove from OutsideNet
2130        if let Some(packets) = self
2131            ._packets_by_location
2132            .get_mut(&PacketLocation::OutsideNet)
2133        {
2134            packets.shift_remove(packet_id);
2135        }
2136
2137        // Move back to output port
2138        let output_port_location = PacketLocation::OutputPort(*epoch_id, port_name.clone());
2139        self._packets_by_location
2140            .entry(output_port_location.clone())
2141            .or_default()
2142            .insert(*packet_id);
2143
2144        // Update packet's location
2145        self._packets.get_mut(packet_id).unwrap().location = output_port_location;
2146
2147        // Remove from epoch's orphaned_packets list
2148        if let Some(epoch) = self._epochs.get_mut(epoch_id) {
2149            epoch
2150                .orphaned_packets
2151                .retain(|info| info.packet_id != *packet_id);
2152        }
2153
2154        Ok(())
2155    }
2156
2157    // ========== Internal Test Helpers ==========
2158
2159    #[cfg(test)]
2160    pub fn startable_epoch_ids(&self) -> Vec<EpochID> {
2161        self.get_startable_epochs()
2162    }
2163}
2164
2165#[cfg(test)]
2166#[path = "net_tests.rs"]
2167mod tests;