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    /// Mutably borrow the scenario value. Identity and probability stay
73    /// immutable so a set's validated index and probability sum remain valid.
74    pub const fn value_mut(&mut self) -> &mut T {
75        &mut self.value
76    }
77
78    #[must_use]
79    pub fn into_value(self) -> T {
80        self.value
81    }
82
83    /// Consume the entry and map its value while retaining its identity and
84    /// probability.
85    #[must_use]
86    pub fn map_value<U>(self, map: impl FnOnce(T) -> U) -> Scenario<U> {
87        Scenario {
88            id: self.id,
89            probability: self.probability,
90            value: map(self.value),
91        }
92    }
93}
94
95/// Named alternatives with no implied time order.
96pub struct ScenarioSet<T> {
97    scenarios: Arc<Vec<Scenario<T>>>,
98    /// ID to position. Built once, so `get` does not scan the set.
99    index: Arc<HashMap<Box<str>, usize>>,
100}
101
102impl<T> ScenarioSet<T> {
103    pub fn new(scenarios: Vec<Scenario<T>>) -> Result<Self, Error> {
104        let mut ids: HashMap<Box<str>, usize> = HashMap::new();
105        ids.try_reserve(scenarios.len()).map_err(|cause| {
106            Error::new(
107                &crate::codes::VALIDATE_SCENARIO_ALLOCATION_REFUSED,
108                format!(
109                    "cannot reserve identity validation for {} scenarios",
110                    scenarios.len()
111                ),
112            )
113            .with_cause(cause)
114        })?;
115        for (position, scenario) in scenarios.iter().enumerate() {
116            if ids.insert(scenario.id.as_str().into(), position).is_some() {
117                return Err(Error::new(
118                    &crate::codes::VALIDATE_SCENARIO_DUPLICATE_ID,
119                    format!("duplicate scenario ID `{}`", scenario.id),
120                ));
121            }
122        }
123
124        let probability_count = scenarios
125            .iter()
126            .filter(|scenario| scenario.probability.is_some())
127            .count();
128        if probability_count != 0
129            && probability_count != scenarios.len()
130            && let Some(missing) = scenarios
131                .iter()
132                .find(|scenario| scenario.probability.is_none())
133        {
134            return Err(Error::new(
135                &crate::codes::VALIDATE_SCENARIO_MISSING_PROBABILITY,
136                format!("scenario `{}` has no probability", missing.id),
137            ));
138        }
139
140        if probability_count != 0 {
141            for scenario in &scenarios {
142                let Some(probability) = scenario.probability else {
143                    return Err(Error::new(
144                        &crate::codes::VALIDATE_SCENARIO_MISSING_PROBABILITY,
145                        format!("scenario `{}` has no probability", scenario.id),
146                    ));
147                };
148                if !probability.is_finite() || probability < 0.0 {
149                    return Err(Error::new(
150                        &crate::codes::VALIDATE_SCENARIO_INVALID_PROBABILITY,
151                        format!(
152                            "scenario `{}` probability must be finite and nonnegative; found {probability}",
153                            scenario.id
154                        ),
155                    ));
156                }
157            }
158            let sum = compensated_sum(scenarios.iter().filter_map(|scenario| scenario.probability));
159            if !sum.is_finite() || (sum - 1.0).abs() > SCENARIO_PROBABILITY_TOLERANCE {
160                return Err(Error::new(
161                    &crate::codes::VALIDATE_SCENARIO_PROBABILITY_SUM,
162                    format!("scenario probabilities must sum to one; found {sum}"),
163                ));
164            }
165        }
166
167        Ok(Self {
168            scenarios: Arc::new(scenarios),
169            index: Arc::new(ids),
170        })
171    }
172
173    #[must_use]
174    /// Look one scenario value up by its stable ID. Constant time: IDs are
175    /// case sensitive and never normalized, and the index is built once.
176    pub fn get(&self, id: &str) -> Option<&T> {
177        self.entry(id).map(Scenario::value)
178    }
179
180    /// Look up the complete scenario entry by its stable ID.
181    #[must_use]
182    pub fn entry(&self, id: &str) -> Option<&Scenario<T>> {
183        self.entry_at(*self.index.get(id)?)
184    }
185
186    /// Look up one scenario value by its insertion position.
187    #[must_use]
188    pub fn get_at(&self, position: usize) -> Option<&T> {
189        self.entry_at(position).map(Scenario::value)
190    }
191
192    /// Look up one complete scenario entry by its insertion position.
193    #[must_use]
194    pub fn entry_at(&self, position: usize) -> Option<&Scenario<T>> {
195        self.scenarios.get(position)
196    }
197
198    pub fn iter(&self) -> impl ExactSizeIterator<Item = &Scenario<T>> {
199        self.scenarios.iter()
200    }
201
202    #[must_use]
203    pub fn len(&self) -> usize {
204        self.scenarios.len()
205    }
206
207    #[must_use]
208    pub fn is_empty(&self) -> bool {
209        self.scenarios.is_empty()
210    }
211}
212
213impl<T: Clone> ScenarioSet<T> {
214    /// Mutably borrow one scenario value by ID through copy on write.
215    pub fn get_mut(&mut self, id: &str) -> Option<&mut T> {
216        self.entry_mut(id).map(Scenario::value_mut)
217    }
218
219    /// Mutably borrow one complete scenario entry by ID through copy on
220    /// write. Its public mutable surface exposes only the value, preserving
221    /// the validated ID index and probability sum.
222    pub fn entry_mut(&mut self, id: &str) -> Option<&mut Scenario<T>> {
223        let position = *self.index.get(id)?;
224        self.entry_at_mut(position)
225    }
226
227    /// Mutably borrow one scenario value by insertion position.
228    pub fn get_at_mut(&mut self, position: usize) -> Option<&mut T> {
229        self.entry_at_mut(position).map(Scenario::value_mut)
230    }
231
232    /// Mutably borrow one complete scenario entry by insertion position.
233    pub fn entry_at_mut(&mut self, position: usize) -> Option<&mut Scenario<T>> {
234        Arc::make_mut(&mut self.scenarios).get_mut(position)
235    }
236
237    /// Iterate over mutable scenario entries through one copy on write
238    /// detachment. Entry identities and probabilities remain immutable.
239    pub fn iter_mut(&mut self) -> impl ExactSizeIterator<Item = &mut Scenario<T>> {
240        Arc::make_mut(&mut self.scenarios).iter_mut()
241    }
242
243    /// Consume the set and map each scenario value while retaining identities,
244    /// probabilities, order, and the already validated identity index. A
245    /// uniquely owned set moves its values without cloning them; a shared set
246    /// first performs the copy on write split.
247    #[must_use]
248    pub fn map_values<U>(self, mut map: impl FnMut(T) -> U) -> ScenarioSet<U> {
249        let scenarios = Arc::unwrap_or_clone(self.scenarios)
250            .into_iter()
251            .map(|scenario| scenario.map_value(&mut map))
252            .collect();
253        ScenarioSet {
254            scenarios: Arc::new(scenarios),
255            index: self.index,
256        }
257    }
258}
259
260impl<T> Clone for ScenarioSet<T> {
261    fn clone(&self) -> Self {
262        Self {
263            scenarios: Arc::clone(&self.scenarios),
264            index: Arc::clone(&self.index),
265        }
266    }
267}
268
269// The ID index is a derived view of `scenarios`, so printing it would only
270// repeat the IDs already shown.
271#[expect(
272    clippy::missing_fields_in_debug,
273    reason = "the index is derived from the scenarios it points into"
274)]
275impl<T: fmt::Debug> fmt::Debug for ScenarioSet<T> {
276    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
277        formatter
278            .debug_struct("ScenarioSet")
279            .field("scenarios", &self.scenarios)
280            .finish()
281    }
282}
283
284fn compensated_sum(values: impl IntoIterator<Item = f64>) -> f64 {
285    let mut sum = 0.0;
286    let mut correction = 0.0;
287    for value in values {
288        let corrected = value - correction;
289        let next = sum + corrected;
290        correction = (next - sum) - corrected;
291        sum = next;
292    }
293    sum
294}
295
296#[cfg(test)]
297mod tests {
298
299    #[test]
300    fn lookup_is_indexed_and_stays_correct_at_scale() {
301        // A linear scan is correct but not what the design allows, so this
302        // exercises enough entries that a scan would be visible in a profile
303        // and asserts the last one is reachable directly.
304        let scenarios: Vec<_> = (0..20_000)
305            .map(|index| Scenario::new(ScenarioId::new(format!("s{index}")).unwrap(), None, index))
306            .collect();
307        let set = ScenarioSet::new(scenarios).unwrap();
308        assert_eq!(set.get("s19999"), Some(&19_999));
309        assert_eq!(set.get("s0"), Some(&0));
310        assert!(set.get("S0").is_none(), "IDs are case sensitive");
311        assert!(set.get("missing").is_none());
312        // Cloning shares the index rather than rebuilding it.
313        assert_eq!(set.clone().get("s12345"), Some(&12_345),);
314    }
315    use super::*;
316
317    fn scenario(id: &str, probability: Option<f64>) -> Scenario<u8> {
318        Scenario::new(ScenarioId::new(id).unwrap(), probability, 1)
319    }
320
321    #[test]
322    fn identities_are_exact_bounded_and_case_sensitive() {
323        assert!(ScenarioId::new("").is_err());
324        assert!(ScenarioId::new("x".repeat(65_537)).is_err());
325        let set = ScenarioSet::new(vec![scenario("base", None), scenario("Base", None)]).unwrap();
326        assert!(set.get("base").is_some());
327        assert!(set.get("BASE").is_none());
328        assert!(ScenarioSet::new(vec![scenario("same", None), scenario("same", None)]).is_err());
329    }
330
331    #[test]
332    fn probabilities_are_all_or_none_and_sum_with_exact_tolerance() {
333        assert!(ScenarioSet::new(vec![scenario("a", None), scenario("b", None)]).is_ok());
334        assert!(ScenarioSet::new(vec![scenario("a", Some(1.0)), scenario("b", None)]).is_err());
335        assert!(ScenarioSet::new(vec![scenario("a", Some(f64::NAN))]).is_err());
336        assert!(
337            ScenarioSet::new(vec![scenario("a", Some(-0.1)), scenario("b", Some(1.1))]).is_err()
338        );
339        assert!(ScenarioSet::new(vec![scenario("a", Some(0.4)), scenario("b", Some(0.6))]).is_ok());
340        assert!(ScenarioSet::new(vec![scenario("a", Some(1.0 + 2e-12))]).is_err());
341    }
342
343    #[test]
344    fn probability_accumulation_overflow_is_an_error() {
345        let result = ScenarioSet::new(vec![
346            scenario("a", Some(f64::MAX)),
347            scenario("b", Some(f64::MAX)),
348        ]);
349        assert!(result.is_err());
350        assert_eq!(
351            result.unwrap_err().diagnostics()[0].code(),
352            "VALIDATE.SCENARIO.PROBABILITY_SUM"
353        );
354    }
355
356    #[test]
357    fn an_empty_set_is_valid() {
358        let set = ScenarioSet::<u8>::new(Vec::new()).unwrap();
359        assert!(set.is_empty());
360    }
361
362    #[test]
363    fn entry_and_mutable_value_access_preserve_validated_metadata() {
364        let mut edited = ScenarioSet::new(vec![
365            Scenario::new(ScenarioId::new("base").unwrap(), Some(0.25), 1_u8),
366            Scenario::new(ScenarioId::new("high").unwrap(), Some(0.75), 2_u8),
367        ])
368        .unwrap();
369        let original = edited.clone();
370        assert!(Arc::ptr_eq(&edited.scenarios, &original.scenarios));
371        assert!(Arc::ptr_eq(&edited.index, &original.index));
372
373        *edited.get_mut("high").unwrap() = 9;
374        assert_eq!(edited.get("high"), Some(&9));
375        assert_eq!(original.get("high"), Some(&2));
376        assert_eq!(edited.entry("high").unwrap().probability(), Some(0.75));
377        assert_eq!(edited.entry_at(0).unwrap().id().as_str(), "base");
378        assert_eq!(edited.get_at(0), Some(&1));
379        assert!(Arc::ptr_eq(&edited.index, &original.index));
380        assert!(!Arc::ptr_eq(&edited.scenarios, &original.scenarios));
381
382        *edited.entry_at_mut(0).unwrap().value_mut() = 7;
383        for scenario in edited.iter_mut() {
384            *scenario.value_mut() += 1;
385        }
386        assert_eq!(edited.get("base"), Some(&8));
387        assert_eq!(edited.get("high"), Some(&10));
388    }
389
390    #[test]
391    fn consuming_map_moves_unique_values_and_reuses_the_index() {
392        use std::sync::atomic::{AtomicUsize, Ordering};
393
394        static CLONES: AtomicUsize = AtomicUsize::new(0);
395
396        #[derive(Debug)]
397        struct Counted(u8);
398
399        impl Clone for Counted {
400            fn clone(&self) -> Self {
401                CLONES.fetch_add(1, Ordering::Relaxed);
402                Self(self.0)
403            }
404        }
405
406        CLONES.store(0, Ordering::Relaxed);
407        let set = ScenarioSet::new(vec![Scenario::new(
408            ScenarioId::new("base").unwrap(),
409            Some(1.0),
410            Counted(3),
411        )])
412        .unwrap();
413        let index = Arc::clone(&set.index);
414        let mapped = set.map_values(|value| usize::from(value.0));
415        assert_eq!(mapped.get("base"), Some(&3));
416        assert_eq!(mapped.entry("base").unwrap().probability(), Some(1.0));
417        assert!(Arc::ptr_eq(&mapped.index, &index));
418        assert_eq!(CLONES.load(Ordering::Relaxed), 0);
419    }
420}