use std::collections::{HashMap, HashSet};
use cobre_core::temporal::{PolicyGraphType, StageRiskConfig};
use super::super::{ErrorKind, ValidationContext, schema::ParsedData};
use super::PROB_TOLERANCE;
pub(super) fn check_stage_structure(data: &ParsedData, ctx: &mut ValidationContext) {
let graph = &data.stages.policy_graph;
let stages = &data.stages.stages;
let stage_ids: HashSet<i32> = stages.iter().map(|s| s.id).collect();
for transition in &graph.transitions {
if !stage_ids.contains(&transition.source_id) {
ctx.add_error(
ErrorKind::InvalidValue,
"stages.json",
None::<&str>,
format!(
"transition source_id {} does not refer to a valid stage ID",
transition.source_id
),
);
}
if !stage_ids.contains(&transition.target_id) {
ctx.add_error(
ErrorKind::InvalidValue,
"stages.json",
None::<&str>,
format!(
"transition target_id {} does not refer to a valid stage ID",
transition.target_id
),
);
}
}
let mut prob_sums: HashMap<i32, f64> = HashMap::new();
for transition in &graph.transitions {
*prob_sums.entry(transition.source_id).or_insert(0.0) += transition.probability;
}
let mut sorted_sources: Vec<i32> = prob_sums.keys().copied().collect();
sorted_sources.sort_unstable();
for source_id in sorted_sources {
let total = prob_sums[&source_id];
if (total - 1.0).abs() > PROB_TOLERANCE {
ctx.add_error(
ErrorKind::InvalidValue,
"stages.json",
None::<&str>,
format!(
"outgoing transition probabilities from stage {source_id} sum to {total:.8} \
(expected 1.0 ±{PROB_TOLERANCE}); probability must sum to 1.0"
),
);
}
}
if graph.graph_type == PolicyGraphType::Cyclic && graph.annual_discount_rate <= 0.0 {
ctx.add_error(
ErrorKind::InvalidValue,
"stages.json",
None::<&str>,
format!(
"cyclic policy graph requires annual_discount_rate > 0.0 for convergence, \
got {}",
graph.annual_discount_rate
),
);
}
for stage in stages {
for block in &stage.blocks {
if block.duration_hours <= 0.0 {
ctx.add_error(
ErrorKind::InvalidValue,
"stages.json",
Some(format!("Stage {}", stage.id)),
format!(
"Stage {}: block has duration_hours {} which is not > 0.0; \
block duration must be positive",
stage.id, block.duration_hours
),
);
}
}
}
for stage in stages {
if let StageRiskConfig::CVaR { alpha, lambda } = stage.risk_config {
if alpha <= 0.0 || alpha > 1.0 {
ctx.add_error(
ErrorKind::InvalidValue,
"stages.json",
Some(format!("Stage {}", stage.id)),
format!(
"Stage {}: CVaR alpha ({alpha}) must be in (0, 1]; \
alpha must be a valid tail probability",
stage.id
),
);
}
if !(0.0..=1.0).contains(&lambda) {
ctx.add_error(
ErrorKind::InvalidValue,
"stages.json",
Some(format!("Stage {}", stage.id)),
format!(
"Stage {}: CVaR lambda ({lambda}) must be in [0, 1]; \
lambda is the CVaR mixing weight",
stage.id
),
);
}
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
clippy::too_many_lines,
clippy::doc_markdown,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)]
mod tests {
use super::super::test_support::*;
use super::super::validate_semantic_stages_penalties_scenarios;
use cobre_core::temporal::{Block, PolicyGraphType, StageRiskConfig, Transition};
use crate::validation::{ErrorKind, ValidationContext};
#[test]
fn test_5b_transition_invalid_source_id() {
let mut stages = make_stages_5b(vec![0, 1]);
stages.policy_graph.transitions = vec![Transition {
source_id: 99, target_id: 1,
probability: 1.0,
annual_discount_rate_override: None,
}];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
assert!(
errors.iter().any(|e| e.kind == ErrorKind::InvalidValue),
"should have InvalidValue for invalid source_id"
);
}
#[test]
fn test_5b_transition_invalid_target_id() {
let mut stages = make_stages_5b(vec![0, 1]);
stages.policy_graph.transitions = vec![Transition {
source_id: 0,
target_id: 99, probability: 1.0,
annual_discount_rate_override: None,
}];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::InvalidValue),
"should have InvalidValue for invalid target_id"
);
}
#[test]
fn test_5b_transition_probability_sum_wrong() {
let mut stages = make_stages_5b(vec![0, 1]);
stages.policy_graph.transitions = vec![Transition {
source_id: 0,
target_id: 1,
probability: 0.5, annual_discount_rate_override: None,
}];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(relevant.len(), 1, "exactly 1 InvalidValue error expected");
let msg = &relevant[0].message;
assert!(
msg.contains("probability"),
"message should contain 'probability', got: {msg}"
);
assert!(
msg.contains("stage 0"),
"message should contain 'stage 0', got: {msg}"
);
}
#[test]
fn test_5b_transition_probability_sum_valid() {
let mut stages = make_stages_5b(vec![0, 1, 2]);
stages.policy_graph.transitions = vec![
Transition {
source_id: 0,
target_id: 1,
probability: 0.6,
annual_discount_rate_override: None,
},
Transition {
source_id: 0,
target_id: 2,
probability: 0.4,
annual_discount_rate_override: None,
},
];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let prob_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
prob_errors.is_empty(),
"valid probability sum should produce no InvalidValue errors, got: {prob_errors:?}"
);
}
#[test]
fn test_5b_cyclic_zero_discount_rate() {
let mut stages = make_stages_5b(vec![0]);
stages.policy_graph.graph_type = PolicyGraphType::Cyclic;
stages.policy_graph.annual_discount_rate = 0.0;
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::InvalidValue),
"cyclic with 0 discount rate should produce InvalidValue"
);
}
#[test]
fn test_5b_cyclic_positive_discount_rate_valid() {
let mut stages = make_stages_5b(vec![0]);
stages.policy_graph.graph_type = PolicyGraphType::Cyclic;
stages.policy_graph.annual_discount_rate = 0.06;
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let discount_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
discount_errors.is_empty(),
"cyclic with positive discount rate should produce no error, got: {discount_errors:?}"
);
}
#[test]
fn test_5b_block_zero_duration() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].blocks = vec![Block {
index: 0,
name: "Peak".to_string(),
duration_hours: 0.0, }];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::InvalidValue),
"zero duration block should produce InvalidValue"
);
}
#[test]
fn test_5b_block_positive_duration_valid() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].blocks = vec![Block {
index: 0,
name: "Peak".to_string(),
duration_hours: 168.0,
}];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
errors.is_empty(),
"positive block duration should produce no error, got: {errors:?}"
);
}
#[test]
fn test_5b_cvar_alpha_zero_invalid() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].risk_config = StageRiskConfig::CVaR {
alpha: 0.0, lambda: 0.5,
};
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::InvalidValue),
"CVaR alpha=0.0 should produce InvalidValue"
);
}
#[test]
fn test_5b_cvar_lambda_out_of_range() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].risk_config = StageRiskConfig::CVaR {
alpha: 0.95,
lambda: -0.1, };
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::InvalidValue),
"CVaR lambda=-0.1 should produce InvalidValue"
);
}
}