cobre-sddp 0.4.2

Stochastic Dual Dynamic Programming (SDDP) for hydrothermal dispatch and energy planning
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
//! Convergence monitor for the SDDP training loop.
//!
//! [`ConvergenceMonitor`] tracks the lower bound (LB), upper bound (UB), gap,
//! and per-iteration history across training iterations, and evaluates the
//! configured stopping rules to determine when training should terminate.
//!
//! ## Design
//!
//! The monitor is a pure computation component: it receives bound values as
//! inputs and produces termination decisions as outputs. It does not run
//! simulations, emit events, or perform checkpointing — those responsibilities
//! belong to the training loop orchestrator.
//!
//! The LB is received as a separate scalar from [`crate::lower_bound::evaluate_lower_bound`]
//! (evaluated after the backward pass). It is **not** derived from the forward
//! synchronisation step. The UB statistics come from [`crate::forward::SyncResult`]
//! produced by [`crate::forward::sync_forward`].
//!
//! ## Gap formula
//!
//! The convergence gap is computed as:
//!
//! `gap = (UB - LB) / max(1.0, |UB|)`
//!
//! The `max(1.0, |UB|)` guard prevents division by zero when the UB is near
//! zero (F-004 resolution).
//!
//! ## Usage
//!
//! ```rust
//! use cobre_sddp::convergence::ConvergenceMonitor;
//! use cobre_sddp::forward::SyncResult;
//! use cobre_sddp::stopping_rule::{StoppingMode, StoppingRule, StoppingRuleSet};
//!
//! let rule_set = StoppingRuleSet {
//!     rules: vec![StoppingRule::IterationLimit { limit: 5 }],
//!     mode: StoppingMode::Any,
//! };
//!
//! let mut monitor = ConvergenceMonitor::new(rule_set);
//!
//! let sync = SyncResult {
//!     global_ub_mean: 110.0,
//!     global_ub_std: 5.0,
//!     ci_95_half_width: 2.0,
//!     sync_time_ms: 10,
//! };
//!
//! let (stop, results) = monitor.update(100.0, &sync);
//! assert!(!stop);
//! assert_eq!(monitor.iteration_count(), 1);
//! assert!((monitor.gap() - 10.0 / 110.0).abs() < 1e-10);
//! ```

use std::time::Instant;

use cobre_core::StoppingRuleResult;

use crate::{
    forward::SyncResult,
    stopping_rule::{MonitorState, StoppingRuleSet},
};

// ---------------------------------------------------------------------------
// ConvergenceMonitor
// ---------------------------------------------------------------------------

/// Tracks bound statistics and evaluates stopping rules across training
/// iterations.
///
/// Constructed once before the training loop begins. On each iteration, the
/// training loop calls [`ConvergenceMonitor::update`] with the latest LB and
/// UB statistics, which returns the termination decision.
///
/// ## Fields (private)
///
/// - `rule_set` — the configured stopping rules and combination mode.
/// - `lower_bound` — latest LB value (0.0 before first update).
/// - `upper_bound` — latest UB mean (0.0 before first update).
/// - `upper_bound_std` — latest UB standard deviation.
/// - `ci_95_half_width` — latest 95% CI half-width.
/// - `gap` — latest convergence gap: `(UB - LB) / max(1.0, |UB|)`.
/// - `lower_bound_history` — all LB values in chronological order.
/// - `iteration_count` — 0-based counter, incremented by each `update` call.
/// - `start_time` — wall-clock origin set at construction.
/// - `shutdown_requested` — set by [`ConvergenceMonitor::set_shutdown`].
/// - `simulation_costs` — set by [`ConvergenceMonitor::set_simulation_costs`].
#[derive(Debug)]
pub struct ConvergenceMonitor {
    rule_set: StoppingRuleSet,
    lower_bound: f64,
    upper_bound: f64,
    upper_bound_std: f64,
    ci_95_half_width: f64,
    gap: f64,
    lower_bound_history: Vec<f64>,
    iteration_count: u64,
    start_time: Instant,
    shutdown_requested: bool,
    simulation_costs: Option<Vec<f64>>,
}

impl ConvergenceMonitor {
    /// Create a new convergence monitor with the given stopping rule set.
    #[must_use]
    pub fn new(rule_set: StoppingRuleSet) -> Self {
        Self {
            rule_set,
            lower_bound: 0.0,
            upper_bound: 0.0,
            upper_bound_std: 0.0,
            ci_95_half_width: 0.0,
            gap: 0.0,
            lower_bound_history: Vec::new(),
            iteration_count: 0,
            start_time: Instant::now(),
            shutdown_requested: false,
            simulation_costs: None,
        }
    }

    /// Update bound statistics and evaluate stopping rules.
    ///
    /// Incorporates the latest lower bound and forward-pass UB statistics,
    /// increments the iteration counter, appends `lb` to the history, and
    /// evaluates the configured stopping rules via [`StoppingRuleSet::evaluate`].
    ///
    /// Returns `(should_stop, results)` where:
    /// - `should_stop` is the combined termination decision.
    /// - `results` lists the evaluation result for every configured rule.
    ///
    /// This method is infallible. Gap computation uses `max(1.0, |UB|)` in the
    /// denominator to guard against division by zero.
    ///
    /// # Arguments
    ///
    /// - `lb` — lower bound from [`crate::lower_bound::evaluate_lower_bound`],
    ///   evaluated after the backward pass.
    /// - `sync_result` — global UB statistics from
    ///   [`crate::forward::sync_forward`], evaluated after the forward pass.
    pub fn update(&mut self, lb: f64, sync_result: &SyncResult) -> (bool, Vec<StoppingRuleResult>) {
        self.lower_bound = lb;
        self.upper_bound = sync_result.global_ub_mean;
        self.upper_bound_std = sync_result.global_ub_std;
        self.ci_95_half_width = sync_result.ci_95_half_width;

        // gap = (UB - LB) / max(1.0, |UB|)  — F-004 resolution
        let denominator = self.upper_bound.abs().max(1.0_f64);
        self.gap = (self.upper_bound - lb) / denominator;

        self.iteration_count += 1;
        self.lower_bound_history.push(lb);

        // Move vecs into MonitorState without cloning. Take them out, evaluate,
        // then restore so the monitor retains its data for the next iteration.
        let history = std::mem::take(&mut self.lower_bound_history);
        let sim_costs = std::mem::take(&mut self.simulation_costs);
        let state = MonitorState {
            iteration: self.iteration_count,
            wall_time_seconds: self.start_time.elapsed().as_secs_f64(),
            lower_bound: self.lower_bound,
            lower_bound_history: history,
            shutdown_requested: self.shutdown_requested,
            simulation_costs: sim_costs,
        };

        let result = self.rule_set.evaluate(&state);
        // Restore the data back into the monitor.
        self.lower_bound_history = state.lower_bound_history;
        self.simulation_costs = state.simulation_costs;
        result
    }

    /// Signal a graceful shutdown request.
    ///
    /// After this call, the next [`ConvergenceMonitor::update`] will return
    /// `(true, results)` with the `GracefulShutdown` rule reporting
    /// `triggered: true`.
    pub fn set_shutdown(&mut self) {
        self.shutdown_requested = true;
    }

    /// Provide simulation costs for the [`crate::stopping_rule::StoppingRule::SimulationBased`] rule.
    ///
    /// The training loop calls this before [`ConvergenceMonitor::update`] on
    /// check iterations where a Monte Carlo simulation has been run. The costs
    /// are forwarded into [`MonitorState::simulation_costs`] during the next
    /// `update` call.
    pub fn set_simulation_costs(&mut self, costs: Vec<f64>) {
        self.simulation_costs = Some(costs);
    }

    /// Current lower bound.
    #[must_use]
    pub fn lower_bound(&self) -> f64 {
        self.lower_bound
    }

    /// Current upper bound mean from the latest forward pass.
    #[must_use]
    pub fn upper_bound(&self) -> f64 {
        self.upper_bound
    }

    /// Current upper bound standard deviation from the latest forward pass.
    #[must_use]
    pub fn upper_bound_std(&self) -> f64 {
        self.upper_bound_std
    }

    /// Current 95% confidence interval half-width (from latest forward pass).
    #[must_use]
    pub fn ci_95_half_width(&self) -> f64 {
        self.ci_95_half_width
    }

    /// Current convergence gap: `(UB - LB) / max(1.0, |UB|)`.
    #[must_use]
    pub fn gap(&self) -> f64 {
        self.gap
    }

    /// Number of completed update calls.
    #[must_use]
    pub fn iteration_count(&self) -> u64 {
        self.iteration_count
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::ConvergenceMonitor;
    use crate::{
        forward::SyncResult,
        stopping_rule::{StoppingMode, StoppingRule, StoppingRuleSet},
    };

    fn make_rule_set(rule: StoppingRule) -> StoppingRuleSet {
        StoppingRuleSet {
            rules: vec![rule],
            mode: StoppingMode::Any,
        }
    }

    fn make_sync(ub_mean: f64) -> SyncResult {
        SyncResult {
            global_ub_mean: ub_mean,
            global_ub_std: 5.0,
            ci_95_half_width: 2.0,
            sync_time_ms: 10,
        }
    }

    fn default_sync() -> SyncResult {
        make_sync(110.0)
    }

    #[test]
    fn new_initializes_all_fields_to_default() {
        let monitor =
            ConvergenceMonitor::new(make_rule_set(StoppingRule::IterationLimit { limit: 10 }));
        assert_eq!(monitor.lower_bound(), 0.0);
        assert_eq!(monitor.upper_bound(), 0.0);
        assert_eq!(monitor.upper_bound_std(), 0.0);
        assert_eq!(monitor.ci_95_half_width(), 0.0);
        assert_eq!(monitor.gap(), 0.0);
        assert_eq!(monitor.iteration_count(), 0);
    }

    #[test]
    fn update_increments_iteration_count() {
        let mut monitor =
            ConvergenceMonitor::new(make_rule_set(StoppingRule::IterationLimit { limit: 100 }));
        monitor.update(100.0, &default_sync());
        assert_eq!(monitor.iteration_count(), 1);
        monitor.update(101.0, &default_sync());
        assert_eq!(monitor.iteration_count(), 2);
    }

    #[test]
    fn update_stores_lb_and_ub_correctly() {
        let mut monitor =
            ConvergenceMonitor::new(make_rule_set(StoppingRule::IterationLimit { limit: 100 }));
        let sync = SyncResult {
            global_ub_mean: 200.0,
            global_ub_std: 10.0,
            ci_95_half_width: 3.0,
            sync_time_ms: 5,
        };
        monitor.update(150.0, &sync);
        assert!((monitor.lower_bound() - 150.0).abs() < 1e-10);
        assert!((monitor.upper_bound() - 200.0).abs() < 1e-10);
        assert!((monitor.upper_bound_std() - 10.0).abs() < 1e-10);
        assert!((monitor.ci_95_half_width() - 3.0).abs() < 1e-10);
    }

    #[test]
    fn gap_formula_uses_max_guard() {
        // UB = 0.5 → denominator = max(1.0, 0.5) = 1.0
        // gap = (0.5 - 100.0) / 1.0 = -99.5
        let mut monitor =
            ConvergenceMonitor::new(make_rule_set(StoppingRule::IterationLimit { limit: 100 }));
        let sync = make_sync(0.5);
        monitor.update(100.0, &sync);
        let expected = (0.5_f64 - 100.0) / 1.0_f64;
        assert!(
            (monitor.gap() - expected).abs() < 1e-10,
            "gap with UB=0.5 must use max guard of 1.0, got {}",
            monitor.gap()
        );
    }

    #[test]
    fn gap_formula_normal_case() {
        // UB = 110, LB = 100 → gap = (110 - 100) / max(1.0, 110.0) = 10/110
        let mut monitor =
            ConvergenceMonitor::new(make_rule_set(StoppingRule::IterationLimit { limit: 100 }));
        let sync = make_sync(110.0);
        monitor.update(100.0, &sync);
        let expected = 10.0_f64 / 110.0_f64;
        assert!(
            (monitor.gap() - expected).abs() < 1e-10,
            "gap must be 10/110, got {}",
            monitor.gap()
        );
    }

    #[test]
    fn lower_bound_history_grows() {
        let mut monitor =
            ConvergenceMonitor::new(make_rule_set(StoppingRule::IterationLimit { limit: 100 }));
        for i in 0..5 {
            monitor.update(f64::from(i) * 10.0, &default_sync());
        }
        assert_eq!(monitor.lower_bound_history.len(), 5);
    }

    #[test]
    fn set_shutdown_triggers_graceful_rule() {
        let rule_set = StoppingRuleSet {
            rules: vec![
                StoppingRule::GracefulShutdown,
                StoppingRule::IterationLimit { limit: 100 },
            ],
            mode: StoppingMode::Any,
        };
        let mut monitor = ConvergenceMonitor::new(rule_set);
        monitor.set_shutdown();
        let (stop, results) = monitor.update(100.0, &default_sync());
        assert!(stop, "should stop after shutdown signal");
        // GracefulShutdown is results[0]
        assert!(
            results[0].triggered,
            "GracefulShutdown result must be triggered"
        );
        assert_eq!(results[0].rule_name, "graceful_shutdown");
    }

    #[test]
    fn set_simulation_costs_populates_monitor_state() {
        // Use a SimulationBased rule evaluated at period=1.
        // Provide simulation costs and verify the rule reaches evaluation
        // (i.e., costs pass through to MonitorState).
        let rule_set = StoppingRuleSet {
            rules: vec![StoppingRule::SimulationBased {
                period: 1,
                distance_tolerance: 1e6, // always trigger if costs present
                replications: 10,
                bound_stability_window: 1,
            }],
            mode: StoppingMode::Any,
        };
        let mut monitor = ConvergenceMonitor::new(rule_set);
        monitor.set_simulation_costs(vec![100.0, 200.0, 300.0]);
        let (_stop, results) = monitor.update(80.0, &default_sync());
        // The SimulationBased rule must have received the costs (it evaluates at
        // iteration 1 which is divisible by period=1) and reached the distance check.
        assert_eq!(results[0].rule_name, "simulation_based");
        // costs are present → detail must NOT contain "no simulation results available"
        assert!(
            !results[0]
                .detail
                .contains("no simulation results available"),
            "detail should not indicate missing costs: {}",
            results[0].detail
        );
    }

    #[test]
    fn iteration_limit_triggers_at_limit() {
        let mut monitor =
            ConvergenceMonitor::new(make_rule_set(StoppingRule::IterationLimit { limit: 3 }));
        let sync = default_sync();
        let (stop1, _) = monitor.update(100.0, &sync);
        let (stop2, _) = monitor.update(100.0, &sync);
        let (stop3, results) = monitor.update(100.0, &sync);
        assert!(!stop1, "should not stop at iteration 1");
        assert!(!stop2, "should not stop at iteration 2");
        assert!(stop3, "should stop at iteration 3 (limit reached)");
        assert!(results[0].triggered);
        assert_eq!(results[0].rule_name, "iteration_limit");
    }

    #[test]
    fn bound_stalling_triggers_when_stable() {
        let monitor = ConvergenceMonitor::new(make_rule_set(StoppingRule::BoundStalling {
            tolerance: 0.01,
            iterations: 3,
        }));
        let sync = default_sync();
        // 4 updates: history after each is [90], [90,99], [90,99,99.5], [90,99,99.5,100]
        // After 4th update: lb_window_start = history[4-3] = history[1] = 99.0
        // Δ = (100 - 99) / max(1, 100) = 1/100 = 0.01 → NOT triggered (tolerance is strict <)
        // Use tolerance=0.011 to trigger
        let rule_set = StoppingRuleSet {
            rules: vec![StoppingRule::BoundStalling {
                tolerance: 0.011,
                iterations: 3,
            }],
            mode: StoppingMode::Any,
        };
        let mut monitor2 = ConvergenceMonitor::new(rule_set);
        let (_, _) = monitor2.update(90.0, &sync);
        let (_, _) = monitor2.update(99.0, &sync);
        let (_, _) = monitor2.update(99.5, &sync);
        let (stop, _) = monitor2.update(100.0, &sync);
        assert!(
            stop,
            "BoundStalling should trigger when improvement is < 0.011"
        );
        // Also verify gap on the last iteration: (110 - 100) / 110 = 10/110
        assert!(
            (monitor2.gap() - 10.0 / 110.0).abs() < 1e-10,
            "gap after 4th update must equal 10/110, got {}",
            monitor2.gap()
        );
        let _ = monitor; // suppress unused warning
    }

    /// AC: IterationLimit(3) in Any mode triggers at the third update.
    #[test]
    fn ac_iteration_limit_triggers_at_third_call() {
        let rule_set = StoppingRuleSet {
            rules: vec![StoppingRule::IterationLimit { limit: 3 }],
            mode: StoppingMode::Any,
        };
        let mut monitor = ConvergenceMonitor::new(rule_set);
        let sync = SyncResult {
            global_ub_mean: 110.0,
            global_ub_std: 5.0,
            ci_95_half_width: 2.0,
            sync_time_ms: 10,
        };
        monitor.update(100.0, &sync);
        monitor.update(100.0, &sync);
        let (stop, results) = monitor.update(100.0, &sync);
        assert!(stop, "third update must trigger IterationLimit(3)");
        assert!(results[0].triggered);
        assert_eq!(results[0].rule_name, "iteration_limit");
    }

    /// AC: gap formula uses |UB| denominator; with UB=110, LB=100 → gap=10/110.
    #[test]
    fn ac_gap_formula_with_ub_110_lb_100() {
        let mut monitor =
            ConvergenceMonitor::new(make_rule_set(StoppingRule::IterationLimit { limit: 100 }));
        let sync = SyncResult {
            global_ub_mean: 110.0,
            global_ub_std: 5.0,
            ci_95_half_width: 2.0,
            sync_time_ms: 10,
        };
        // 4 updates simulating BoundStalling AC scenario
        monitor.update(90.0, &sync);
        monitor.update(99.0, &sync);
        monitor.update(99.5, &sync);
        monitor.update(100.0, &sync);
        let expected = 10.0_f64 / 110.0_f64;
        assert!(
            (monitor.gap() - expected).abs() < 1e-10,
            "gap must equal {expected}, got {}",
            monitor.gap()
        );
    }

    /// AC: `set_shutdown` causes `GracefulShutdown` to trigger on next update.
    #[test]
    fn ac_set_shutdown_triggers_graceful_shutdown_rule() {
        let rule_set = StoppingRuleSet {
            rules: vec![
                StoppingRule::GracefulShutdown,
                StoppingRule::IterationLimit { limit: 100 },
            ],
            mode: StoppingMode::Any,
        };
        let mut monitor = ConvergenceMonitor::new(rule_set);
        monitor.set_shutdown();
        let (stop, results) = monitor.update(100.0, &default_sync());
        assert!(stop);
        // GracefulShutdown is at index 0
        assert!(results[0].triggered);
        assert_eq!(results[0].rule_name, "graceful_shutdown");
    }

    /// AC: `lower_bound` and `iteration_count` track correctly after 2 updates.
    #[test]
    fn ac_lb_and_iteration_count_track_correctly() {
        let mut monitor =
            ConvergenceMonitor::new(make_rule_set(StoppingRule::IterationLimit { limit: 100 }));
        monitor.update(50.0, &default_sync());
        monitor.update(60.0, &default_sync());
        assert!(
            (monitor.lower_bound() - 60.0).abs() < 1e-10,
            "lower_bound must return latest LB 60.0, got {}",
            monitor.lower_bound()
        );
        assert_eq!(monitor.iteration_count(), 2);
    }
}