Skip to main content

powerio_core/
scenario.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::sync::Arc;
4
5use crate::Error;
6use crate::validation::valid_nonempty_text;
7
8/// Absolute tolerance for a complete probability sum.
9pub const SCENARIO_PROBABILITY_TOLERANCE: f64 = 1e-12;
10
11/// Case-sensitive stable identity of one scenario.
12#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct ScenarioId(Box<str>);
14
15impl ScenarioId {
16    pub fn new(id: impl Into<String>) -> Result<Self, Error> {
17        let id = id.into();
18        if !valid_nonempty_text(&id) {
19            return Err(Error::new(
20                &crate::codes::VALIDATE_SCENARIO_INVALID_ID,
21                "a scenario ID must be nonempty and bounded",
22            ));
23        }
24        Ok(Self(id.into_boxed_str()))
25    }
26
27    #[must_use]
28    pub fn as_str(&self) -> &str {
29        &self.0
30    }
31}
32
33impl fmt::Display for ScenarioId {
34    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35        formatter.write_str(&self.0)
36    }
37}
38
39/// One named scenario and its optional probability.
40#[derive(Clone, Debug)]
41pub struct Scenario<T> {
42    id: ScenarioId,
43    probability: Option<f64>,
44    value: T,
45}
46
47impl<T> Scenario<T> {
48    #[must_use]
49    pub const fn new(id: ScenarioId, probability: Option<f64>, value: T) -> Self {
50        Self {
51            id,
52            probability,
53            value,
54        }
55    }
56
57    #[must_use]
58    pub const fn id(&self) -> &ScenarioId {
59        &self.id
60    }
61
62    #[must_use]
63    pub const fn probability(&self) -> Option<f64> {
64        self.probability
65    }
66
67    #[must_use]
68    pub const fn value(&self) -> &T {
69        &self.value
70    }
71
72    #[must_use]
73    pub fn into_value(self) -> T {
74        self.value
75    }
76}
77
78/// Named alternatives with no implied time order.
79pub struct ScenarioSet<T> {
80    scenarios: Arc<[Scenario<T>]>,
81    /// ID to position. Built once, so `get` does not scan the set.
82    index: Arc<HashMap<Box<str>, usize>>,
83}
84
85impl<T> ScenarioSet<T> {
86    pub fn new(scenarios: Vec<Scenario<T>>) -> Result<Self, Error> {
87        let mut ids: HashMap<Box<str>, usize> = HashMap::new();
88        ids.try_reserve(scenarios.len()).map_err(|cause| {
89            Error::new(
90                &crate::codes::VALIDATE_SCENARIO_ALLOCATION_REFUSED,
91                format!(
92                    "cannot reserve identity validation for {} scenarios",
93                    scenarios.len()
94                ),
95            )
96            .with_cause(cause)
97        })?;
98        for (position, scenario) in scenarios.iter().enumerate() {
99            if ids.insert(scenario.id.as_str().into(), position).is_some() {
100                return Err(Error::new(
101                    &crate::codes::VALIDATE_SCENARIO_DUPLICATE_ID,
102                    format!("duplicate scenario ID `{}`", scenario.id),
103                ));
104            }
105        }
106
107        let probability_count = scenarios
108            .iter()
109            .filter(|scenario| scenario.probability.is_some())
110            .count();
111        if probability_count != 0 && probability_count != scenarios.len() {
112            if let Some(missing) = scenarios
113                .iter()
114                .find(|scenario| scenario.probability.is_none())
115            {
116                return Err(Error::new(
117                    &crate::codes::VALIDATE_SCENARIO_MISSING_PROBABILITY,
118                    format!("scenario `{}` has no probability", missing.id),
119                ));
120            }
121        }
122
123        if probability_count != 0 {
124            for scenario in &scenarios {
125                let Some(probability) = scenario.probability else {
126                    return Err(Error::new(
127                        &crate::codes::VALIDATE_SCENARIO_MISSING_PROBABILITY,
128                        format!("scenario `{}` has no probability", scenario.id),
129                    ));
130                };
131                if !probability.is_finite() || probability < 0.0 {
132                    return Err(Error::new(
133                        &crate::codes::VALIDATE_SCENARIO_INVALID_PROBABILITY,
134                        format!(
135                            "scenario `{}` probability must be finite and nonnegative; found {probability}",
136                            scenario.id
137                        ),
138                    ));
139                }
140            }
141            let sum = compensated_sum(scenarios.iter().filter_map(|scenario| scenario.probability));
142            if !sum.is_finite() || (sum - 1.0).abs() > SCENARIO_PROBABILITY_TOLERANCE {
143                return Err(Error::new(
144                    &crate::codes::VALIDATE_SCENARIO_PROBABILITY_SUM,
145                    format!("scenario probabilities must sum to one; found {sum}"),
146                ));
147            }
148        }
149
150        Ok(Self {
151            scenarios: scenarios.into(),
152            index: Arc::new(ids),
153        })
154    }
155
156    #[must_use]
157    /// Look one scenario up by its stable ID. Constant time: IDs are case
158    /// sensitive and never normalized, and the index is built once.
159    pub fn get(&self, id: &str) -> Option<&Scenario<T>> {
160        self.scenarios.get(*self.index.get(id)?)
161    }
162
163    pub fn iter(&self) -> impl ExactSizeIterator<Item = &Scenario<T>> {
164        self.scenarios.iter()
165    }
166
167    #[must_use]
168    pub fn len(&self) -> usize {
169        self.scenarios.len()
170    }
171
172    #[must_use]
173    pub fn is_empty(&self) -> bool {
174        self.scenarios.is_empty()
175    }
176}
177
178impl<T> Clone for ScenarioSet<T> {
179    fn clone(&self) -> Self {
180        Self {
181            scenarios: Arc::clone(&self.scenarios),
182            index: Arc::clone(&self.index),
183        }
184    }
185}
186
187// The ID index is a derived view of `scenarios`, so printing it would only
188// repeat the IDs already shown.
189#[expect(
190    clippy::missing_fields_in_debug,
191    reason = "the index is derived from the scenarios it points into"
192)]
193impl<T: fmt::Debug> fmt::Debug for ScenarioSet<T> {
194    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
195        formatter
196            .debug_struct("ScenarioSet")
197            .field("scenarios", &self.scenarios)
198            .finish()
199    }
200}
201
202fn compensated_sum(values: impl IntoIterator<Item = f64>) -> f64 {
203    let mut sum = 0.0;
204    let mut correction = 0.0;
205    for value in values {
206        let corrected = value - correction;
207        let next = sum + corrected;
208        correction = (next - sum) - corrected;
209        sum = next;
210    }
211    sum
212}
213
214#[cfg(test)]
215mod tests {
216
217    #[test]
218    fn lookup_is_indexed_and_stays_correct_at_scale() {
219        // A linear scan is correct but not what the design allows, so this
220        // exercises enough entries that a scan would be visible in a profile
221        // and asserts the last one is reachable directly.
222        let scenarios: Vec<_> = (0..20_000)
223            .map(|index| Scenario::new(ScenarioId::new(format!("s{index}")).unwrap(), None, index))
224            .collect();
225        let set = ScenarioSet::new(scenarios).unwrap();
226        assert_eq!(set.get("s19999").map(Scenario::value), Some(&19_999));
227        assert_eq!(set.get("s0").map(Scenario::value), Some(&0));
228        assert!(set.get("S0").is_none(), "IDs are case sensitive");
229        assert!(set.get("missing").is_none());
230        // Cloning shares the index rather than rebuilding it.
231        assert_eq!(
232            set.clone().get("s12345").map(Scenario::value),
233            Some(&12_345)
234        );
235    }
236    use super::*;
237
238    fn scenario(id: &str, probability: Option<f64>) -> Scenario<u8> {
239        Scenario::new(ScenarioId::new(id).unwrap(), probability, 1)
240    }
241
242    #[test]
243    fn identities_are_exact_bounded_and_case_sensitive() {
244        assert!(ScenarioId::new("").is_err());
245        assert!(ScenarioId::new("x".repeat(65_537)).is_err());
246        let set = ScenarioSet::new(vec![scenario("base", None), scenario("Base", None)]).unwrap();
247        assert!(set.get("base").is_some());
248        assert!(set.get("BASE").is_none());
249        assert!(ScenarioSet::new(vec![scenario("same", None), scenario("same", None)]).is_err());
250    }
251
252    #[test]
253    fn probabilities_are_all_or_none_and_sum_with_exact_tolerance() {
254        assert!(ScenarioSet::new(vec![scenario("a", None), scenario("b", None)]).is_ok());
255        assert!(ScenarioSet::new(vec![scenario("a", Some(1.0)), scenario("b", None)]).is_err());
256        assert!(ScenarioSet::new(vec![scenario("a", Some(f64::NAN))]).is_err());
257        assert!(
258            ScenarioSet::new(vec![scenario("a", Some(-0.1)), scenario("b", Some(1.1))]).is_err()
259        );
260        assert!(ScenarioSet::new(vec![scenario("a", Some(0.4)), scenario("b", Some(0.6))]).is_ok());
261        assert!(ScenarioSet::new(vec![scenario("a", Some(1.0 + 2e-12))]).is_err());
262    }
263
264    #[test]
265    fn probability_accumulation_overflow_is_an_error() {
266        let result = ScenarioSet::new(vec![
267            scenario("a", Some(f64::MAX)),
268            scenario("b", Some(f64::MAX)),
269        ]);
270        assert!(result.is_err());
271        assert_eq!(
272            result.unwrap_err().diagnostics()[0].code(),
273            "VALIDATE.SCENARIO.PROBABILITY_SUM"
274        );
275    }
276
277    #[test]
278    fn an_empty_set_is_valid() {
279        let set = ScenarioSet::<u8>::new(Vec::new()).unwrap();
280        assert!(set.is_empty());
281    }
282}