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 #[must_use]
73 pub fn into_value(self) -> T {
74 self.value
75 }
76}
77
78pub struct ScenarioSet<T> {
80 scenarios: Arc<[Scenario<T>]>,
81 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 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#[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 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 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}