Skip to main content

cobre_core/model/
temporal.rs

1//! Temporal domain types — stages, blocks, seasons, and the policy graph.
2//!
3//! This module defines the types that describe the time structure of a
4//! multi-stage stochastic optimization problem: how the study horizon is
5//! partitioned into stages, how stages are subdivided into load blocks,
6//! how stages relate to seasonal patterns, and how the policy graph
7//! encodes stage-to-stage transitions.
8//!
9//! These are clarity-first data types following the dual-nature design
10//! principle: they use `Vec<T>`, `String`, and `Option` for readability
11//! and correctness. LP-related fields (variable indices, constraint counts,
12//! coefficient arrays) belong to the performance layer in downstream solver crates.
13//!
14//! Source: `stages.json`. See `internal-structures.md` SS12.
15
16use chrono::{Datelike, NaiveDate};
17
18// ---------------------------------------------------------------------------
19// Supporting enums
20// ---------------------------------------------------------------------------
21
22/// Block formulation mode controlling how blocks within a stage relate
23/// to each other in the LP.
24///
25/// See [Block Formulations](../math/block-formulations.md) for the
26/// mathematical treatment of each mode.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub enum BlockMode {
30    /// Blocks are independent sub-periods solved simultaneously.
31    /// Water balance is aggregated across all blocks in the stage.
32    /// This is the default and most common mode.
33    Parallel,
34
35    /// Blocks are sequential within the stage, with inter-block
36    /// state transitions (intra-stage storage dynamics).
37    /// Enables modeling of daily cycling patterns within monthly stages.
38    Chronological,
39}
40
41/// Season cycle type controlling how season IDs map to calendar periods.
42///
43/// See [Input Scenarios §1.1](input-scenarios.md) for the JSON schema
44/// and calendar mapping rules.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47pub enum SeasonCycleType {
48    /// Each season corresponds to one calendar month (12 seasons).
49    Monthly,
50    /// Each season corresponds to one ISO calendar week (52 seasons).
51    Weekly,
52    /// User-defined date ranges with explicit boundaries per season.
53    Custom,
54}
55
56/// Opening tree noise generation algorithm for a stage.
57///
58/// Controls which algorithm is used to generate noise vectors for
59/// the opening tree at this stage. This is orthogonal to
60/// `SamplingScheme`, which selects the forward-pass noise *source*
61/// (in-sample, external, historical). `NoiseMethod` governs *how*
62/// the noise vectors are produced (SAA, LHS, QMC-Sobol, QMC-Halton,
63/// Selective, or `HistoricalResiduals`).
64///
65/// See [Input Scenarios §1.8](input-scenarios.md) for the
66/// full method catalog and use cases.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
69pub enum NoiseMethod {
70    /// Sample Average Approximation. Pure Monte Carlo random sampling.
71    Saa,
72    /// Latin Hypercube Sampling. Stratified sampling ensuring uniform coverage.
73    Lhs,
74    /// Quasi-Monte Carlo with Sobol sequences. Low-discrepancy.
75    QmcSobol,
76    /// Quasi-Monte Carlo with Halton sequences. Low-discrepancy.
77    QmcHalton,
78    /// Selective/Representative Sampling. Clustering on historical data.
79    Selective,
80    /// Historical residuals from the `HistoricalScenarioLibrary`.
81    /// Copies pre-computed eta (residual) vectors from actual historical
82    /// observations. Skips the parametric Cholesky correlation step since
83    /// empirical cross-entity correlation is embedded in the residuals.
84    /// Year pool configuration is sourced from the system-level
85    /// `HistoricalYears` config (same as the Historical forward sampling
86    /// scheme).
87    HistoricalResiduals,
88}
89
90/// Horizon type tag for the policy graph.
91///
92/// Determines whether the study horizon is finite (acyclic linear chain or DAG)
93/// or cyclic (infinite periodic horizon with at least one back-edge). The
94/// solver-level `HorizonMode` enum in downstream solver crates is built from a
95/// [`PolicyGraph`] that carries this tag — it precomputes transition maps,
96/// cycle detection, and discount factors for efficient runtime dispatch.
97///
98/// Cross-reference: [Horizon Mode Trait SS3.1](../architecture/horizon-mode-trait.md).
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101pub enum PolicyGraphType {
102    /// Acyclic stage chain: the study has a definite end stage.
103    /// Terminal value is zero (no future-cost approximation beyond the horizon).
104    FiniteHorizon,
105    /// Infinite periodic horizon: at least one transition has
106    /// `source_id >= target_id` (a back-edge). Requires a positive
107    /// `annual_discount_rate` for convergence.
108    Cyclic,
109}
110
111// ---------------------------------------------------------------------------
112// Block (SS12.2)
113// ---------------------------------------------------------------------------
114
115/// A load block within a stage, representing a sub-period with uniform
116/// demand and generation characteristics.
117///
118/// Blocks partition the stage duration into sub-periods (e.g., peak,
119/// off-peak, shoulder). Block IDs are contiguous within each stage,
120/// starting at 0. The block weight (fraction of stage duration) is
121/// derived from `duration_hours` and is not stored — it is computed
122/// on demand as `duration_hours / sum(all block hours in stage)`.
123///
124/// Source: `stages.json` `stages[].blocks[]`.
125/// See [Input Scenarios §1.5](input-scenarios.md).
126#[derive(Debug, Clone, PartialEq)]
127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
128pub struct Block {
129    /// 0-based index within the parent stage.
130    /// Matches the `id` field from `stages.json`, validated to be
131    /// contiguous (0, 1, 2, ..., n-1) during loading.
132    pub index: usize,
133
134    /// Human-readable block label (e.g., "LEVE", "MEDIA", "PESADA").
135    pub name: String,
136
137    /// Duration of this block in hours. Must be positive.
138    /// Validation: the sum of all block hours within a stage must
139    /// equal the total stage duration in hours.
140    /// See [Input Scenarios §1.10](input-scenarios.md), rule 3.
141    pub duration_hours: f64,
142}
143
144// ---------------------------------------------------------------------------
145// StageStateConfig (SS12.3)
146// ---------------------------------------------------------------------------
147
148/// State variable flags controlling which variables carry state
149/// between stages for a given stage.
150///
151/// Source: `stages.json` `stages[].state_variables`.
152/// See [Input Scenarios §1.6](input-scenarios.md).
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
155pub struct StageStateConfig {
156    /// Whether reservoir storage volumes are state variables.
157    /// Default: true. Mandatory in most applications but kept as an
158    /// explicit flag for transparency.
159    pub storage: bool,
160
161    /// Whether past inflow realizations (AR model lags) are state
162    /// variables. Default: false. Required when PAR model order `p > 0`
163    /// and inflow lag state tracking is enabled.
164    pub inflow_lags: bool,
165}
166
167// ---------------------------------------------------------------------------
168// StageLagTransition (SS12.3.1)
169// ---------------------------------------------------------------------------
170
171/// Pre-encoded contribution of a single stage to the lag accumulator.
172///
173/// Each stage in a multi-resolution study may span a different calendar
174/// duration than the lag period it feeds into (for example, weekly stages
175/// contributing to a monthly lag slot). `StageLagTransition` encodes the
176/// fractional weights and finalization signal that the precomputation
177/// algorithm derives from stage date boundaries, so the hot path can apply
178/// them without recomputing calendar arithmetic at runtime.
179///
180/// A `Vec<StageLagTransition>` indexed by stage index is the canonical way
181/// to carry this information alongside the stage vector.
182// Four boolean flags encode orthogonal hot-path conditions; a state machine enum
183// would require 2^4 = 16 variants with no semantic benefit. Each flag is
184// independently set by the precomputation algorithm and tested by separate
185// if-guards in `accumulate_and_shift_lag_state`.
186#[allow(clippy::struct_excessive_bools)]
187#[derive(Debug, Clone, Copy, PartialEq)]
188#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
189pub struct StageLagTransition {
190    /// Fraction of this stage's realized value that is added to the
191    /// current lag accumulator bucket.
192    ///
193    /// A value of `1.0` means the stage contributes its full realized value
194    /// to the accumulator. A value between `0.0` and `1.0` indicates a
195    /// partial contribution (for example, a weekly stage that covers only
196    /// part of a monthly lag period). Validation that this value lies in
197    /// `[0.0, 1.0]` is the responsibility of the precomputation algorithm.
198    pub accumulate_weight: f64,
199
200    /// Fraction of this stage's realized value that carries over into the
201    /// next lag accumulator bucket when `finalize_period` is `true`.
202    ///
203    /// When a stage straddles the boundary between two lag periods, the
204    /// portion of the stage value that belongs to the next period is
205    /// captured here. For stages that fall entirely within one lag period,
206    /// this is `0.0`. Validation that `accumulate_weight + spillover_weight`
207    /// is consistent with the stage's temporal position is the
208    /// responsibility of the precomputation algorithm.
209    pub spillover_weight: f64,
210
211    /// Whether this stage marks the end of a complete lag accumulation
212    /// period.
213    ///
214    /// When `true`, the accumulator bucket for the current lag period is
215    /// considered finalized after processing this stage: the accumulated
216    /// value is committed to the lag state vector and the accumulator is
217    /// reset (possibly seeded with the `spillover_weight` contribution).
218    /// When `false`, accumulation continues into the next stage.
219    pub finalize_period: bool,
220
221    /// Whether this stage should also accumulate into a downstream
222    /// (coarser-resolution) ring buffer.
223    ///
224    /// Set to `true` for stages in the pre-transition window when the study
225    /// transitions from a finer to a coarser temporal resolution (for example,
226    /// the last `L_q * 3` monthly stages before a monthly-to-quarterly
227    /// boundary). `false` for all stages in uniform-resolution studies,
228    /// producing zero overhead on the hot path.
229    pub accumulate_downstream: bool,
230
231    /// Fraction of this stage's realized value to accumulate into the
232    /// downstream lag period bucket.
233    ///
234    /// Analogous to `accumulate_weight` but relative to the downstream
235    /// (coarser) lag period boundaries. `0.0` when `accumulate_downstream`
236    /// is `false`.
237    pub downstream_accumulate_weight: f64,
238
239    /// Fraction of this stage's realized value carrying over into the next
240    /// downstream lag period when `downstream_finalize` is `true`.
241    ///
242    /// Analogous to `spillover_weight` but for the downstream period
243    /// boundary. `0.0` when `accumulate_downstream` is `false`.
244    pub downstream_spillover_weight: f64,
245
246    /// Whether this stage marks the end of a complete downstream lag
247    /// accumulation period.
248    ///
249    /// When `true`, the downstream accumulator bucket is finalized and
250    /// pushed to the downstream ring buffer. For example, the last monthly
251    /// stage of a calendar quarter has `downstream_finalize = true` when the
252    /// study transitions to quarterly resolution. `false` when
253    /// `accumulate_downstream` is `false`.
254    pub downstream_finalize: bool,
255
256    /// Whether the lag state must be rebuilt from the downstream ring buffer
257    /// at this stage.
258    ///
259    /// Set to `true` on the first quarterly stage (the transition stage) when
260    /// `downstream_par_order > 0`. When `true`, `accumulate_and_shift_lag_state`
261    /// overwrites `state[lag_start..]` with the completed quarterly lags from the
262    /// downstream ring buffer before resuming primary accumulation at quarterly
263    /// resolution. `false` for all stages in uniform-resolution studies and all
264    /// pre-transition monthly stages, producing zero overhead on the hot path.
265    pub rebuild_from_downstream: bool,
266}
267
268// ---------------------------------------------------------------------------
269// StageRiskConfig (SS12.4)
270// ---------------------------------------------------------------------------
271
272/// Per-stage risk measure configuration, representing the parsed and
273/// validated risk parameters for a single stage.
274///
275/// This is the clarity-first representation stored in the [`Stage`] struct.
276/// The solver-level `RiskMeasure` enum in
277/// [Risk Measure Trait](../architecture/risk-measure-trait.md) is the
278/// dispatch type built FROM this configuration during the variant
279/// selection pipeline.
280///
281/// Source: `stages.json` `stages[].risk_measure`.
282/// See [Input Scenarios §1.7](input-scenarios.md).
283#[derive(Debug, Clone, Copy, PartialEq)]
284#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
285pub enum StageRiskConfig {
286    /// Risk-neutral expected value. No additional parameters.
287    Expectation,
288
289    /// Convex combination of expectation and `CVaR`.
290    /// See [Risk Measures](../math/risk-measures.md) for the
291    /// mathematical formulation.
292    CVaR {
293        /// Confidence level `alpha` in (0, 1].
294        /// `alpha = 0.95` means 5% worst-case scenarios are considered.
295        alpha: f64,
296
297        /// Risk aversion weight `lambda` in \[0, 1\].
298        /// `lambda = 0` reduces to Expectation; `lambda = 1` is pure `CVaR`.
299        lambda: f64,
300    },
301}
302
303// ---------------------------------------------------------------------------
304// ScenarioSourceConfig (SS12.5)
305// ---------------------------------------------------------------------------
306
307/// Scenario source configuration for one stage.
308///
309/// Groups the scenario-related settings. Sourced from
310/// `stages.json` `scenario_source` and per-stage overrides.
311///
312/// See [Input Scenarios §1.4, §1.8](input-scenarios.md).
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
315pub struct ScenarioSourceConfig {
316    /// Number of noise realizations per stage for both the opening
317    /// tree and forward pass.
318    /// Must be positive. Controls the per-stage branching factor.
319    pub branching_factor: usize,
320
321    /// Algorithm for generating noise vectors in the opening tree.
322    /// Orthogonal to `SamplingScheme`, which selects the noise
323    /// source (in-sample, external, historical).
324    /// Can vary per stage, allowing adaptive strategies (e.g., LHS
325    /// for near-term, SAA for distant stages).
326    pub noise_method: NoiseMethod,
327}
328
329// ---------------------------------------------------------------------------
330// Stage (SS12.6)
331// ---------------------------------------------------------------------------
332
333/// A single stage in the multi-stage stochastic optimization problem.
334///
335/// Stages partition the study horizon into decision periods. Each stage
336/// has a temporal extent, block structure, scenario configuration, risk
337/// parameters, and state variable flags. Stages are sorted by `id` in
338/// canonical order after loading (see Design Principles §3).
339///
340/// Study stages have non-negative IDs; pre-study stages (used only for
341/// PAR model lag initialization) have negative IDs. Pre-study stages
342/// carry only `id`, `start_date`, `end_date`, and `season_id` — their
343/// blocks, risk, and sampling fields are unused.
344///
345/// This struct does NOT contain LP-related fields (variable indices,
346/// constraint counts, coefficient arrays). Those belong to the
347/// downstream solver crate performance layer — see Solver Abstraction SS11.
348///
349/// Source: `stages.json` `stages[]` and `pre_study_stages[]`.
350/// See [Input Scenarios §1.4](input-scenarios.md).
351#[derive(Debug, Clone, PartialEq)]
352#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
353pub struct Stage {
354    // -- Identity and temporal extent --
355    /// 0-based index of this stage in the canonical-ordered stages
356    /// vector. Used for array indexing into per-stage data structures
357    /// (cuts, results, penalty arrays). Assigned during loading after
358    /// sorting by `id`.
359    pub index: usize,
360
361    /// Unique stage identifier from `stages.json`.
362    /// Non-negative for study stages, negative for pre-study stages.
363    /// The `id` is the domain-level identifier; `index` is the
364    /// internal array position.
365    pub id: i32,
366
367    /// Stage start date (inclusive). Parsed from ISO 8601 string.
368    /// Uses `chrono::NaiveDate` — timezone-free calendar date, which
369    /// is appropriate because stage boundaries are calendar concepts,
370    /// not instants in time.
371    pub start_date: NaiveDate,
372
373    /// Stage end date (exclusive). Parsed from ISO 8601 string.
374    /// The stage duration is `end_date - start_date`.
375    pub end_date: NaiveDate,
376
377    /// Season index linking to [`SeasonDefinition`]. Maps this stage to
378    /// a position in the seasonal cycle (e.g., month 0-11 for monthly).
379    /// Required for PAR model coefficient lookup and inflow history
380    /// aggregation. `None` for stages without seasonal structure.
381    pub season_id: Option<usize>,
382
383    // -- Block structure --
384    /// Ordered list of load blocks within this stage.
385    /// Sorted by block index (0, 1, ..., n-1). The sum of all block
386    /// `duration_hours` must equal the total stage duration in hours.
387    pub blocks: Vec<Block>,
388
389    /// Block formulation mode for this stage.
390    /// Can vary per stage (e.g., chronological for near-term,
391    /// parallel for distant stages).
392    /// See [Block Formulations](../math/block-formulations.md).
393    pub block_mode: BlockMode,
394
395    // -- State, risk, and sampling --
396    /// State variable flags controlling which variables carry state
397    /// from this stage to the next.
398    pub state_config: StageStateConfig,
399
400    /// Risk measure configuration for this stage.
401    /// Can vary per stage (e.g., `CVaR` for near-term, Expectation
402    /// for distant stages).
403    pub risk_config: StageRiskConfig,
404
405    /// Scenario source configuration (branching factor and noise method).
406    pub scenario_config: ScenarioSourceConfig,
407}
408
409// ---------------------------------------------------------------------------
410// SeasonDefinition (SS12.7)
411// ---------------------------------------------------------------------------
412
413/// A single season entry mapping a season ID to a calendar period.
414///
415/// Season definitions are required when deriving AR parameters from
416/// inflow history — the season determines how history values are
417/// aggregated into seasonal means and standard deviations.
418///
419/// Source: `stages.json` `season_definitions.seasons[]`.
420/// See [Input Scenarios §1.1](input-scenarios.md).
421#[derive(Debug, Clone, PartialEq)]
422#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
423pub struct SeasonDefinition {
424    /// Season index (0-based). For monthly cycles: 0 = January, ...,
425    /// 11 = December. For weekly cycles: 0-51 (ISO week numbers).
426    pub id: usize,
427
428    /// Human-readable label (e.g., "January", "Q1", "Wet Season").
429    pub label: String,
430
431    /// Calendar month where this season starts (1-12).
432    /// For monthly `cycle_type`, this uniquely identifies the month.
433    pub month_start: u32,
434
435    /// Calendar day where this season starts (1-31).
436    /// Only used when `cycle_type` is `Custom`. Default: 1.
437    pub day_start: Option<u32>,
438
439    /// Calendar month where this season ends (1-12).
440    /// Only used when `cycle_type` is `Custom`.
441    pub month_end: Option<u32>,
442
443    /// Calendar day where this season ends (1-31).
444    /// Only used when `cycle_type` is `Custom`.
445    pub day_end: Option<u32>,
446}
447
448// ---------------------------------------------------------------------------
449// SeasonMap (SS12.8)
450// ---------------------------------------------------------------------------
451
452/// Complete season definitions including cycle type and all season entries.
453///
454/// The `SeasonMap` is the resolved representation of the `season_definitions`
455/// section in `stages.json`. It provides the season-to-calendar mapping
456/// consumed by the PAR model and inflow history aggregation.
457///
458/// Source: `stages.json` `season_definitions`.
459/// See [Input Scenarios §1.1](input-scenarios.md).
460#[derive(Debug, Clone, PartialEq)]
461#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
462pub struct SeasonMap {
463    /// Cycle type controlling how season IDs map to calendar periods.
464    pub cycle_type: SeasonCycleType,
465
466    /// Season entries sorted by `id`. Length depends on `cycle_type`:
467    /// 12 for `Monthly`, 52 for `Weekly`, user-defined for `Custom`.
468    pub seasons: Vec<SeasonDefinition>,
469}
470
471impl SeasonMap {
472    /// Resolve a calendar date to a season ID using the cycle definition.
473    ///
474    /// This mapping is purely calendar-based and does not depend on the study
475    /// horizon — a date from 1931 maps to the same season as a date from 2026
476    /// if they share the same calendar position. This is essential for PAR
477    /// model estimation from historical inflow data that predates the study.
478    ///
479    /// Returns `None` only for `Custom` cycle types where the date does not
480    /// fall within any defined season range. For a well-formed `Monthly` or
481    /// `Weekly` map every in-range calendar date resolves; in particular a
482    /// `Weekly` map treats the year as 52 buckets and folds the ISO-8601 53rd
483    /// week into week 52 (see the `Weekly` arm), so it never returns `None`.
484    #[must_use]
485    pub fn season_for_date(&self, date: NaiveDate) -> Option<usize> {
486        match self.cycle_type {
487            SeasonCycleType::Monthly => {
488                let month = date.month();
489                self.seasons
490                    .iter()
491                    .find(|s| s.month_start == month)
492                    .map(|s| s.id)
493            }
494            SeasonCycleType::Weekly => {
495                // Weekly seasons are a fixed 52-bucket year (`id` 0..=51). ISO-8601
496                // long years (2020, 2026, …) carry a 53rd week of trailing
497                // late-December days; `.min(51)` deliberately folds those into week
498                // 52 — the adjacent same-time-of-year bucket. This is a contract,
499                // NOT `None` and NOT week 1: returning `None` would silently drop
500                // those end-of-year observations from PAR estimation (every caller
501                // skips `None` dates), and week 1 (early January) is the wrong
502                // season for a late-December date. `saturating_sub(1)` guards the
503                // unreachable week-0 case so the index can never underflow.
504                let iso_week = date.iso_week().week();
505                let week_idx = (iso_week.saturating_sub(1)).min(51) as usize;
506                self.seasons.iter().find(|s| s.id == week_idx).map(|s| s.id)
507            }
508            SeasonCycleType::Custom => {
509                let (m, d) = (date.month(), date.day());
510                self.seasons
511                    .iter()
512                    .find(|s| {
513                        let ms = s.month_start;
514                        let ds = s.day_start.unwrap_or(1);
515                        let me = s.month_end.unwrap_or(ms);
516                        let de = s.day_end.unwrap_or(31);
517                        let start = (ms, ds);
518                        let end = (me, de);
519                        let cur = (m, d);
520                        if start <= end {
521                            cur >= start && cur <= end
522                        } else {
523                            cur >= start || cur <= end
524                        }
525                    })
526                    .map(|s| s.id)
527            }
528        }
529    }
530}
531
532// ---------------------------------------------------------------------------
533// Transition (SS12.9)
534// ---------------------------------------------------------------------------
535
536/// A single transition in the policy graph, representing a directed
537/// edge from one stage to another with an associated probability and
538/// optional discount rate override.
539///
540/// Transitions define the stage traversal order for both the forward
541/// and backward passes. In finite horizon mode, transitions form a
542/// linear chain. In cyclic mode, at least one transition creates a
543/// back-edge (`source_id >= target_id`).
544///
545/// Source: `stages.json` `policy_graph.transitions[]`.
546/// See [Input Scenarios §1.2](input-scenarios.md).
547#[derive(Debug, Clone, Copy, PartialEq)]
548#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
549pub struct Transition {
550    /// Source stage ID. Must exist in the stage set.
551    pub source_id: i32,
552
553    /// Target stage ID. Must exist in the stage set.
554    pub target_id: i32,
555
556    /// Transition probability. Outgoing probabilities from each source
557    /// must sum to 1.0 (within tolerance).
558    pub probability: f64,
559
560    /// Per-transition annual discount rate override.
561    /// When `None`, the global `annual_discount_rate` from the
562    /// [`PolicyGraph`] is used. When `Some(r)`, this rate is converted to
563    /// a per-transition factor using the source stage duration:
564    /// `d = 1 / (1 + r)^dt`.
565    /// See [Discount Rate §3](../math/discount-rate.md).
566    pub annual_discount_rate_override: Option<f64>,
567}
568
569// ---------------------------------------------------------------------------
570// PolicyGraph (SS12.10)
571// ---------------------------------------------------------------------------
572
573/// Parsed and validated policy graph defining stage transitions,
574/// horizon type, and global discount rate.
575///
576/// This is the `cobre-core` clarity-first representation loaded from
577/// `stages.json`. It stores the graph topology as specified by the
578/// user. The solver-level `HorizonMode` enum (see Horizon Mode Trait
579/// SS1) is built from this struct during initialization — it
580/// precomputes transition maps, cycle detection, and discount factors
581/// for efficient runtime dispatch.
582///
583/// Cross-reference: [Horizon Mode Trait](../architecture/horizon-mode-trait.md)
584/// defines the `HorizonMode` enum that interprets this graph structure.
585///
586/// Source: `stages.json` `policy_graph`.
587/// See [Input Scenarios §1.2](input-scenarios.md).
588#[derive(Debug, Clone, PartialEq)]
589#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
590pub struct PolicyGraph {
591    /// Horizon type: finite (acyclic chain) or cyclic (infinite periodic).
592    /// Determines which `HorizonMode` variant will be constructed at
593    /// solver initialization.
594    pub graph_type: PolicyGraphType,
595
596    /// Global annual discount rate.
597    /// Converted to per-transition factors using source stage durations:
598    /// `d = 1 / (1 + annual_discount_rate)^dt`.
599    /// A value of 0.0 means no discounting (`d = 1.0` for all transitions).
600    /// For cyclic graphs, must be > 0 for convergence (validation rule 7).
601    /// See [Discount Rate §3](../math/discount-rate.md).
602    pub annual_discount_rate: f64,
603
604    /// Stage transitions with probabilities and optional per-transition
605    /// discount rate overrides. For finite horizon, these form a linear
606    /// chain or DAG. For cyclic horizon, at least one transition has
607    /// `source_id >= target_id` (the back-edge).
608    pub transitions: Vec<Transition>,
609
610    /// Season definitions loaded from `season_definitions` in
611    /// `stages.json`. Required when PAR models or inflow history
612    /// aggregation are used. `None` when no season definitions are
613    /// provided and none are required.
614    pub season_map: Option<SeasonMap>,
615}
616
617impl Default for PolicyGraph {
618    /// Returns a finite-horizon policy graph with no transitions and no discounting.
619    ///
620    /// This is the minimal-viable-solver default: a finite study horizon with
621    /// zero terminal value and no discount factor. `cobre-io` replaces this
622    /// with the fully specified graph loaded from `stages.json`.
623    fn default() -> Self {
624        Self {
625            graph_type: PolicyGraphType::FiniteHorizon,
626            annual_discount_rate: 0.0,
627            transitions: Vec::new(),
628            season_map: None,
629        }
630    }
631}
632
633// ---------------------------------------------------------------------------
634// Tests
635// ---------------------------------------------------------------------------
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    #[test]
642    fn test_block_mode_copy() {
643        let original = BlockMode::Parallel;
644        let copied = original;
645        assert_eq!(original, BlockMode::Parallel);
646        assert_eq!(copied, BlockMode::Parallel);
647
648        let chrono = BlockMode::Chronological;
649        let copied_chrono = chrono;
650        assert_eq!(chrono, BlockMode::Chronological);
651        assert_eq!(copied_chrono, BlockMode::Chronological);
652    }
653
654    #[test]
655    fn test_stage_duration() {
656        let stage = Stage {
657            index: 0,
658            id: 1,
659            start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
660            end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
661            season_id: Some(0),
662            blocks: vec![Block {
663                index: 0,
664                name: "SINGLE".to_string(),
665                duration_hours: 744.0,
666            }],
667            block_mode: BlockMode::Parallel,
668            state_config: StageStateConfig {
669                storage: true,
670                inflow_lags: false,
671            },
672            risk_config: StageRiskConfig::Expectation,
673            scenario_config: ScenarioSourceConfig {
674                branching_factor: 50,
675                noise_method: NoiseMethod::Saa,
676            },
677        };
678
679        assert_eq!(
680            stage.end_date - stage.start_date,
681            chrono::TimeDelta::days(31)
682        );
683    }
684
685    #[test]
686    fn test_policy_graph_construction() {
687        let transitions = vec![
688            Transition {
689                source_id: 1,
690                target_id: 2,
691                probability: 1.0,
692                annual_discount_rate_override: None,
693            },
694            Transition {
695                source_id: 2,
696                target_id: 3,
697                probability: 1.0,
698                annual_discount_rate_override: Some(0.08),
699            },
700            Transition {
701                source_id: 3,
702                target_id: 4,
703                probability: 1.0,
704                annual_discount_rate_override: None,
705            },
706        ];
707
708        let graph = PolicyGraph {
709            graph_type: PolicyGraphType::FiniteHorizon,
710            annual_discount_rate: 0.06,
711            transitions,
712            season_map: None,
713        };
714
715        assert_eq!(graph.graph_type, PolicyGraphType::FiniteHorizon);
716        assert!((graph.annual_discount_rate - 0.06).abs() < f64::EPSILON);
717        assert_eq!(graph.transitions.len(), 3);
718        assert_eq!(
719            graph.transitions[1].annual_discount_rate_override,
720            Some(0.08)
721        );
722        assert!(graph.season_map.is_none());
723    }
724
725    #[test]
726    fn test_season_map_construction() {
727        let months = [
728            "January",
729            "February",
730            "March",
731            "April",
732            "May",
733            "June",
734            "July",
735            "August",
736            "September",
737            "October",
738            "November",
739            "December",
740        ];
741
742        let seasons: Vec<SeasonDefinition> = months
743            .iter()
744            .enumerate()
745            .map(|(i, &label)| SeasonDefinition {
746                id: i,
747                label: label.to_string(),
748                month_start: u32::try_from(i + 1).unwrap(),
749                day_start: None,
750                month_end: None,
751                day_end: None,
752            })
753            .collect();
754
755        let season_map = SeasonMap {
756            cycle_type: SeasonCycleType::Monthly,
757            seasons,
758        };
759
760        assert_eq!(season_map.cycle_type, SeasonCycleType::Monthly);
761        assert_eq!(season_map.seasons.len(), 12);
762        assert_eq!(season_map.seasons[0].label, "January");
763        assert_eq!(season_map.seasons[11].label, "December");
764        assert_eq!(season_map.seasons[0].month_start, 1);
765        assert_eq!(season_map.seasons[11].month_start, 12);
766    }
767
768    #[test]
769    fn test_weekly_season_iso_week_53_folds_into_week_52() {
770        // 52-bucket weekly map (ids 0..=51). `month_start` is unused for `Weekly`
771        // resolution but must be a valid month.
772        let seasons: Vec<SeasonDefinition> = (0..52)
773            .map(|i| SeasonDefinition {
774                id: i,
775                label: format!("W{:02}", i + 1),
776                month_start: 1,
777                day_start: None,
778                month_end: None,
779                day_end: None,
780            })
781            .collect();
782        let season_map = SeasonMap {
783            cycle_type: SeasonCycleType::Weekly,
784            seasons,
785        };
786
787        // 2020 is an ISO long year: 2020-12-30 falls in ISO week 53. The contract
788        // folds week 53 into week 52 (id 51) — explicitly NOT `None` and NOT week 1.
789        let week53_date = NaiveDate::from_ymd_opt(2020, 12, 30).unwrap();
790        assert_eq!(
791            week53_date.iso_week().week(),
792            53,
793            "precondition: ISO week 53"
794        );
795        assert_eq!(
796            season_map.season_for_date(week53_date),
797            Some(51),
798            "ISO week 53 must fold into week 52 (id 51), not None or week 1 (id 0)"
799        );
800
801        // A normal week resolves to its own bucket: 2021-01-11 is ISO week 2 -> id 1.
802        let week2_date = NaiveDate::from_ymd_opt(2021, 1, 11).unwrap();
803        assert_eq!(week2_date.iso_week().week(), 2, "precondition: ISO week 2");
804        assert_eq!(season_map.season_for_date(week2_date), Some(1));
805    }
806
807    #[cfg(feature = "serde")]
808    #[test]
809    fn test_policy_graph_serde_roundtrip() {
810        let graph = PolicyGraph {
811            graph_type: PolicyGraphType::FiniteHorizon,
812            annual_discount_rate: 0.06,
813            transitions: vec![
814                Transition {
815                    source_id: 1,
816                    target_id: 2,
817                    probability: 1.0,
818                    annual_discount_rate_override: None,
819                },
820                Transition {
821                    source_id: 2,
822                    target_id: 3,
823                    probability: 1.0,
824                    annual_discount_rate_override: None,
825                },
826            ],
827            season_map: None,
828        };
829
830        let json = serde_json::to_string(&graph).unwrap();
831
832        // Acceptance criterion: JSON must contain both key-value pairs.
833        assert!(
834            json.contains("\"graph_type\":\"FiniteHorizon\""),
835            "JSON did not contain expected graph_type: {json}"
836        );
837        assert!(
838            json.contains("\"annual_discount_rate\":0.06"),
839            "JSON did not contain expected annual_discount_rate: {json}"
840        );
841
842        // Round-trip must produce an equal value.
843        let deserialized: PolicyGraph = serde_json::from_str(&json).unwrap();
844        assert_eq!(graph, deserialized);
845    }
846}