Skip to main content

cobre_core/
system.rs

1//! Top-level system struct and builder.
2//!
3//! The `System` struct is the top-level in-memory representation of a fully loaded,
4//! validated, and resolved case. It is produced by `cobre-io::load_case()` and consumed
5//! by solvers and analysis tools (e.g., optimization, simulation, power flow).
6//!
7//! All entity collections in `System` are stored in canonical ID-sorted order to ensure
8//! declaration-order invariance: results are bit-for-bit identical regardless of input
9//! entity ordering. See the design principles spec for details.
10
11use std::collections::{HashMap, HashSet};
12
13use crate::{
14    Bus, CascadeTopology, CorrelationModel, EnergyContract, EntityId, GenericConstraint, Hydro,
15    InflowModel, InitialConditions, Line, LoadModel, NetworkTopology, NonControllableSource,
16    PolicyGraph, PumpingStation, ResolvedBounds, ResolvedPenalties, ScenarioSource, Stage, Thermal,
17    ValidationError,
18};
19
20/// Top-level system representation.
21///
22/// Produced by `cobre-io::load_case()` or [`SystemBuilder`] in tests.
23/// Consumed by solvers and analysis tools via shared reference.
24/// Immutable and thread-safe after construction.
25///
26/// Entity collections are in canonical order (sorted by [`EntityId`]'s inner `i32`).
27/// Lookup indices provide O(1) access by [`EntityId`].
28///
29/// # Examples
30///
31/// ```
32/// use cobre_core::{Bus, DeficitSegment, EntityId, SystemBuilder};
33///
34/// let bus = Bus {
35///     id: EntityId(1),
36///     name: "Main Bus".to_string(),
37///     deficit_segments: vec![],
38///     excess_cost: 0.0,
39/// };
40///
41/// let system = SystemBuilder::new()
42///     .buses(vec![bus])
43///     .build()
44///     .expect("valid system");
45///
46/// assert_eq!(system.n_buses(), 1);
47/// assert!(system.bus(EntityId(1)).is_some());
48/// ```
49#[derive(Debug, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51pub struct System {
52    // Entity collections (canonical ordering by ID)
53    buses: Vec<Bus>,
54    lines: Vec<Line>,
55    hydros: Vec<Hydro>,
56    thermals: Vec<Thermal>,
57    pumping_stations: Vec<PumpingStation>,
58    contracts: Vec<EnergyContract>,
59    non_controllable_sources: Vec<NonControllableSource>,
60
61    // O(1) lookup indices (entity ID -> position in collection) -- private.
62    // Per spec SS6.2: HashMap lookup indices are NOT serialized. After deserialization
63    // the caller must invoke `rebuild_indices()` to restore O(1) lookup capability.
64    #[cfg_attr(feature = "serde", serde(skip))]
65    bus_index: HashMap<EntityId, usize>,
66    #[cfg_attr(feature = "serde", serde(skip))]
67    line_index: HashMap<EntityId, usize>,
68    #[cfg_attr(feature = "serde", serde(skip))]
69    hydro_index: HashMap<EntityId, usize>,
70    #[cfg_attr(feature = "serde", serde(skip))]
71    thermal_index: HashMap<EntityId, usize>,
72    #[cfg_attr(feature = "serde", serde(skip))]
73    pumping_station_index: HashMap<EntityId, usize>,
74    #[cfg_attr(feature = "serde", serde(skip))]
75    contract_index: HashMap<EntityId, usize>,
76    #[cfg_attr(feature = "serde", serde(skip))]
77    non_controllable_source_index: HashMap<EntityId, usize>,
78
79    // Topology
80    /// Resolved hydro cascade graph.
81    cascade: CascadeTopology,
82    /// Resolved transmission network topology.
83    network: NetworkTopology,
84
85    // Temporal domain
86    /// Ordered list of stages (study + pre-study), sorted by `id` (canonical order).
87    stages: Vec<Stage>,
88    /// Policy graph defining stage transitions, horizon type, and discount rate.
89    policy_graph: PolicyGraph,
90
91    // Stage O(1) lookup index (stage ID -> position in stages vec).
92    // Stage IDs are `i32` (pre-study stages have negative IDs).
93    // Not serialized; rebuilt via `rebuild_indices()`.
94    #[cfg_attr(feature = "serde", serde(skip))]
95    stage_index: HashMap<i32, usize>,
96
97    // Resolved tables (populated by cobre-io after penalty/bound cascade)
98    /// Pre-resolved penalty values for all entities across all stages.
99    penalties: ResolvedPenalties,
100    /// Pre-resolved bound values for all entities across all stages.
101    bounds: ResolvedBounds,
102
103    // Scenario pipeline data (raw parameters loaded by cobre-io)
104    /// PAR(p) inflow model parameters, one entry per (hydro, stage) pair.
105    inflow_models: Vec<InflowModel>,
106    /// Seasonal load statistics, one entry per (bus, stage) pair.
107    load_models: Vec<LoadModel>,
108    /// Correlation model for stochastic inflow/load generation.
109    correlation: CorrelationModel,
110
111    // Study state
112    /// Initial reservoir storage levels at the start of the study.
113    initial_conditions: InitialConditions,
114    /// User-defined generic linear constraints, sorted by `id`.
115    generic_constraints: Vec<GenericConstraint>,
116    /// Top-level scenario source configuration (sampling scheme, seed).
117    scenario_source: ScenarioSource,
118}
119
120// Compile-time check that System is Send + Sync.
121const _: () = {
122    const fn assert_send_sync<T: Send + Sync>() {}
123    const fn check() {
124        assert_send_sync::<System>();
125    }
126    let _ = check;
127};
128
129impl System {
130    /// Returns all buses in canonical ID order.
131    #[must_use]
132    pub fn buses(&self) -> &[Bus] {
133        &self.buses
134    }
135
136    /// Returns all lines in canonical ID order.
137    #[must_use]
138    pub fn lines(&self) -> &[Line] {
139        &self.lines
140    }
141
142    /// Returns all hydro plants in canonical ID order.
143    #[must_use]
144    pub fn hydros(&self) -> &[Hydro] {
145        &self.hydros
146    }
147
148    /// Returns all thermal plants in canonical ID order.
149    #[must_use]
150    pub fn thermals(&self) -> &[Thermal] {
151        &self.thermals
152    }
153
154    /// Returns all pumping stations in canonical ID order.
155    #[must_use]
156    pub fn pumping_stations(&self) -> &[PumpingStation] {
157        &self.pumping_stations
158    }
159
160    /// Returns all energy contracts in canonical ID order.
161    #[must_use]
162    pub fn contracts(&self) -> &[EnergyContract] {
163        &self.contracts
164    }
165
166    /// Returns all non-controllable sources in canonical ID order.
167    #[must_use]
168    pub fn non_controllable_sources(&self) -> &[NonControllableSource] {
169        &self.non_controllable_sources
170    }
171
172    /// Returns the number of buses in the system.
173    #[must_use]
174    pub fn n_buses(&self) -> usize {
175        self.buses.len()
176    }
177
178    /// Returns the number of lines in the system.
179    #[must_use]
180    pub fn n_lines(&self) -> usize {
181        self.lines.len()
182    }
183
184    /// Returns the number of hydro plants in the system.
185    #[must_use]
186    pub fn n_hydros(&self) -> usize {
187        self.hydros.len()
188    }
189
190    /// Returns the number of thermal plants in the system.
191    #[must_use]
192    pub fn n_thermals(&self) -> usize {
193        self.thermals.len()
194    }
195
196    /// Returns the number of pumping stations in the system.
197    #[must_use]
198    pub fn n_pumping_stations(&self) -> usize {
199        self.pumping_stations.len()
200    }
201
202    /// Returns the number of energy contracts in the system.
203    #[must_use]
204    pub fn n_contracts(&self) -> usize {
205        self.contracts.len()
206    }
207
208    /// Returns the number of non-controllable sources in the system.
209    #[must_use]
210    pub fn n_non_controllable_sources(&self) -> usize {
211        self.non_controllable_sources.len()
212    }
213
214    /// Returns the bus with the given ID, or `None` if not found.
215    #[must_use]
216    pub fn bus(&self, id: EntityId) -> Option<&Bus> {
217        self.bus_index.get(&id).map(|&i| &self.buses[i])
218    }
219
220    /// Returns the line with the given ID, or `None` if not found.
221    #[must_use]
222    pub fn line(&self, id: EntityId) -> Option<&Line> {
223        self.line_index.get(&id).map(|&i| &self.lines[i])
224    }
225
226    /// Returns the hydro plant with the given ID, or `None` if not found.
227    #[must_use]
228    pub fn hydro(&self, id: EntityId) -> Option<&Hydro> {
229        self.hydro_index.get(&id).map(|&i| &self.hydros[i])
230    }
231
232    /// Returns the thermal plant with the given ID, or `None` if not found.
233    #[must_use]
234    pub fn thermal(&self, id: EntityId) -> Option<&Thermal> {
235        self.thermal_index.get(&id).map(|&i| &self.thermals[i])
236    }
237
238    /// Returns the pumping station with the given ID, or `None` if not found.
239    #[must_use]
240    pub fn pumping_station(&self, id: EntityId) -> Option<&PumpingStation> {
241        self.pumping_station_index
242            .get(&id)
243            .map(|&i| &self.pumping_stations[i])
244    }
245
246    /// Returns the energy contract with the given ID, or `None` if not found.
247    #[must_use]
248    pub fn contract(&self, id: EntityId) -> Option<&EnergyContract> {
249        self.contract_index.get(&id).map(|&i| &self.contracts[i])
250    }
251
252    /// Returns the non-controllable source with the given ID, or `None` if not found.
253    #[must_use]
254    pub fn non_controllable_source(&self, id: EntityId) -> Option<&NonControllableSource> {
255        self.non_controllable_source_index
256            .get(&id)
257            .map(|&i| &self.non_controllable_sources[i])
258    }
259
260    /// Returns a reference to the hydro cascade topology.
261    #[must_use]
262    pub fn cascade(&self) -> &CascadeTopology {
263        &self.cascade
264    }
265
266    /// Returns a reference to the transmission network topology.
267    #[must_use]
268    pub fn network(&self) -> &NetworkTopology {
269        &self.network
270    }
271
272    /// Returns all stages in canonical ID order (study and pre-study stages).
273    #[must_use]
274    pub fn stages(&self) -> &[Stage] {
275        &self.stages
276    }
277
278    /// Returns the number of stages (study and pre-study) in the system.
279    #[must_use]
280    pub fn n_stages(&self) -> usize {
281        self.stages.len()
282    }
283
284    /// Returns the stage with the given stage ID, or `None` if not found.
285    ///
286    /// Stage IDs are `i32`. Study stages have non-negative IDs; pre-study
287    /// stages (used only for PAR model lag initialization) have negative IDs.
288    #[must_use]
289    pub fn stage(&self, id: i32) -> Option<&Stage> {
290        self.stage_index.get(&id).map(|&i| &self.stages[i])
291    }
292
293    /// Returns a reference to the policy graph.
294    #[must_use]
295    pub fn policy_graph(&self) -> &PolicyGraph {
296        &self.policy_graph
297    }
298
299    /// Returns a reference to the pre-resolved penalty table.
300    #[must_use]
301    pub fn penalties(&self) -> &ResolvedPenalties {
302        &self.penalties
303    }
304
305    /// Returns a reference to the pre-resolved bounds table.
306    #[must_use]
307    pub fn bounds(&self) -> &ResolvedBounds {
308        &self.bounds
309    }
310
311    /// Returns all PAR(p) inflow models in canonical order (by hydro ID, then stage ID).
312    #[must_use]
313    pub fn inflow_models(&self) -> &[InflowModel] {
314        &self.inflow_models
315    }
316
317    /// Returns all load models in canonical order (by bus ID, then stage ID).
318    #[must_use]
319    pub fn load_models(&self) -> &[LoadModel] {
320        &self.load_models
321    }
322
323    /// Returns a reference to the correlation model.
324    #[must_use]
325    pub fn correlation(&self) -> &CorrelationModel {
326        &self.correlation
327    }
328
329    /// Returns a reference to the initial conditions.
330    #[must_use]
331    pub fn initial_conditions(&self) -> &InitialConditions {
332        &self.initial_conditions
333    }
334
335    /// Returns all generic constraints in canonical ID order.
336    #[must_use]
337    pub fn generic_constraints(&self) -> &[GenericConstraint] {
338        &self.generic_constraints
339    }
340
341    /// Returns a reference to the scenario source configuration.
342    #[must_use]
343    pub fn scenario_source(&self) -> &ScenarioSource {
344        &self.scenario_source
345    }
346
347    /// Replace the scenario models and correlation on this `System`, returning a new
348    /// `System` with updated fields and all other fields preserved.
349    ///
350    /// This is the only supported way to update `inflow_models` and `correlation`
351    /// after a `System` has been constructed — the fields are not public outside
352    /// this crate. All entity collections, topology, stages, penalties, bounds, and
353    /// study state are preserved unchanged.
354    ///
355    /// # Examples
356    ///
357    /// ```
358    /// use cobre_core::{EntityId, SystemBuilder};
359    /// use cobre_core::scenario::{InflowModel, CorrelationModel};
360    ///
361    /// let system = SystemBuilder::new().build().expect("valid system");
362    /// let model = InflowModel {
363    ///     hydro_id: EntityId(1),
364    ///     stage_id: 0,
365    ///     mean_m3s: 100.0,
366    ///     std_m3s: 10.0,
367    ///     ar_coefficients: vec![],
368    ///     residual_std_ratio: 1.0,
369    /// };
370    /// let updated = system.with_scenario_models(vec![model], CorrelationModel::default());
371    /// assert_eq!(updated.inflow_models().len(), 1);
372    /// ```
373    #[must_use]
374    pub fn with_scenario_models(
375        mut self,
376        inflow_models: Vec<InflowModel>,
377        correlation: CorrelationModel,
378    ) -> Self {
379        self.inflow_models = inflow_models;
380        self.correlation = correlation;
381        self
382    }
383
384    /// Rebuild all O(1) lookup indices from the entity collections.
385    ///
386    /// Required after deserialization: the `HashMap` lookup indices are not serialized
387    /// (per spec SS6.2 — they are derived from the entity collections). After
388    /// deserializing a `System` from JSON or any other format, call this method once
389    /// to restore O(1) access via [`bus`](Self::bus), [`hydro`](Self::hydro), etc.
390    ///
391    /// # Examples
392    ///
393    /// ```
394    /// # #[cfg(feature = "serde")]
395    /// # {
396    /// use cobre_core::{Bus, DeficitSegment, EntityId, SystemBuilder};
397    ///
398    /// let system = SystemBuilder::new()
399    ///     .buses(vec![Bus {
400    ///         id: EntityId(1),
401    ///         name: "A".to_string(),
402    ///         deficit_segments: vec![],
403    ///         excess_cost: 0.0,
404    ///     }])
405    ///     .build()
406    ///     .expect("valid system");
407    ///
408    /// let json = serde_json::to_string(&system).unwrap();
409    /// let mut deserialized: cobre_core::System = serde_json::from_str(&json).unwrap();
410    /// deserialized.rebuild_indices();
411    ///
412    /// // O(1) lookup now works after index rebuild.
413    /// assert!(deserialized.bus(EntityId(1)).is_some());
414    /// # }
415    /// ```
416    pub fn rebuild_indices(&mut self) {
417        self.bus_index = build_index(&self.buses);
418        self.line_index = build_index(&self.lines);
419        self.hydro_index = build_index(&self.hydros);
420        self.thermal_index = build_index(&self.thermals);
421        self.pumping_station_index = build_index(&self.pumping_stations);
422        self.contract_index = build_index(&self.contracts);
423        self.non_controllable_source_index = build_index(&self.non_controllable_sources);
424        self.stage_index = build_stage_index(&self.stages);
425    }
426}
427
428/// Builder for constructing a validated, immutable [`System`].
429///
430/// Accepts entity collections, sorts entities by ID, checks for duplicate IDs,
431/// builds topology, and returns the [`System`]. All entity collections default to
432/// empty; only supply the collections your test case requires.
433///
434/// # Examples
435///
436/// ```
437/// use cobre_core::{Bus, DeficitSegment, EntityId, SystemBuilder};
438///
439/// let system = SystemBuilder::new()
440///     .buses(vec![
441///         Bus { id: EntityId(2), name: "B".to_string(), deficit_segments: vec![], excess_cost: 0.0 },
442///         Bus { id: EntityId(1), name: "A".to_string(), deficit_segments: vec![], excess_cost: 0.0 },
443///     ])
444///     .build()
445///     .expect("valid system");
446///
447/// // Canonical ordering: id=1 comes before id=2.
448/// assert_eq!(system.buses()[0].id, EntityId(1));
449/// assert_eq!(system.buses()[1].id, EntityId(2));
450/// ```
451pub struct SystemBuilder {
452    buses: Vec<Bus>,
453    lines: Vec<Line>,
454    hydros: Vec<Hydro>,
455    thermals: Vec<Thermal>,
456    pumping_stations: Vec<PumpingStation>,
457    contracts: Vec<EnergyContract>,
458    non_controllable_sources: Vec<NonControllableSource>,
459    // New fields from tickets 004-007
460    stages: Vec<Stage>,
461    policy_graph: PolicyGraph,
462    penalties: ResolvedPenalties,
463    bounds: ResolvedBounds,
464    inflow_models: Vec<InflowModel>,
465    load_models: Vec<LoadModel>,
466    correlation: CorrelationModel,
467    initial_conditions: InitialConditions,
468    generic_constraints: Vec<GenericConstraint>,
469    scenario_source: ScenarioSource,
470}
471
472impl Default for SystemBuilder {
473    fn default() -> Self {
474        Self::new()
475    }
476}
477
478impl SystemBuilder {
479    /// Create a new empty builder. All entity collections start empty.
480    ///
481    /// New fields default to empty/default values so that
482    /// pre-existing tests continue to work without modification.
483    #[must_use]
484    pub fn new() -> Self {
485        Self {
486            buses: Vec::new(),
487            lines: Vec::new(),
488            hydros: Vec::new(),
489            thermals: Vec::new(),
490            pumping_stations: Vec::new(),
491            contracts: Vec::new(),
492            non_controllable_sources: Vec::new(),
493            stages: Vec::new(),
494            policy_graph: PolicyGraph::default(),
495            penalties: ResolvedPenalties::empty(),
496            bounds: ResolvedBounds::empty(),
497            inflow_models: Vec::new(),
498            load_models: Vec::new(),
499            correlation: CorrelationModel::default(),
500            initial_conditions: InitialConditions::default(),
501            generic_constraints: Vec::new(),
502            scenario_source: ScenarioSource::default(),
503        }
504    }
505
506    /// Set the bus collection.
507    #[must_use]
508    pub fn buses(mut self, buses: Vec<Bus>) -> Self {
509        self.buses = buses;
510        self
511    }
512
513    /// Set the line collection.
514    #[must_use]
515    pub fn lines(mut self, lines: Vec<Line>) -> Self {
516        self.lines = lines;
517        self
518    }
519
520    /// Set the hydro plant collection.
521    #[must_use]
522    pub fn hydros(mut self, hydros: Vec<Hydro>) -> Self {
523        self.hydros = hydros;
524        self
525    }
526
527    /// Set the thermal plant collection.
528    #[must_use]
529    pub fn thermals(mut self, thermals: Vec<Thermal>) -> Self {
530        self.thermals = thermals;
531        self
532    }
533
534    /// Set the pumping station collection.
535    #[must_use]
536    pub fn pumping_stations(mut self, stations: Vec<PumpingStation>) -> Self {
537        self.pumping_stations = stations;
538        self
539    }
540
541    /// Set the energy contract collection.
542    #[must_use]
543    pub fn contracts(mut self, contracts: Vec<EnergyContract>) -> Self {
544        self.contracts = contracts;
545        self
546    }
547
548    /// Set the non-controllable source collection.
549    #[must_use]
550    pub fn non_controllable_sources(mut self, sources: Vec<NonControllableSource>) -> Self {
551        self.non_controllable_sources = sources;
552        self
553    }
554
555    /// Set the stage collection (study and pre-study stages).
556    ///
557    /// Stages are sorted by `id` in [`build`](Self::build) to canonical order.
558    #[must_use]
559    pub fn stages(mut self, stages: Vec<Stage>) -> Self {
560        self.stages = stages;
561        self
562    }
563
564    /// Set the policy graph.
565    #[must_use]
566    pub fn policy_graph(mut self, policy_graph: PolicyGraph) -> Self {
567        self.policy_graph = policy_graph;
568        self
569    }
570
571    /// Set the pre-resolved penalty table.
572    ///
573    /// Populated by `cobre-io` after the three-tier penalty cascade is applied.
574    #[must_use]
575    pub fn penalties(mut self, penalties: ResolvedPenalties) -> Self {
576        self.penalties = penalties;
577        self
578    }
579
580    /// Set the pre-resolved bounds table.
581    ///
582    /// Populated by `cobre-io` after base bounds are overlaid with stage overrides.
583    #[must_use]
584    pub fn bounds(mut self, bounds: ResolvedBounds) -> Self {
585        self.bounds = bounds;
586        self
587    }
588
589    /// Set the PAR(p) inflow model collection.
590    #[must_use]
591    pub fn inflow_models(mut self, inflow_models: Vec<InflowModel>) -> Self {
592        self.inflow_models = inflow_models;
593        self
594    }
595
596    /// Set the load model collection.
597    #[must_use]
598    pub fn load_models(mut self, load_models: Vec<LoadModel>) -> Self {
599        self.load_models = load_models;
600        self
601    }
602
603    /// Set the correlation model.
604    #[must_use]
605    pub fn correlation(mut self, correlation: CorrelationModel) -> Self {
606        self.correlation = correlation;
607        self
608    }
609
610    /// Set the initial conditions.
611    #[must_use]
612    pub fn initial_conditions(mut self, initial_conditions: InitialConditions) -> Self {
613        self.initial_conditions = initial_conditions;
614        self
615    }
616
617    /// Set the generic constraint collection.
618    ///
619    /// Constraints are sorted by `id` in [`build`](Self::build) to canonical order.
620    #[must_use]
621    pub fn generic_constraints(mut self, generic_constraints: Vec<GenericConstraint>) -> Self {
622        self.generic_constraints = generic_constraints;
623        self
624    }
625
626    /// Set the scenario source configuration.
627    #[must_use]
628    pub fn scenario_source(mut self, scenario_source: ScenarioSource) -> Self {
629        self.scenario_source = scenario_source;
630        self
631    }
632
633    /// Build the [`System`].
634    ///
635    /// Sorts all entity collections by [`EntityId`] (canonical ordering).
636    /// Checks for duplicate IDs within each collection.
637    /// Validates all cross-reference fields (e.g., `bus_id`, `downstream_id`) against
638    /// the appropriate index to ensure every referenced entity exists.
639    /// Builds [`CascadeTopology`] and [`NetworkTopology`].
640    /// Validates the cascade graph for cycles and checks hydro filling configurations.
641    /// Constructs lookup indices.
642    ///
643    /// Returns `Err` with a list of all validation errors found across all collections.
644    /// All invalid references across all entity types are collected before returning —
645    /// no short-circuiting on first error.
646    ///
647    /// # Errors
648    ///
649    /// Returns `Err(Vec<ValidationError>)` if:
650    /// - Duplicate IDs are detected in any entity collection.
651    /// - Any cross-reference field refers to an entity ID that does not exist.
652    /// - The hydro cascade graph contains a cycle.
653    /// - Any hydro filling configuration is invalid (non-positive inflow or missing
654    ///   `entry_stage_id`).
655    ///
656    /// All errors across all collections are reported together.
657    pub fn build(mut self) -> Result<System, Vec<ValidationError>> {
658        self.buses.sort_by_key(|e| e.id.0);
659        self.lines.sort_by_key(|e| e.id.0);
660        self.hydros.sort_by_key(|e| e.id.0);
661        self.thermals.sort_by_key(|e| e.id.0);
662        self.pumping_stations.sort_by_key(|e| e.id.0);
663        self.contracts.sort_by_key(|e| e.id.0);
664        self.non_controllable_sources.sort_by_key(|e| e.id.0);
665        self.stages.sort_by_key(|s| s.id);
666        self.generic_constraints.sort_by_key(|c| c.id.0);
667
668        let mut errors: Vec<ValidationError> = Vec::new();
669        check_duplicates(&self.buses, "Bus", &mut errors);
670        check_duplicates(&self.lines, "Line", &mut errors);
671        check_duplicates(&self.hydros, "Hydro", &mut errors);
672        check_duplicates(&self.thermals, "Thermal", &mut errors);
673        check_duplicates(&self.pumping_stations, "PumpingStation", &mut errors);
674        check_duplicates(&self.contracts, "EnergyContract", &mut errors);
675        check_duplicates(
676            &self.non_controllable_sources,
677            "NonControllableSource",
678            &mut errors,
679        );
680
681        if !errors.is_empty() {
682            return Err(errors);
683        }
684
685        let bus_index = build_index(&self.buses);
686        let line_index = build_index(&self.lines);
687        let hydro_index = build_index(&self.hydros);
688        let thermal_index = build_index(&self.thermals);
689        let pumping_station_index = build_index(&self.pumping_stations);
690        let contract_index = build_index(&self.contracts);
691        let non_controllable_source_index = build_index(&self.non_controllable_sources);
692
693        validate_cross_references(
694            &self.lines,
695            &self.hydros,
696            &self.thermals,
697            &self.pumping_stations,
698            &self.contracts,
699            &self.non_controllable_sources,
700            &bus_index,
701            &hydro_index,
702            &mut errors,
703        );
704
705        if !errors.is_empty() {
706            return Err(errors);
707        }
708
709        let cascade = CascadeTopology::build(&self.hydros);
710
711        if cascade.topological_order().len() < self.hydros.len() {
712            let in_topo: HashSet<EntityId> = cascade.topological_order().iter().copied().collect();
713            let mut cycle_ids: Vec<EntityId> = self
714                .hydros
715                .iter()
716                .map(|h| h.id)
717                .filter(|id| !in_topo.contains(id))
718                .collect();
719            cycle_ids.sort_by_key(|id| id.0);
720            errors.push(ValidationError::CascadeCycle { cycle_ids });
721        }
722
723        validate_filling_configs(&self.hydros, &mut errors);
724
725        if !errors.is_empty() {
726            return Err(errors);
727        }
728
729        let network = NetworkTopology::build(
730            &self.buses,
731            &self.lines,
732            &self.hydros,
733            &self.thermals,
734            &self.non_controllable_sources,
735            &self.contracts,
736            &self.pumping_stations,
737        );
738
739        let stage_index = build_stage_index(&self.stages);
740
741        Ok(System {
742            buses: self.buses,
743            lines: self.lines,
744            hydros: self.hydros,
745            thermals: self.thermals,
746            pumping_stations: self.pumping_stations,
747            contracts: self.contracts,
748            non_controllable_sources: self.non_controllable_sources,
749            bus_index,
750            line_index,
751            hydro_index,
752            thermal_index,
753            pumping_station_index,
754            contract_index,
755            non_controllable_source_index,
756            cascade,
757            network,
758            stages: self.stages,
759            policy_graph: self.policy_graph,
760            stage_index,
761            penalties: self.penalties,
762            bounds: self.bounds,
763            inflow_models: self.inflow_models,
764            load_models: self.load_models,
765            correlation: self.correlation,
766            initial_conditions: self.initial_conditions,
767            generic_constraints: self.generic_constraints,
768            scenario_source: self.scenario_source,
769        })
770    }
771}
772
773trait HasId {
774    fn entity_id(&self) -> EntityId;
775}
776
777impl HasId for Bus {
778    fn entity_id(&self) -> EntityId {
779        self.id
780    }
781}
782impl HasId for Line {
783    fn entity_id(&self) -> EntityId {
784        self.id
785    }
786}
787impl HasId for Hydro {
788    fn entity_id(&self) -> EntityId {
789        self.id
790    }
791}
792impl HasId for Thermal {
793    fn entity_id(&self) -> EntityId {
794        self.id
795    }
796}
797impl HasId for PumpingStation {
798    fn entity_id(&self) -> EntityId {
799        self.id
800    }
801}
802impl HasId for EnergyContract {
803    fn entity_id(&self) -> EntityId {
804        self.id
805    }
806}
807impl HasId for NonControllableSource {
808    fn entity_id(&self) -> EntityId {
809        self.id
810    }
811}
812
813fn build_index<T: HasId>(entities: &[T]) -> HashMap<EntityId, usize> {
814    let mut index = HashMap::with_capacity(entities.len());
815    for (i, entity) in entities.iter().enumerate() {
816        index.insert(entity.entity_id(), i);
817    }
818    index
819}
820
821/// Build a stage lookup index from the canonical-ordered stages vec.
822///
823/// Keys are `i32` stage IDs (which can be negative for pre-study stages).
824fn build_stage_index(stages: &[Stage]) -> HashMap<i32, usize> {
825    let mut index = HashMap::with_capacity(stages.len());
826    for (i, stage) in stages.iter().enumerate() {
827        index.insert(stage.id, i);
828    }
829    index
830}
831
832fn check_duplicates<T: HasId>(
833    entities: &[T],
834    entity_type: &'static str,
835    errors: &mut Vec<ValidationError>,
836) {
837    for window in entities.windows(2) {
838        if window[0].entity_id() == window[1].entity_id() {
839            errors.push(ValidationError::DuplicateId {
840                entity_type,
841                id: window[0].entity_id(),
842            });
843        }
844    }
845}
846
847/// Validate all cross-reference fields across entity collections.
848///
849/// Checks every entity field that references another entity by [`EntityId`]
850/// against the appropriate index. All invalid references are appended to
851/// `errors` — no short-circuiting on first error.
852///
853/// This function runs after duplicate checking passes and after indices are
854/// built, but before topology construction.
855#[allow(clippy::too_many_arguments)]
856fn validate_cross_references(
857    lines: &[Line],
858    hydros: &[Hydro],
859    thermals: &[Thermal],
860    pumping_stations: &[PumpingStation],
861    contracts: &[EnergyContract],
862    non_controllable_sources: &[NonControllableSource],
863    bus_index: &HashMap<EntityId, usize>,
864    hydro_index: &HashMap<EntityId, usize>,
865    errors: &mut Vec<ValidationError>,
866) {
867    validate_line_refs(lines, bus_index, errors);
868    validate_hydro_refs(hydros, bus_index, hydro_index, errors);
869    validate_thermal_refs(thermals, bus_index, errors);
870    validate_pumping_station_refs(pumping_stations, bus_index, hydro_index, errors);
871    validate_contract_refs(contracts, bus_index, errors);
872    validate_ncs_refs(non_controllable_sources, bus_index, errors);
873}
874
875fn validate_line_refs(
876    lines: &[Line],
877    bus_index: &HashMap<EntityId, usize>,
878    errors: &mut Vec<ValidationError>,
879) {
880    for line in lines {
881        if !bus_index.contains_key(&line.source_bus_id) {
882            errors.push(ValidationError::InvalidReference {
883                source_entity_type: "Line",
884                source_id: line.id,
885                field_name: "source_bus_id",
886                referenced_id: line.source_bus_id,
887                expected_type: "Bus",
888            });
889        }
890        if !bus_index.contains_key(&line.target_bus_id) {
891            errors.push(ValidationError::InvalidReference {
892                source_entity_type: "Line",
893                source_id: line.id,
894                field_name: "target_bus_id",
895                referenced_id: line.target_bus_id,
896                expected_type: "Bus",
897            });
898        }
899    }
900}
901
902fn validate_hydro_refs(
903    hydros: &[Hydro],
904    bus_index: &HashMap<EntityId, usize>,
905    hydro_index: &HashMap<EntityId, usize>,
906    errors: &mut Vec<ValidationError>,
907) {
908    for hydro in hydros {
909        if !bus_index.contains_key(&hydro.bus_id) {
910            errors.push(ValidationError::InvalidReference {
911                source_entity_type: "Hydro",
912                source_id: hydro.id,
913                field_name: "bus_id",
914                referenced_id: hydro.bus_id,
915                expected_type: "Bus",
916            });
917        }
918        if let Some(downstream_id) = hydro.downstream_id {
919            if !hydro_index.contains_key(&downstream_id) {
920                errors.push(ValidationError::InvalidReference {
921                    source_entity_type: "Hydro",
922                    source_id: hydro.id,
923                    field_name: "downstream_id",
924                    referenced_id: downstream_id,
925                    expected_type: "Hydro",
926                });
927            }
928        }
929        if let Some(ref diversion) = hydro.diversion {
930            if !hydro_index.contains_key(&diversion.downstream_id) {
931                errors.push(ValidationError::InvalidReference {
932                    source_entity_type: "Hydro",
933                    source_id: hydro.id,
934                    field_name: "diversion.downstream_id",
935                    referenced_id: diversion.downstream_id,
936                    expected_type: "Hydro",
937                });
938            }
939        }
940    }
941}
942
943fn validate_thermal_refs(
944    thermals: &[Thermal],
945    bus_index: &HashMap<EntityId, usize>,
946    errors: &mut Vec<ValidationError>,
947) {
948    for thermal in thermals {
949        if !bus_index.contains_key(&thermal.bus_id) {
950            errors.push(ValidationError::InvalidReference {
951                source_entity_type: "Thermal",
952                source_id: thermal.id,
953                field_name: "bus_id",
954                referenced_id: thermal.bus_id,
955                expected_type: "Bus",
956            });
957        }
958    }
959}
960
961fn validate_pumping_station_refs(
962    pumping_stations: &[PumpingStation],
963    bus_index: &HashMap<EntityId, usize>,
964    hydro_index: &HashMap<EntityId, usize>,
965    errors: &mut Vec<ValidationError>,
966) {
967    for ps in pumping_stations {
968        if !bus_index.contains_key(&ps.bus_id) {
969            errors.push(ValidationError::InvalidReference {
970                source_entity_type: "PumpingStation",
971                source_id: ps.id,
972                field_name: "bus_id",
973                referenced_id: ps.bus_id,
974                expected_type: "Bus",
975            });
976        }
977        if !hydro_index.contains_key(&ps.source_hydro_id) {
978            errors.push(ValidationError::InvalidReference {
979                source_entity_type: "PumpingStation",
980                source_id: ps.id,
981                field_name: "source_hydro_id",
982                referenced_id: ps.source_hydro_id,
983                expected_type: "Hydro",
984            });
985        }
986        if !hydro_index.contains_key(&ps.destination_hydro_id) {
987            errors.push(ValidationError::InvalidReference {
988                source_entity_type: "PumpingStation",
989                source_id: ps.id,
990                field_name: "destination_hydro_id",
991                referenced_id: ps.destination_hydro_id,
992                expected_type: "Hydro",
993            });
994        }
995    }
996}
997
998fn validate_contract_refs(
999    contracts: &[EnergyContract],
1000    bus_index: &HashMap<EntityId, usize>,
1001    errors: &mut Vec<ValidationError>,
1002) {
1003    for contract in contracts {
1004        if !bus_index.contains_key(&contract.bus_id) {
1005            errors.push(ValidationError::InvalidReference {
1006                source_entity_type: "EnergyContract",
1007                source_id: contract.id,
1008                field_name: "bus_id",
1009                referenced_id: contract.bus_id,
1010                expected_type: "Bus",
1011            });
1012        }
1013    }
1014}
1015
1016fn validate_ncs_refs(
1017    non_controllable_sources: &[NonControllableSource],
1018    bus_index: &HashMap<EntityId, usize>,
1019    errors: &mut Vec<ValidationError>,
1020) {
1021    for ncs in non_controllable_sources {
1022        if !bus_index.contains_key(&ncs.bus_id) {
1023            errors.push(ValidationError::InvalidReference {
1024                source_entity_type: "NonControllableSource",
1025                source_id: ncs.id,
1026                field_name: "bus_id",
1027                referenced_id: ncs.bus_id,
1028                expected_type: "Bus",
1029            });
1030        }
1031    }
1032}
1033
1034/// Validate filling configurations for all hydros that have one.
1035///
1036/// For each hydro with `filling: Some(config)`:
1037/// - `filling_inflow_m3s` must be positive (> 0.0).
1038/// - `entry_stage_id` must be set (`Some`), since filling requires a known start stage.
1039///
1040/// All violations are appended to `errors` — no short-circuiting on first error.
1041fn validate_filling_configs(hydros: &[Hydro], errors: &mut Vec<ValidationError>) {
1042    for hydro in hydros {
1043        if let Some(filling) = &hydro.filling {
1044            if filling.filling_inflow_m3s.is_nan() || filling.filling_inflow_m3s <= 0.0 {
1045                errors.push(ValidationError::InvalidFillingConfig {
1046                    hydro_id: hydro.id,
1047                    reason: "filling_inflow_m3s must be positive".to_string(),
1048                });
1049            }
1050            if hydro.entry_stage_id.is_none() {
1051                errors.push(ValidationError::InvalidFillingConfig {
1052                    hydro_id: hydro.id,
1053                    reason: "filling requires entry_stage_id to be set".to_string(),
1054                });
1055            }
1056        }
1057    }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062    use super::*;
1063    use crate::entities::{ContractType, HydroGenerationModel, HydroPenalties, ThermalCostSegment};
1064
1065    fn make_bus(id: i32) -> Bus {
1066        Bus {
1067            id: EntityId(id),
1068            name: format!("bus-{id}"),
1069            deficit_segments: vec![],
1070            excess_cost: 0.0,
1071        }
1072    }
1073
1074    fn make_line(id: i32, source_bus_id: i32, target_bus_id: i32) -> Line {
1075        crate::Line {
1076            id: EntityId(id),
1077            name: format!("line-{id}"),
1078            source_bus_id: EntityId(source_bus_id),
1079            target_bus_id: EntityId(target_bus_id),
1080            entry_stage_id: None,
1081            exit_stage_id: None,
1082            direct_capacity_mw: 100.0,
1083            reverse_capacity_mw: 100.0,
1084            losses_percent: 0.0,
1085            exchange_cost: 0.0,
1086        }
1087    }
1088
1089    fn make_hydro_on_bus(id: i32, bus_id: i32) -> Hydro {
1090        let zero_penalties = HydroPenalties {
1091            spillage_cost: 0.0,
1092            diversion_cost: 0.0,
1093            fpha_turbined_cost: 0.0,
1094            storage_violation_below_cost: 0.0,
1095            filling_target_violation_cost: 0.0,
1096            turbined_violation_below_cost: 0.0,
1097            outflow_violation_below_cost: 0.0,
1098            outflow_violation_above_cost: 0.0,
1099            generation_violation_below_cost: 0.0,
1100            evaporation_violation_cost: 0.0,
1101            water_withdrawal_violation_cost: 0.0,
1102        };
1103        Hydro {
1104            id: EntityId(id),
1105            name: format!("hydro-{id}"),
1106            bus_id: EntityId(bus_id),
1107            downstream_id: None,
1108            entry_stage_id: None,
1109            exit_stage_id: None,
1110            min_storage_hm3: 0.0,
1111            max_storage_hm3: 1.0,
1112            min_outflow_m3s: 0.0,
1113            max_outflow_m3s: None,
1114            generation_model: HydroGenerationModel::ConstantProductivity {
1115                productivity_mw_per_m3s: 1.0,
1116            },
1117            min_turbined_m3s: 0.0,
1118            max_turbined_m3s: 1.0,
1119            min_generation_mw: 0.0,
1120            max_generation_mw: 1.0,
1121            tailrace: None,
1122            hydraulic_losses: None,
1123            efficiency: None,
1124            evaporation_coefficients_mm: None,
1125            diversion: None,
1126            filling: None,
1127            penalties: zero_penalties,
1128        }
1129    }
1130
1131    /// Creates a hydro on bus 0. Caller must supply `make_bus(0)`.
1132    fn make_hydro(id: i32) -> Hydro {
1133        make_hydro_on_bus(id, 0)
1134    }
1135
1136    fn make_thermal_on_bus(id: i32, bus_id: i32) -> Thermal {
1137        Thermal {
1138            id: EntityId(id),
1139            name: format!("thermal-{id}"),
1140            bus_id: EntityId(bus_id),
1141            entry_stage_id: None,
1142            exit_stage_id: None,
1143            cost_segments: vec![ThermalCostSegment {
1144                capacity_mw: 100.0,
1145                cost_per_mwh: 50.0,
1146            }],
1147            min_generation_mw: 0.0,
1148            max_generation_mw: 100.0,
1149            gnl_config: None,
1150        }
1151    }
1152
1153    /// Creates a thermal on bus 0. Caller must supply `make_bus(0)`.
1154    fn make_thermal(id: i32) -> Thermal {
1155        make_thermal_on_bus(id, 0)
1156    }
1157
1158    fn make_pumping_station_full(
1159        id: i32,
1160        bus_id: i32,
1161        source_hydro_id: i32,
1162        destination_hydro_id: i32,
1163    ) -> PumpingStation {
1164        PumpingStation {
1165            id: EntityId(id),
1166            name: format!("ps-{id}"),
1167            bus_id: EntityId(bus_id),
1168            source_hydro_id: EntityId(source_hydro_id),
1169            destination_hydro_id: EntityId(destination_hydro_id),
1170            entry_stage_id: None,
1171            exit_stage_id: None,
1172            consumption_mw_per_m3s: 0.5,
1173            min_flow_m3s: 0.0,
1174            max_flow_m3s: 10.0,
1175        }
1176    }
1177
1178    fn make_pumping_station(id: i32) -> PumpingStation {
1179        make_pumping_station_full(id, 0, 0, 1)
1180    }
1181
1182    fn make_contract_on_bus(id: i32, bus_id: i32) -> EnergyContract {
1183        EnergyContract {
1184            id: EntityId(id),
1185            name: format!("contract-{id}"),
1186            bus_id: EntityId(bus_id),
1187            contract_type: ContractType::Import,
1188            entry_stage_id: None,
1189            exit_stage_id: None,
1190            price_per_mwh: 0.0,
1191            min_mw: 0.0,
1192            max_mw: 100.0,
1193        }
1194    }
1195
1196    fn make_contract(id: i32) -> EnergyContract {
1197        make_contract_on_bus(id, 0)
1198    }
1199
1200    fn make_ncs_on_bus(id: i32, bus_id: i32) -> NonControllableSource {
1201        NonControllableSource {
1202            id: EntityId(id),
1203            name: format!("ncs-{id}"),
1204            bus_id: EntityId(bus_id),
1205            entry_stage_id: None,
1206            exit_stage_id: None,
1207            max_generation_mw: 50.0,
1208            curtailment_cost: 0.0,
1209        }
1210    }
1211
1212    fn make_ncs(id: i32) -> NonControllableSource {
1213        make_ncs_on_bus(id, 0)
1214    }
1215
1216    #[test]
1217    fn test_empty_system() {
1218        let system = SystemBuilder::new().build().expect("empty system is valid");
1219        assert_eq!(system.n_buses(), 0);
1220        assert_eq!(system.n_lines(), 0);
1221        assert_eq!(system.n_hydros(), 0);
1222        assert_eq!(system.n_thermals(), 0);
1223        assert_eq!(system.n_pumping_stations(), 0);
1224        assert_eq!(system.n_contracts(), 0);
1225        assert_eq!(system.n_non_controllable_sources(), 0);
1226        assert!(system.buses().is_empty());
1227        assert!(system.cascade().is_empty());
1228    }
1229
1230    #[test]
1231    fn test_canonical_ordering() {
1232        // Provide buses in reverse order: id=2, id=1, id=0
1233        let system = SystemBuilder::new()
1234            .buses(vec![make_bus(2), make_bus(1), make_bus(0)])
1235            .build()
1236            .expect("valid system");
1237
1238        assert_eq!(system.buses()[0].id, EntityId(0));
1239        assert_eq!(system.buses()[1].id, EntityId(1));
1240        assert_eq!(system.buses()[2].id, EntityId(2));
1241    }
1242
1243    #[test]
1244    fn test_lookup_by_id() {
1245        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1246        let system = SystemBuilder::new()
1247            .buses(vec![make_bus(0)])
1248            .hydros(vec![make_hydro(10), make_hydro(5), make_hydro(20)])
1249            .build()
1250            .expect("valid system");
1251
1252        assert_eq!(system.hydro(EntityId(5)).map(|h| h.id), Some(EntityId(5)));
1253        assert_eq!(system.hydro(EntityId(10)).map(|h| h.id), Some(EntityId(10)));
1254        assert_eq!(system.hydro(EntityId(20)).map(|h| h.id), Some(EntityId(20)));
1255    }
1256
1257    #[test]
1258    fn test_lookup_missing_id() {
1259        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1260        let system = SystemBuilder::new()
1261            .buses(vec![make_bus(0)])
1262            .hydros(vec![make_hydro(1), make_hydro(2)])
1263            .build()
1264            .expect("valid system");
1265
1266        assert!(system.hydro(EntityId(999)).is_none());
1267    }
1268
1269    #[test]
1270    fn test_count_queries() {
1271        let system = SystemBuilder::new()
1272            .buses(vec![make_bus(0), make_bus(1)])
1273            .lines(vec![make_line(0, 0, 1)])
1274            .hydros(vec![make_hydro(0), make_hydro(1), make_hydro(2)])
1275            .thermals(vec![make_thermal(0)])
1276            .pumping_stations(vec![make_pumping_station(0)])
1277            .contracts(vec![make_contract(0), make_contract(1)])
1278            .non_controllable_sources(vec![make_ncs(0)])
1279            .build()
1280            .expect("valid system");
1281
1282        assert_eq!(system.n_buses(), 2);
1283        assert_eq!(system.n_lines(), 1);
1284        assert_eq!(system.n_hydros(), 3);
1285        assert_eq!(system.n_thermals(), 1);
1286        assert_eq!(system.n_pumping_stations(), 1);
1287        assert_eq!(system.n_contracts(), 2);
1288        assert_eq!(system.n_non_controllable_sources(), 1);
1289    }
1290
1291    #[test]
1292    fn test_slice_accessors() {
1293        let system = SystemBuilder::new()
1294            .buses(vec![make_bus(0), make_bus(1), make_bus(2)])
1295            .build()
1296            .expect("valid system");
1297
1298        let buses = system.buses();
1299        assert_eq!(buses.len(), 3);
1300        assert_eq!(buses[0].id, EntityId(0));
1301        assert_eq!(buses[1].id, EntityId(1));
1302        assert_eq!(buses[2].id, EntityId(2));
1303    }
1304
1305    #[test]
1306    fn test_duplicate_id_error() {
1307        // Two buses with the same id=0 must yield an Err.
1308        let result = SystemBuilder::new()
1309            .buses(vec![make_bus(0), make_bus(0)])
1310            .build();
1311
1312        assert!(result.is_err());
1313        let errors = result.unwrap_err();
1314        assert!(!errors.is_empty());
1315        assert!(errors.iter().any(|e| matches!(
1316            e,
1317            ValidationError::DuplicateId {
1318                entity_type: "Bus",
1319                id: EntityId(0),
1320            }
1321        )));
1322    }
1323
1324    #[test]
1325    fn test_multiple_duplicate_errors() {
1326        // Duplicates in both buses (id=0) and thermals (id=5) must both be reported.
1327        let result = SystemBuilder::new()
1328            .buses(vec![make_bus(0), make_bus(0)])
1329            .thermals(vec![make_thermal(5), make_thermal(5)])
1330            .build();
1331
1332        assert!(result.is_err());
1333        let errors = result.unwrap_err();
1334
1335        let has_bus_dup = errors.iter().any(|e| {
1336            matches!(
1337                e,
1338                ValidationError::DuplicateId {
1339                    entity_type: "Bus",
1340                    ..
1341                }
1342            )
1343        });
1344        let has_thermal_dup = errors.iter().any(|e| {
1345            matches!(
1346                e,
1347                ValidationError::DuplicateId {
1348                    entity_type: "Thermal",
1349                    ..
1350                }
1351            )
1352        });
1353        assert!(has_bus_dup, "expected Bus duplicate error");
1354        assert!(has_thermal_dup, "expected Thermal duplicate error");
1355    }
1356
1357    #[test]
1358    fn test_send_sync() {
1359        fn require_send_sync<T: Send + Sync>(_: T) {}
1360        let system = SystemBuilder::new().build().expect("valid system");
1361        require_send_sync(system);
1362    }
1363
1364    #[test]
1365    fn test_cascade_accessible() {
1366        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1367        let mut h0 = make_hydro_on_bus(0, 0);
1368        h0.downstream_id = Some(EntityId(1));
1369        let mut h1 = make_hydro_on_bus(1, 0);
1370        h1.downstream_id = Some(EntityId(2));
1371        let h2 = make_hydro_on_bus(2, 0);
1372
1373        let system = SystemBuilder::new()
1374            .buses(vec![make_bus(0)])
1375            .hydros(vec![h0, h1, h2])
1376            .build()
1377            .expect("valid system");
1378
1379        let order = system.cascade().topological_order();
1380        assert!(!order.is_empty(), "topological order must be non-empty");
1381        let pos_0 = order
1382            .iter()
1383            .position(|&id| id == EntityId(0))
1384            .expect("EntityId(0) must be in topological order");
1385        let pos_2 = order
1386            .iter()
1387            .position(|&id| id == EntityId(2))
1388            .expect("EntityId(2) must be in topological order");
1389        assert!(pos_0 < pos_2, "EntityId(0) must precede EntityId(2)");
1390    }
1391
1392    #[test]
1393    fn test_network_accessible() {
1394        let system = SystemBuilder::new()
1395            .buses(vec![make_bus(0), make_bus(1)])
1396            .lines(vec![make_line(0, 0, 1)])
1397            .build()
1398            .expect("valid system");
1399
1400        let connections = system.network().bus_lines(EntityId(0));
1401        assert!(!connections.is_empty(), "bus 0 must have connections");
1402        assert_eq!(connections[0].line_id, EntityId(0));
1403    }
1404
1405    #[test]
1406    fn test_all_entity_lookups() {
1407        // Provide all buses and hydros that the other entities reference.
1408        // - Buses 0 and 1 are needed by all entities (lines, hydros, thermals, etc.)
1409        // - Hydros 0 and 1 are needed by the pumping station (source/destination)
1410        // - Hydro 3 is the entity under test (lookup by id=3), on bus 0
1411        let system = SystemBuilder::new()
1412            .buses(vec![make_bus(0), make_bus(1)])
1413            .lines(vec![make_line(2, 0, 1)])
1414            .hydros(vec![
1415                make_hydro_on_bus(0, 0),
1416                make_hydro_on_bus(1, 0),
1417                make_hydro_on_bus(3, 0),
1418            ])
1419            .thermals(vec![make_thermal(4)])
1420            .pumping_stations(vec![make_pumping_station(5)])
1421            .contracts(vec![make_contract(6)])
1422            .non_controllable_sources(vec![make_ncs(7)])
1423            .build()
1424            .expect("valid system");
1425
1426        assert!(system.bus(EntityId(1)).is_some());
1427        assert!(system.line(EntityId(2)).is_some());
1428        assert!(system.hydro(EntityId(3)).is_some());
1429        assert!(system.thermal(EntityId(4)).is_some());
1430        assert!(system.pumping_station(EntityId(5)).is_some());
1431        assert!(system.contract(EntityId(6)).is_some());
1432        assert!(system.non_controllable_source(EntityId(7)).is_some());
1433
1434        assert!(system.bus(EntityId(999)).is_none());
1435        assert!(system.line(EntityId(999)).is_none());
1436        assert!(system.hydro(EntityId(999)).is_none());
1437        assert!(system.thermal(EntityId(999)).is_none());
1438        assert!(system.pumping_station(EntityId(999)).is_none());
1439        assert!(system.contract(EntityId(999)).is_none());
1440        assert!(system.non_controllable_source(EntityId(999)).is_none());
1441    }
1442
1443    #[test]
1444    fn test_default_builder() {
1445        let system = SystemBuilder::default()
1446            .build()
1447            .expect("default builder produces valid empty system");
1448        assert_eq!(system.n_buses(), 0);
1449    }
1450
1451    // ---- Cross-reference validation tests -----------------------------------
1452
1453    #[test]
1454    fn test_invalid_bus_reference_hydro() {
1455        // Hydro references bus id=99 which does not exist.
1456        let hydro = make_hydro_on_bus(1, 99);
1457        let result = SystemBuilder::new().hydros(vec![hydro]).build();
1458
1459        assert!(result.is_err(), "expected Err for missing bus reference");
1460        let errors = result.unwrap_err();
1461        assert!(
1462            errors.iter().any(|e| matches!(
1463                e,
1464                ValidationError::InvalidReference {
1465                    source_entity_type: "Hydro",
1466                    source_id: EntityId(1),
1467                    field_name: "bus_id",
1468                    referenced_id: EntityId(99),
1469                    expected_type: "Bus",
1470                }
1471            )),
1472            "expected InvalidReference for Hydro bus_id=99, got: {errors:?}"
1473        );
1474    }
1475
1476    #[test]
1477    fn test_invalid_downstream_reference() {
1478        // Hydro references downstream hydro id=50 which does not exist.
1479        let bus = make_bus(0);
1480        let mut hydro = make_hydro(1);
1481        hydro.downstream_id = Some(EntityId(50));
1482
1483        let result = SystemBuilder::new()
1484            .buses(vec![bus])
1485            .hydros(vec![hydro])
1486            .build();
1487
1488        assert!(
1489            result.is_err(),
1490            "expected Err for missing downstream reference"
1491        );
1492        let errors = result.unwrap_err();
1493        assert!(
1494            errors.iter().any(|e| matches!(
1495                e,
1496                ValidationError::InvalidReference {
1497                    source_entity_type: "Hydro",
1498                    source_id: EntityId(1),
1499                    field_name: "downstream_id",
1500                    referenced_id: EntityId(50),
1501                    expected_type: "Hydro",
1502                }
1503            )),
1504            "expected InvalidReference for Hydro downstream_id=50, got: {errors:?}"
1505        );
1506    }
1507
1508    #[test]
1509    fn test_invalid_pumping_station_hydro_refs() {
1510        // Pumping station references source hydro id=77 which does not exist.
1511        let bus = make_bus(0);
1512        let dest_hydro = make_hydro(1);
1513        let ps = make_pumping_station_full(10, 0, 77, 1);
1514
1515        let result = SystemBuilder::new()
1516            .buses(vec![bus])
1517            .hydros(vec![dest_hydro])
1518            .pumping_stations(vec![ps])
1519            .build();
1520
1521        assert!(
1522            result.is_err(),
1523            "expected Err for missing source_hydro_id reference"
1524        );
1525        let errors = result.unwrap_err();
1526        assert!(
1527            errors.iter().any(|e| matches!(
1528                e,
1529                ValidationError::InvalidReference {
1530                    source_entity_type: "PumpingStation",
1531                    source_id: EntityId(10),
1532                    field_name: "source_hydro_id",
1533                    referenced_id: EntityId(77),
1534                    expected_type: "Hydro",
1535                }
1536            )),
1537            "expected InvalidReference for PumpingStation source_hydro_id=77, got: {errors:?}"
1538        );
1539    }
1540
1541    #[test]
1542    fn test_multiple_invalid_references_collected() {
1543        // A line with bad source_bus_id AND a thermal with bad bus_id.
1544        // Both errors must be reported (no short-circuiting).
1545        let line = make_line(1, 99, 0);
1546        let thermal = make_thermal_on_bus(2, 88);
1547
1548        let result = SystemBuilder::new()
1549            .buses(vec![make_bus(0)])
1550            .lines(vec![line])
1551            .thermals(vec![thermal])
1552            .build();
1553
1554        assert!(
1555            result.is_err(),
1556            "expected Err for multiple invalid references"
1557        );
1558        let errors = result.unwrap_err();
1559
1560        let has_line_error = errors.iter().any(|e| {
1561            matches!(
1562                e,
1563                ValidationError::InvalidReference {
1564                    source_entity_type: "Line",
1565                    field_name: "source_bus_id",
1566                    referenced_id: EntityId(99),
1567                    ..
1568                }
1569            )
1570        });
1571        let has_thermal_error = errors.iter().any(|e| {
1572            matches!(
1573                e,
1574                ValidationError::InvalidReference {
1575                    source_entity_type: "Thermal",
1576                    field_name: "bus_id",
1577                    referenced_id: EntityId(88),
1578                    ..
1579                }
1580            )
1581        });
1582
1583        assert!(
1584            has_line_error,
1585            "expected Line source_bus_id=99 error, got: {errors:?}"
1586        );
1587        assert!(
1588            has_thermal_error,
1589            "expected Thermal bus_id=88 error, got: {errors:?}"
1590        );
1591        assert!(
1592            errors.len() >= 2,
1593            "expected at least 2 errors, got {}: {errors:?}",
1594            errors.len()
1595        );
1596    }
1597
1598    #[test]
1599    fn test_valid_cross_references_pass() {
1600        // All cross-references point to entities that exist — build must succeed.
1601        let bus_0 = make_bus(0);
1602        let bus_1 = make_bus(1);
1603        let h0 = make_hydro_on_bus(0, 0);
1604        let h1 = make_hydro_on_bus(1, 1);
1605        let mut h2 = make_hydro_on_bus(2, 0);
1606        h2.downstream_id = Some(EntityId(1));
1607        let line = make_line(10, 0, 1);
1608        let thermal = make_thermal_on_bus(20, 0);
1609        let ps = make_pumping_station_full(30, 0, 0, 1);
1610        let contract = make_contract_on_bus(40, 1);
1611        let ncs = make_ncs_on_bus(50, 0);
1612
1613        let result = SystemBuilder::new()
1614            .buses(vec![bus_0, bus_1])
1615            .lines(vec![line])
1616            .hydros(vec![h0, h1, h2])
1617            .thermals(vec![thermal])
1618            .pumping_stations(vec![ps])
1619            .contracts(vec![contract])
1620            .non_controllable_sources(vec![ncs])
1621            .build();
1622
1623        assert!(
1624            result.is_ok(),
1625            "expected Ok for all valid cross-references, got: {:?}",
1626            result.unwrap_err()
1627        );
1628        let system = result.unwrap_or_else(|_| unreachable!());
1629        assert_eq!(system.n_buses(), 2);
1630        assert_eq!(system.n_hydros(), 3);
1631        assert_eq!(system.n_lines(), 1);
1632        assert_eq!(system.n_thermals(), 1);
1633        assert_eq!(system.n_pumping_stations(), 1);
1634        assert_eq!(system.n_contracts(), 1);
1635        assert_eq!(system.n_non_controllable_sources(), 1);
1636    }
1637
1638    // ---- Cascade cycle detection tests --------------------------------------
1639
1640    #[test]
1641    fn test_cascade_cycle_detected() {
1642        // Three-node cycle: A(0)->B(1)->C(2)->A(0).
1643        // All three reference a common bus (bus 0).
1644        let bus = make_bus(0);
1645        let mut h0 = make_hydro(0);
1646        h0.downstream_id = Some(EntityId(1));
1647        let mut h1 = make_hydro(1);
1648        h1.downstream_id = Some(EntityId(2));
1649        let mut h2 = make_hydro(2);
1650        h2.downstream_id = Some(EntityId(0));
1651
1652        let result = SystemBuilder::new()
1653            .buses(vec![bus])
1654            .hydros(vec![h0, h1, h2])
1655            .build();
1656
1657        assert!(result.is_err(), "expected Err for 3-node cycle");
1658        let errors = result.unwrap_err();
1659        let cycle_error = errors
1660            .iter()
1661            .find(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1662        assert!(
1663            cycle_error.is_some(),
1664            "expected CascadeCycle error, got: {errors:?}"
1665        );
1666        let ValidationError::CascadeCycle { cycle_ids } = cycle_error.unwrap() else {
1667            unreachable!()
1668        };
1669        assert_eq!(
1670            cycle_ids,
1671            &[EntityId(0), EntityId(1), EntityId(2)],
1672            "cycle_ids must be sorted ascending, got: {cycle_ids:?}"
1673        );
1674    }
1675
1676    #[test]
1677    fn test_cascade_self_loop_detected() {
1678        // Single hydro pointing to itself: A(0)->A(0).
1679        let bus = make_bus(0);
1680        let mut h0 = make_hydro(0);
1681        h0.downstream_id = Some(EntityId(0));
1682
1683        let result = SystemBuilder::new()
1684            .buses(vec![bus])
1685            .hydros(vec![h0])
1686            .build();
1687
1688        assert!(result.is_err(), "expected Err for self-loop");
1689        let errors = result.unwrap_err();
1690        let has_cycle = errors
1691            .iter()
1692            .any(|e| matches!(e, ValidationError::CascadeCycle { cycle_ids } if cycle_ids.contains(&EntityId(0))));
1693        assert!(
1694            has_cycle,
1695            "expected CascadeCycle containing EntityId(0), got: {errors:?}"
1696        );
1697    }
1698
1699    #[test]
1700    fn test_valid_acyclic_cascade_passes() {
1701        // Linear acyclic cascade A(0)->B(1)->C(2).
1702        // Verifies that a valid cascade produces Ok with correct topological_order length.
1703        let bus = make_bus(0);
1704        let mut h0 = make_hydro(0);
1705        h0.downstream_id = Some(EntityId(1));
1706        let mut h1 = make_hydro(1);
1707        h1.downstream_id = Some(EntityId(2));
1708        let h2 = make_hydro(2);
1709
1710        let result = SystemBuilder::new()
1711            .buses(vec![bus])
1712            .hydros(vec![h0, h1, h2])
1713            .build();
1714
1715        assert!(
1716            result.is_ok(),
1717            "expected Ok for acyclic cascade, got: {:?}",
1718            result.unwrap_err()
1719        );
1720        let system = result.unwrap_or_else(|_| unreachable!());
1721        assert_eq!(
1722            system.cascade().topological_order().len(),
1723            system.n_hydros(),
1724            "topological_order must contain all hydros"
1725        );
1726    }
1727
1728    // ---- Filling config validation tests ------------------------------------
1729
1730    #[test]
1731    fn test_filling_without_entry_stage() {
1732        // Filling config present but entry_stage_id is None.
1733        use crate::entities::FillingConfig;
1734        let bus = make_bus(0);
1735        let mut hydro = make_hydro(1);
1736        hydro.entry_stage_id = None;
1737        hydro.filling = Some(FillingConfig {
1738            start_stage_id: 10,
1739            filling_inflow_m3s: 100.0,
1740        });
1741
1742        let result = SystemBuilder::new()
1743            .buses(vec![bus])
1744            .hydros(vec![hydro])
1745            .build();
1746
1747        assert!(
1748            result.is_err(),
1749            "expected Err for filling without entry_stage_id"
1750        );
1751        let errors = result.unwrap_err();
1752        let has_error = errors.iter().any(|e| match e {
1753            ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1754                *hydro_id == EntityId(1) && reason.contains("entry_stage_id")
1755            }
1756            _ => false,
1757        });
1758        assert!(
1759            has_error,
1760            "expected InvalidFillingConfig with entry_stage_id reason, got: {errors:?}"
1761        );
1762    }
1763
1764    #[test]
1765    fn test_filling_negative_inflow() {
1766        // Filling config with filling_inflow_m3s <= 0.0.
1767        use crate::entities::FillingConfig;
1768        let bus = make_bus(0);
1769        let mut hydro = make_hydro(1);
1770        hydro.entry_stage_id = Some(10);
1771        hydro.filling = Some(FillingConfig {
1772            start_stage_id: 10,
1773            filling_inflow_m3s: -5.0,
1774        });
1775
1776        let result = SystemBuilder::new()
1777            .buses(vec![bus])
1778            .hydros(vec![hydro])
1779            .build();
1780
1781        assert!(
1782            result.is_err(),
1783            "expected Err for negative filling_inflow_m3s"
1784        );
1785        let errors = result.unwrap_err();
1786        let has_error = errors.iter().any(|e| match e {
1787            ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1788                *hydro_id == EntityId(1) && reason.contains("filling_inflow_m3s must be positive")
1789            }
1790            _ => false,
1791        });
1792        assert!(
1793            has_error,
1794            "expected InvalidFillingConfig with positive inflow reason, got: {errors:?}"
1795        );
1796    }
1797
1798    #[test]
1799    fn test_valid_filling_config_passes() {
1800        // Valid filling config: entry_stage_id set and filling_inflow_m3s positive.
1801        use crate::entities::FillingConfig;
1802        let bus = make_bus(0);
1803        let mut hydro = make_hydro(1);
1804        hydro.entry_stage_id = Some(10);
1805        hydro.filling = Some(FillingConfig {
1806            start_stage_id: 10,
1807            filling_inflow_m3s: 100.0,
1808        });
1809
1810        let result = SystemBuilder::new()
1811            .buses(vec![bus])
1812            .hydros(vec![hydro])
1813            .build();
1814
1815        assert!(
1816            result.is_ok(),
1817            "expected Ok for valid filling config, got: {:?}",
1818            result.unwrap_err()
1819        );
1820    }
1821
1822    #[test]
1823    fn test_cascade_cycle_and_invalid_filling_both_reported() {
1824        // Both a cascade cycle (A->A self-loop) AND an invalid filling config
1825        // must produce both error variants.
1826        use crate::entities::FillingConfig;
1827        let bus = make_bus(0);
1828
1829        // Hydro 0: self-loop (cycle)
1830        let mut h0 = make_hydro(0);
1831        h0.downstream_id = Some(EntityId(0));
1832
1833        // Hydro 1: valid cycle participant? No -- use a separate hydro with invalid filling.
1834        let mut h1 = make_hydro(1);
1835        h1.entry_stage_id = None; // no entry_stage_id
1836        h1.filling = Some(FillingConfig {
1837            start_stage_id: 5,
1838            filling_inflow_m3s: 50.0,
1839        });
1840
1841        let result = SystemBuilder::new()
1842            .buses(vec![bus])
1843            .hydros(vec![h0, h1])
1844            .build();
1845
1846        assert!(result.is_err(), "expected Err for cycle + invalid filling");
1847        let errors = result.unwrap_err();
1848        let has_cycle = errors
1849            .iter()
1850            .any(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1851        let has_filling = errors
1852            .iter()
1853            .any(|e| matches!(e, ValidationError::InvalidFillingConfig { .. }));
1854        assert!(has_cycle, "expected CascadeCycle error, got: {errors:?}");
1855        assert!(
1856            has_filling,
1857            "expected InvalidFillingConfig error, got: {errors:?}"
1858        );
1859    }
1860
1861    #[cfg(feature = "serde")]
1862    #[test]
1863    fn test_system_serde_roundtrip() {
1864        // Build a system with a bus, a hydro, a line, and a thermal.
1865        let bus_a = make_bus(1);
1866        let bus_b = make_bus(2);
1867        let hydro = make_hydro_on_bus(10, 1);
1868        let thermal = make_thermal_on_bus(20, 2);
1869        let line = make_line(1, 1, 2);
1870
1871        let system = SystemBuilder::new()
1872            .buses(vec![bus_a, bus_b])
1873            .hydros(vec![hydro])
1874            .thermals(vec![thermal])
1875            .lines(vec![line])
1876            .build()
1877            .expect("valid system");
1878
1879        let json = serde_json::to_string(&system).unwrap();
1880
1881        // Deserialize and rebuild indices.
1882        let mut deserialized: System = serde_json::from_str(&json).unwrap();
1883        deserialized.rebuild_indices();
1884
1885        // Entity collections must match.
1886        assert_eq!(system.buses(), deserialized.buses());
1887        assert_eq!(system.hydros(), deserialized.hydros());
1888        assert_eq!(system.thermals(), deserialized.thermals());
1889        assert_eq!(system.lines(), deserialized.lines());
1890
1891        // O(1) lookup must work after index rebuild.
1892        assert_eq!(
1893            deserialized.bus(EntityId(1)).map(|b| b.id),
1894            Some(EntityId(1))
1895        );
1896        assert_eq!(
1897            deserialized.hydro(EntityId(10)).map(|h| h.id),
1898            Some(EntityId(10))
1899        );
1900        assert_eq!(
1901            deserialized.thermal(EntityId(20)).map(|t| t.id),
1902            Some(EntityId(20))
1903        );
1904        assert_eq!(
1905            deserialized.line(EntityId(1)).map(|l| l.id),
1906            Some(EntityId(1))
1907        );
1908    }
1909
1910    // ---- Extended System tests ----------------------------------------------
1911
1912    fn make_stage(id: i32) -> Stage {
1913        use crate::temporal::{
1914            Block, BlockMode, NoiseMethod, ScenarioSourceConfig, StageRiskConfig, StageStateConfig,
1915        };
1916        use chrono::NaiveDate;
1917        Stage {
1918            index: usize::try_from(id.max(0)).unwrap_or(0),
1919            id,
1920            start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1921            end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1922            season_id: Some(0),
1923            blocks: vec![Block {
1924                index: 0,
1925                name: "SINGLE".to_string(),
1926                duration_hours: 744.0,
1927            }],
1928            block_mode: BlockMode::Parallel,
1929            state_config: StageStateConfig {
1930                storage: true,
1931                inflow_lags: false,
1932            },
1933            risk_config: StageRiskConfig::Expectation,
1934            scenario_config: ScenarioSourceConfig {
1935                branching_factor: 50,
1936                noise_method: NoiseMethod::Saa,
1937            },
1938        }
1939    }
1940
1941    /// Verify that `SystemBuilder::new().build()` still works correctly.
1942    /// New fields must default to empty/default values.
1943    #[test]
1944    fn test_system_backward_compat() {
1945        let system = SystemBuilder::new().build().expect("empty system is valid");
1946        // Entity counts unchanged
1947        assert_eq!(system.n_buses(), 0);
1948        assert_eq!(system.n_hydros(), 0);
1949        // New fields default to empty
1950        assert_eq!(system.n_stages(), 0);
1951        assert!(system.stages().is_empty());
1952        assert!(system.initial_conditions().storage.is_empty());
1953        assert!(system.generic_constraints().is_empty());
1954        assert!(system.inflow_models().is_empty());
1955        assert!(system.load_models().is_empty());
1956        assert_eq!(system.penalties().n_stages(), 0);
1957        assert_eq!(system.bounds().n_stages(), 0);
1958    }
1959
1960    /// Build a System with 2 stages and verify `n_stages()` and `stage(id)` lookup.
1961    #[test]
1962    fn test_system_with_stages() {
1963        let s0 = make_stage(0);
1964        let s1 = make_stage(1);
1965
1966        let system = SystemBuilder::new()
1967            .stages(vec![s1.clone(), s0.clone()]) // supply in reverse order
1968            .build()
1969            .expect("valid system");
1970
1971        // Canonical ordering: id=0 comes before id=1
1972        assert_eq!(system.n_stages(), 2);
1973        assert_eq!(system.stages()[0].id, 0);
1974        assert_eq!(system.stages()[1].id, 1);
1975
1976        // O(1) lookup by stage id
1977        let found = system.stage(0).expect("stage 0 must be found");
1978        assert_eq!(found.id, s0.id);
1979
1980        let found1 = system.stage(1).expect("stage 1 must be found");
1981        assert_eq!(found1.id, s1.id);
1982
1983        // Missing stage returns None
1984        assert!(system.stage(99).is_none());
1985    }
1986
1987    /// Build a System with 3 stages having IDs 0, 1, 2 and verify `stage()` lookups.
1988    #[test]
1989    fn test_system_stage_lookup_by_id() {
1990        let stages: Vec<Stage> = [0i32, 1, 2].iter().map(|&id| make_stage(id)).collect();
1991
1992        let system = SystemBuilder::new()
1993            .stages(stages)
1994            .build()
1995            .expect("valid system");
1996
1997        assert_eq!(system.stage(1).map(|s| s.id), Some(1));
1998        assert!(system.stage(99).is_none());
1999    }
2000
2001    /// Build a System with `InitialConditions` containing 1 storage entry and verify accessor.
2002    #[test]
2003    fn test_system_with_initial_conditions() {
2004        let ic = InitialConditions {
2005            storage: vec![crate::HydroStorage {
2006                hydro_id: EntityId(0),
2007                value_hm3: 15_000.0,
2008            }],
2009            filling_storage: vec![],
2010        };
2011
2012        let system = SystemBuilder::new()
2013            .initial_conditions(ic)
2014            .build()
2015            .expect("valid system");
2016
2017        assert_eq!(system.initial_conditions().storage.len(), 1);
2018        assert_eq!(system.initial_conditions().storage[0].hydro_id, EntityId(0));
2019        assert!((system.initial_conditions().storage[0].value_hm3 - 15_000.0).abs() < f64::EPSILON);
2020    }
2021
2022    /// Verify serde round-trip of a System with stages and `policy_graph`,
2023    /// including that `stage_index` is correctly rebuilt after deserialization.
2024    #[cfg(feature = "serde")]
2025    #[test]
2026    fn test_system_serde_roundtrip_with_stages() {
2027        use crate::temporal::PolicyGraphType;
2028
2029        let stages = vec![make_stage(0), make_stage(1)];
2030        let policy_graph = PolicyGraph {
2031            graph_type: PolicyGraphType::FiniteHorizon,
2032            annual_discount_rate: 0.0,
2033            transitions: vec![],
2034            season_map: None,
2035        };
2036
2037        let system = SystemBuilder::new()
2038            .stages(stages)
2039            .policy_graph(policy_graph)
2040            .build()
2041            .expect("valid system");
2042
2043        let json = serde_json::to_string(&system).unwrap();
2044        let mut deserialized: System = serde_json::from_str(&json).unwrap();
2045
2046        // stage_index is skipped during serde; rebuild before querying
2047        deserialized.rebuild_indices();
2048
2049        // Collections must match after round-trip
2050        assert_eq!(system.n_stages(), deserialized.n_stages());
2051        assert_eq!(system.stages()[0].id, deserialized.stages()[0].id);
2052        assert_eq!(system.stages()[1].id, deserialized.stages()[1].id);
2053
2054        // O(1) lookup must work after index rebuild
2055        assert_eq!(deserialized.stage(0).map(|s| s.id), Some(0));
2056        assert_eq!(deserialized.stage(1).map(|s| s.id), Some(1));
2057        assert!(deserialized.stage(99).is_none());
2058
2059        // policy_graph fields must round-trip
2060        assert_eq!(
2061            deserialized.policy_graph().graph_type,
2062            system.policy_graph().graph_type
2063        );
2064    }
2065}