1use std::collections::HashMap;
2use std::fmt;
3use std::sync::Arc;
4
5use crate::Error;
6use crate::validation::valid_nonempty_text;
7
8pub const SCENARIO_PROBABILITY_TOLERANCE: f64 = 1e-12;
10
11#[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#[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 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 #[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
95pub struct ScenarioSet<T> {
97 scenarios: Arc<Vec<Scenario<T>>>,
98 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 pub fn get(&self, id: &str) -> Option<&T> {
177 self.entry(id).map(Scenario::value)
178 }
179
180 #[must_use]
182 pub fn entry(&self, id: &str) -> Option<&Scenario<T>> {
183 self.entry_at(*self.index.get(id)?)
184 }
185
186 #[must_use]
188 pub fn get_at(&self, position: usize) -> Option<&T> {
189 self.entry_at(position).map(Scenario::value)
190 }
191
192 #[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 pub fn get_mut(&mut self, id: &str) -> Option<&mut T> {
216 self.entry_mut(id).map(Scenario::value_mut)
217 }
218
219 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 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 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 pub fn iter_mut(&mut self) -> impl ExactSizeIterator<Item = &mut Scenario<T>> {
240 Arc::make_mut(&mut self.scenarios).iter_mut()
241 }
242
243 #[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#[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 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 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}