1use std::collections::HashSet;
4use std::fmt;
5use std::sync::OnceLock;
6
7use prospicio_core::{Error, Period, Result, StreamRng};
8use rayon::prelude::*;
9
10use crate::distortion::Distortion;
11use crate::distribution::{Distribution, check_probability};
12use crate::provenance::{Provenance, SIM_INDEX_SCHEME};
13use crate::risk::var_sorted;
14use crate::sampled::{Empirical, Sampled};
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub enum KeyValue {
23 Int(i64),
24 Text(String),
25 Period(Period),
26}
27
28impl From<i64> for KeyValue {
29 fn from(v: i64) -> Self {
30 Self::Int(v)
31 }
32}
33
34impl From<i32> for KeyValue {
35 fn from(v: i32) -> Self {
36 Self::Int(v.into())
37 }
38}
39
40impl From<&str> for KeyValue {
41 fn from(v: &str) -> Self {
42 Self::Text(v.into())
43 }
44}
45
46impl From<String> for KeyValue {
47 fn from(v: String) -> Self {
48 Self::Text(v)
49 }
50}
51
52impl From<Period> for KeyValue {
53 fn from(v: Period) -> Self {
54 Self::Period(v)
55 }
56}
57
58impl fmt::Display for KeyValue {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match self {
61 Self::Int(v) => write!(f, "{v}"),
62 Self::Text(v) => f.write_str(v),
63 Self::Period(v) => write!(f, "{v}"),
64 }
65 }
66}
67
68pub type ComponentKey = Vec<KeyValue>;
72
73#[derive(Debug, Clone)]
109pub struct PredictiveDistribution {
110 dims: Vec<String>,
111 components: Vec<ComponentKey>,
112 n_sims: usize,
113 draws: Vec<f64>,
115 provenance: Provenance,
116 total: OnceLock<Sampled>,
118}
119
120impl PredictiveDistribution {
121 pub fn from_draws(
128 dims: Vec<String>,
129 components: Vec<ComponentKey>,
130 draws: Vec<f64>,
131 provenance: Provenance,
132 ) -> Result<Self> {
133 validate_keys(&dims, &components)?;
134 let n_components = components.len();
135 if draws.is_empty() || draws.len() % n_components != 0 {
136 return Err(Error::InvalidParameter {
137 name: "draws",
138 value: draws.len() as f64,
139 reason: "length must be a positive multiple of the number of components",
140 });
141 }
142 if let Some(&bad) = draws.iter().find(|x| !x.is_finite()) {
143 return Err(Error::InvalidParameter {
144 name: "draws",
145 value: bad,
146 reason: "must all be finite",
147 });
148 }
149 Ok(Self {
150 dims,
151 components,
152 n_sims: draws.len() / n_components,
153 draws,
154 provenance,
155 total: OnceLock::new(),
156 })
157 }
158
159 pub fn simulate<F>(
168 dims: Vec<String>,
169 components: Vec<ComponentKey>,
170 n_sims: usize,
171 seed: u64,
172 provenance: Provenance,
173 simulate: F,
174 ) -> Result<Self>
175 where
176 F: Fn(&mut StreamRng, &mut [f64]) + Sync,
177 {
178 Self::simulate_with(true, dims, components, n_sims, seed, provenance, simulate)
179 }
180
181 pub fn simulate_with<F>(
186 parallel: bool,
187 dims: Vec<String>,
188 components: Vec<ComponentKey>,
189 n_sims: usize,
190 seed: u64,
191 provenance: Provenance,
192 simulate: F,
193 ) -> Result<Self>
194 where
195 F: Fn(&mut StreamRng, &mut [f64]) + Sync,
196 {
197 validate_keys(&dims, &components)?;
198 if n_sims == 0 {
199 return Err(Error::InvalidParameter {
200 name: "n_sims",
201 value: 0.0,
202 reason: "must be positive",
203 });
204 }
205 let mut draws = vec![0.0; n_sims * components.len()];
206 let run =
207 |(i, row): (usize, &mut [f64])| simulate(&mut StreamRng::new(seed, i as u64), row);
208 if parallel {
209 draws
210 .par_chunks_mut(components.len())
211 .enumerate()
212 .for_each(run);
213 } else {
214 draws.chunks_mut(components.len()).enumerate().for_each(run);
215 }
216 Self::from_draws(
217 dims,
218 components,
219 draws,
220 provenance.seed(seed, SIM_INDEX_SCHEME),
221 )
222 }
223
224 pub fn dims(&self) -> &[String] {
226 &self.dims
227 }
228
229 pub fn components(&self) -> &[ComponentKey] {
231 &self.components
232 }
233
234 pub fn n_sims(&self) -> usize {
236 self.n_sims
237 }
238
239 pub fn n_components(&self) -> usize {
241 self.components.len()
242 }
243
244 pub fn provenance(&self) -> &Provenance {
246 &self.provenance
247 }
248
249 pub fn row(&self, sim: usize) -> Option<&[f64]> {
251 let n = self.n_components();
252 self.draws.get(sim * n..(sim + 1) * n)
253 }
254
255 pub fn draw_matrix(&self) -> &[f64] {
259 &self.draws
260 }
261
262 pub fn component_index(&self, key: &ComponentKey) -> Option<usize> {
287 if let Some(j) = self.components.iter().position(|k| k == key) {
288 return Some(j);
289 }
290 let by_label = |k: &ComponentKey| {
291 k.len() == key.len() && k.iter().zip(key).all(|(c, v)| {
292 c == v
293 || matches!((c, v), (KeyValue::Period(p), KeyValue::Int(_) | KeyValue::Text(_))
294 if p.to_string() == v.to_string())
295 })
296 };
297 let mut matches = self
298 .components
299 .iter()
300 .enumerate()
301 .filter(|(_, k)| by_label(k));
302 match (matches.next(), matches.next()) {
303 (Some((j, _)), None) => Some(j),
304 _ => None,
305 }
306 }
307
308 pub fn marginal(&self, key: &ComponentKey) -> Option<Sampled> {
311 let j = self.component_index(key)?;
312 let column = self
313 .draws
314 .iter()
315 .skip(j)
316 .step_by(self.n_components())
317 .copied()
318 .collect();
319 Some(Sampled::new(column).expect("draws were validated as finite and non-empty"))
320 }
321
322 pub fn aggregate(&self, keep: &[&str]) -> Result<Self> {
329 let mut kept = Vec::with_capacity(keep.len());
330 for (i, name) in keep.iter().enumerate() {
331 let Some(d) = self.dims.iter().position(|dim| dim == name) else {
332 return Err(Error::InvalidParameter {
333 name: "keep",
334 value: i as f64,
335 reason: "names a dimension this distribution does not have",
336 });
337 };
338 if kept.contains(&d) {
339 return Err(Error::InvalidParameter {
340 name: "keep",
341 value: i as f64,
342 reason: "repeats an earlier dimension",
343 });
344 }
345 kept.push(d);
346 }
347
348 let mut groups: Vec<ComponentKey> = Vec::new();
349 let group_of: Vec<usize> = self
350 .components
351 .iter()
352 .map(|key| {
353 let projected: ComponentKey = kept.iter().map(|&d| key[d].clone()).collect();
354 match groups.iter().position(|g| *g == projected) {
355 Some(g) => g,
356 None => {
357 groups.push(projected);
358 groups.len() - 1
359 }
360 }
361 })
362 .collect();
363
364 let n_groups = groups.len();
365 let mut draws = vec![0.0; self.n_sims * n_groups];
366 for (row, out) in self
367 .draws
368 .chunks_exact(self.n_components())
369 .zip(draws.chunks_exact_mut(n_groups))
370 {
371 for (value, &g) in row.iter().zip(&group_of) {
372 out[g] += value;
373 }
374 }
375 Self::from_draws(
376 keep.iter().map(|s| s.to_string()).collect(),
377 groups,
378 draws,
379 self.provenance.clone(),
380 )
381 }
382
383 pub fn resample(&self, rng: &mut StreamRng, n: usize) -> Result<Self> {
386 let mut draws = Vec::with_capacity(n * self.n_components());
387 for _ in 0..n {
388 let sim = ((rng.next_open01() * self.n_sims as f64) as usize).min(self.n_sims - 1);
389 draws.extend_from_slice(self.row(sim).expect("sim < n_sims"));
390 }
391 Self::from_draws(
392 self.dims.clone(),
393 self.components.clone(),
394 draws,
395 self.provenance.clone().param("resampled_rows", n),
396 )
397 }
398
399 pub fn total(&self) -> &Sampled {
401 self.total.get_or_init(|| {
402 let sums = self
403 .draws
404 .chunks_exact(self.n_components())
405 .map(|row| row.iter().sum())
406 .collect();
407 Sampled::new(sums).expect("sums of finite draws are finite")
408 })
409 }
410
411 pub fn allocate(&self, d: &Distortion) -> Vec<f64> {
444 let n = self.n_sims;
445 let m = self.n_components();
446 let totals = self.total().draws();
447 let mut order: Vec<usize> = (0..n).collect();
448 order.sort_by(|&a, &b| totals[a].total_cmp(&totals[b]));
449 let mut weights = d.weights(n);
450 let mut start = 0;
452 while start < n {
453 let mut end = start + 1;
454 while end < n && totals[order[end]] == totals[order[start]] {
455 end += 1;
456 }
457 if end - start > 1 {
458 let mean = weights[start..end].iter().sum::<f64>() / (end - start) as f64;
459 weights[start..end].fill(mean);
460 }
461 start = end;
462 }
463 let mut contributions = vec![0.0; m];
464 for (w, &sim) in weights.iter().zip(&order) {
465 if *w == 0.0 {
466 continue;
467 }
468 let row = &self.draws[sim * m..(sim + 1) * m];
469 for (c, x) in contributions.iter_mut().zip(row) {
470 *c += w * x;
471 }
472 }
473 contributions
474 }
475}
476
477impl PredictiveDistribution {
478 pub fn marginal_expected_shortfall(&self, p: f64) -> Result<Vec<f64>> {
485 Ok(self.allocate(&Distortion::tvar(p)?))
486 }
487
488 pub fn covar(&self, component: &ComponentKey, p: f64, q: f64) -> Result<f64> {
510 check_probability(q)?;
511 let j = self.component_index(component).ok_or_else(|| {
512 Error::Data(format!("no component {component:?} in this distribution"))
513 })?;
514 let m = self.n_components();
515 let own: Vec<f64> = self.draws.iter().skip(j).step_by(m).copied().collect();
516 let mut sorted = own.clone();
517 sorted.sort_by(f64::total_cmp);
518 let threshold = var_sorted(&sorted, p)?;
519 let totals = self.total().draws();
520 let mut stressed: Vec<f64> = own
521 .iter()
522 .zip(totals)
523 .filter(|(x, _)| **x >= threshold)
524 .map(|(_, t)| *t)
525 .collect();
526 stressed.sort_by(f64::total_cmp);
527 var_sorted(&stressed, q)
528 }
529
530 pub fn esscher_allocation(&self, h: f64) -> Result<Vec<f64>> {
536 if !h.is_finite() {
537 return Err(Error::InvalidParameter {
538 name: "h",
539 value: h,
540 reason: "must be finite",
541 });
542 }
543 let m = self.n_components();
544 let w = crate::risk::esscher_weights(self.total().draws(), h);
545 let mut out = vec![0.0; m];
546 for (wi, row) in w.iter().zip(self.draws.chunks_exact(m)) {
547 for (o, x) in out.iter_mut().zip(row) {
548 *o += wi * x;
549 }
550 }
551 Ok(out)
552 }
553}
554
555impl PredictiveDistribution {
556 pub fn blend(models: &[&PredictiveDistribution], weights: &[f64], seed: u64) -> Result<Self> {
583 let first = *models
584 .first()
585 .ok_or_else(|| Error::Data("blend needs at least one model".into()))?;
586 if weights.len() != models.len() {
587 return Err(Error::Data(format!(
588 "{} weights for {} models",
589 weights.len(),
590 models.len()
591 )));
592 }
593 if weights.iter().any(|w| !(w.is_finite() && *w >= 0.0)) {
594 return Err(Error::Data(
595 "weights must be finite and non-negative".into(),
596 ));
597 }
598 let total: f64 = weights.iter().sum();
599 if total <= 0.0 {
600 return Err(Error::Data("weights must not all be zero".into()));
601 }
602 for m in &models[1..] {
603 if m.dims != first.dims || m.components != first.components || m.n_sims != first.n_sims
604 {
605 return Err(Error::Data(
606 "blended distributions need the same dimensions, components and simulations"
607 .into(),
608 ));
609 }
610 }
611 let mut cumulative = Vec::with_capacity(weights.len());
612 let mut acc = 0.0;
613 for w in weights {
614 acc += w / total;
615 cumulative.push(acc);
616 }
617 let m = first.n_components();
618 let mut draws = Vec::with_capacity(first.draws.len());
619 for i in 0..first.n_sims {
620 let u = StreamRng::new(seed, i as u64).next_open01();
621 let k = cumulative
622 .iter()
623 .position(|&c| u < c)
624 .unwrap_or(models.len() - 1);
625 draws.extend_from_slice(&models[k].draws[i * m..(i + 1) * m]);
626 }
627 let mut provenance = Provenance::new("blend").seed(seed, SIM_INDEX_SCHEME);
628 for (model, w) in models.iter().zip(weights) {
629 provenance = provenance.param(model.provenance.model.clone(), w / total);
630 }
631 Self::from_draws(
632 first.dims.clone(),
633 first.components.clone(),
634 draws,
635 provenance,
636 )
637 }
638}
639
640impl PredictiveDistribution {
641 pub fn blend_by_component(
650 models: &[&PredictiveDistribution],
651 weights: &[Vec<f64>],
652 seed: u64,
653 ) -> Result<Self> {
654 let first = *models
655 .first()
656 .ok_or_else(|| Error::Data("blend needs at least one model".into()))?;
657 for m in &models[1..] {
658 if m.dims != first.dims || m.components != first.components || m.n_sims != first.n_sims
659 {
660 return Err(Error::Data(
661 "blended distributions need the same dimensions, components and simulations"
662 .into(),
663 ));
664 }
665 }
666 let c = first.n_components();
667 if weights.len() != c {
668 return Err(Error::Data(format!(
669 "{} weight vectors for {c} components",
670 weights.len()
671 )));
672 }
673 let mut cumulative = Vec::with_capacity(c);
674 for w in weights {
675 if w.len() != models.len() || w.iter().any(|v| !(v.is_finite() && *v >= 0.0)) {
676 return Err(Error::Data(format!(
677 "each component needs {} finite non-negative weights",
678 models.len()
679 )));
680 }
681 let total: f64 = w.iter().sum();
682 if total <= 0.0 {
683 return Err(Error::Data("weights must not all be zero".into()));
684 }
685 let mut acc = 0.0;
686 cumulative.push(
687 w.iter()
688 .map(|v| {
689 acc += v / total;
690 acc
691 })
692 .collect::<Vec<f64>>(),
693 );
694 }
695 let mut draws = Vec::with_capacity(first.draws.len());
696 for i in 0..first.n_sims {
697 let u = StreamRng::new(seed, i as u64).next_open01();
698 for (j, cum) in cumulative.iter().enumerate() {
699 let k = cum.iter().position(|&v| u < v).unwrap_or(models.len() - 1);
700 draws.push(models[k].draws[i * c + j]);
701 }
702 }
703 let mut provenance = Provenance::new("blend_by_component").seed(seed, SIM_INDEX_SCHEME);
704 for model in models {
705 provenance = provenance.param("model", model.provenance.model.clone());
706 }
707 Self::from_draws(
708 first.dims.clone(),
709 first.components.clone(),
710 draws,
711 provenance,
712 )
713 }
714}
715
716impl Distribution for PredictiveDistribution {
717 fn mean(&self) -> f64 {
718 self.total().mean()
719 }
720
721 fn variance(&self) -> f64 {
722 self.total().variance()
723 }
724
725 fn cdf(&self, x: f64) -> f64 {
726 self.total().cdf(x)
727 }
728
729 fn quantile(&self, p: f64) -> Result<f64> {
730 self.total().quantile(p)
731 }
732
733 fn sample(&self, rng: &mut StreamRng, n: usize) -> Vec<f64> {
734 self.total().sample(rng, n)
735 }
736}
737
738impl Empirical for PredictiveDistribution {
739 fn draws(&self) -> &[f64] {
740 self.total().draws()
741 }
742
743 fn sorted(&self) -> &[f64] {
744 self.total().sorted()
745 }
746}
747
748fn validate_keys(dims: &[String], components: &[ComponentKey]) -> Result<()> {
751 if components.is_empty() {
752 return Err(Error::InvalidParameter {
753 name: "components",
754 value: 0.0,
755 reason: "must not be empty",
756 });
757 }
758 let mut seen_dims = HashSet::new();
759 for (i, dim) in dims.iter().enumerate() {
760 if !seen_dims.insert(dim) {
761 return Err(Error::InvalidParameter {
762 name: "dims",
763 value: i as f64,
764 reason: "repeats an earlier dimension",
765 });
766 }
767 }
768 let mut seen_keys = HashSet::new();
769 for (i, key) in components.iter().enumerate() {
770 if key.len() != dims.len() {
771 return Err(Error::InvalidParameter {
772 name: "components",
773 value: i as f64,
774 reason: "key does not have one value per dimension",
775 });
776 }
777 if !seen_keys.insert(key) {
778 return Err(Error::InvalidParameter {
779 name: "components",
780 value: i as f64,
781 reason: "repeats an earlier key",
782 });
783 }
784 }
785 Ok(())
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791
792 #[test]
793 fn period_components_match_their_labels() {
794 use prospicio_core::{Grain, Month};
795 let q = |y, m| {
796 KeyValue::Period(Period::containing(
797 Month::new(y, m).unwrap(),
798 Grain::Quarter,
799 ))
800 };
801 let pd = PredictiveDistribution::from_draws(
802 vec!["lob".into(), "origin".into()],
803 vec![
804 vec![KeyValue::from("auto"), q(2021, 1)],
805 vec![KeyValue::from("auto"), q(2021, 7)],
806 vec![KeyValue::from("home"), q(2021, 7)],
807 ],
808 vec![1.0, 2.0, 3.0],
809 Provenance::new("test"),
810 )
811 .unwrap();
812 let key = |lob: &str, origin: &str| vec![KeyValue::from(lob), KeyValue::from(origin)];
813 assert_eq!(pd.component_index(&key("auto", "2021Q3")), Some(1));
814 assert_eq!(pd.marginal(&key("home", "2021Q3")).unwrap().mean(), 3.0);
815 assert_eq!(pd.component_index(&key("home", "2021Q1")), None);
816 assert_eq!(pd.component_index(&key("auto", "2021")), None);
818 assert_eq!(pd.component_index(&vec![KeyValue::from("auto")]), None);
819
820 let year = KeyValue::Period(Period::containing(Month::january(2021), Grain::Year));
823 let both = PredictiveDistribution::from_draws(
824 vec!["origin".into()],
825 vec![vec![KeyValue::from("2021")], vec![year.clone()]],
826 vec![1.0, 2.0],
827 Provenance::new("test"),
828 )
829 .unwrap();
830 assert_eq!(both.component_index(&vec![KeyValue::from("2021")]), Some(0));
831 assert_eq!(both.component_index(&vec![year]), Some(1));
832 assert_eq!(both.component_index(&vec![KeyValue::from(2021)]), Some(1));
833 }
834
835 #[test]
836 fn mes_and_esscher_allocations_add_up() {
837 let mut rng = StreamRng::new(4, 0);
838 let n = 5000;
839 let mut draws = Vec::with_capacity(2 * n);
840 for _ in 0..n {
841 let z = prospicio_math::special::norm_quantile(rng.next_open01());
842 draws.push(10.0 + 2.0 * z);
843 draws.push(5.0 + z + prospicio_math::special::norm_quantile(rng.next_open01()));
844 }
845 let pd = PredictiveDistribution::from_draws(
846 vec!["lob".into()],
847 vec![vec![KeyValue::from("a")], vec![KeyValue::from("b")]],
848 draws,
849 Provenance::new("test"),
850 )
851 .unwrap();
852 let mes = pd.marginal_expected_shortfall(0.9).unwrap();
853 let tvar = Distortion::tvar(0.9).unwrap().apply_sorted(&{
854 let mut t = pd.total().draws().to_vec();
855 t.sort_by(f64::total_cmp);
856 t
857 });
858 assert!((mes.iter().sum::<f64>() - tvar).abs() < 1e-9);
859 let es = pd.esscher_allocation(0.2).unwrap();
860 let total = crate::risk::esscher(pd.total().draws(), 0.2).unwrap();
861 assert!((es.iter().sum::<f64>() - total).abs() < 1e-9);
862 let means = pd.esscher_allocation(0.0).unwrap();
863 assert!((means[0] - pd.marginal(&vec![KeyValue::from("a")]).unwrap().mean()).abs() < 1e-9);
864 let key = vec![KeyValue::from("a")];
866 assert!(
867 pd.covar(&key, 0.95, 0.5).unwrap() > pd.total().draws().iter().sum::<f64>() / n as f64
868 );
869 assert!(pd.covar(&vec![KeyValue::from("z")], 0.9, 0.5).is_err());
870 }
871 use crate::Lognormal;
872
873 fn key(values: &[KeyValue]) -> ComponentKey {
874 values.to_vec()
875 }
876
877 fn lob_origin() -> PredictiveDistribution {
879 let components = vec![
880 key(&["Auto".into(), 2023.into()]),
881 key(&["Auto".into(), 2024.into()]),
882 key(&["Home".into(), 2023.into()]),
883 key(&["Home".into(), 2024.into()]),
884 ];
885 #[rustfmt::skip]
886 let draws = vec![
887 1.0, 2.0, 10.0, 20.0,
888 3.0, 4.0, 30.0, 40.0,
889 5.0, 6.0, 50.0, 60.0,
890 ];
891 PredictiveDistribution::from_draws(
892 vec!["lob".into(), "origin".into()],
893 components,
894 draws,
895 Provenance::new("test"),
896 )
897 .unwrap()
898 }
899
900 #[test]
901 fn shape_and_rows() {
902 let pd = lob_origin();
903 assert_eq!((pd.n_sims(), pd.n_components()), (3, 4));
904 assert_eq!(pd.row(1), Some(&[3.0, 4.0, 30.0, 40.0][..]));
905 assert_eq!(pd.row(3), None);
906 }
907
908 #[test]
909 fn marginal_is_one_column_in_simulation_order() {
910 let pd = lob_origin();
911 let m = pd.marginal(&key(&["Home".into(), 2023.into()])).unwrap();
912 assert_eq!(m.draws(), [10.0, 30.0, 50.0]);
913 assert!(pd.marginal(&key(&["Home".into(), 2025.into()])).is_none());
914 }
915
916 #[test]
917 fn aggregate_sums_within_each_simulation() {
918 let pd = lob_origin();
919 let by_lob = pd.aggregate(&["lob"]).unwrap();
920 assert_eq!(by_lob.dims(), ["lob"]);
921 assert_eq!(
922 by_lob.components(),
923 [key(&["Auto".into()]), key(&["Home".into()])]
924 );
925 assert_eq!(by_lob.draw_matrix(), [3.0, 30.0, 7.0, 70.0, 11.0, 110.0]);
926
927 let by_origin = pd.aggregate(&["origin"]).unwrap();
928 assert_eq!(
929 by_origin.draw_matrix(),
930 [11.0, 22.0, 33.0, 44.0, 55.0, 66.0]
931 );
932
933 let total = pd.aggregate(&[]).unwrap();
934 assert_eq!(total.components(), [ComponentKey::new()]);
935 assert_eq!(total.draw_matrix(), pd.total().draws());
936 assert_eq!(pd.total().draws(), [33.0, 77.0, 121.0]);
937 }
938
939 #[test]
940 fn aggregate_can_reorder_dimensions() {
941 let pd = lob_origin();
942 let swapped = pd.aggregate(&["origin", "lob"]).unwrap();
943 assert_eq!(swapped.components()[0], key(&[2023.into(), "Auto".into()]));
944 assert_eq!(swapped.draw_matrix(), pd.draw_matrix());
945 }
946
947 #[test]
948 fn aggregate_rejects_unknown_and_repeated_dimensions() {
949 let pd = lob_origin();
950 assert!(pd.aggregate(&["state"]).is_err());
951 assert!(pd.aggregate(&["lob", "lob"]).is_err());
952 }
953
954 #[test]
955 fn blend_keeps_rows_whole_and_checks_shapes() {
956 let pd = |v: f64| {
957 PredictiveDistribution::from_draws(
958 vec!["lob".into()],
959 vec![vec![KeyValue::from("a")], vec![KeyValue::from("b")]],
960 (0..400)
961 .flat_map(|i| [v + i as f64, -(v + i as f64)])
962 .collect(),
963 Provenance::new(format!("m{v}")),
964 )
965 .unwrap()
966 };
967 let (a, b) = (pd(0.0), pd(0.5));
968 let blend = PredictiveDistribution::blend(&[&a, &b], &[1.0, 3.0], 3).unwrap();
969 assert!(blend.total().draws().iter().all(|t| t.abs() < 1e-12));
971 let from_b = (0..400)
972 .filter(|&i| blend.draws[2 * i].fract() != 0.0)
973 .count() as f64;
974 assert!((from_b / 400.0 - 0.75).abs() < 0.08);
975 assert_eq!(
976 blend.provenance().parameters[1],
977 ("m0.5".into(), "0.75".into())
978 );
979 assert!(PredictiveDistribution::blend(&[&a, &b], &[1.0], 3).is_err());
980 assert!(PredictiveDistribution::blend(&[&a, &b], &[0.0, 0.0], 3).is_err());
981 let short = PredictiveDistribution::from_draws(
982 vec!["lob".into()],
983 vec![vec![KeyValue::from("a")], vec![KeyValue::from("b")]],
984 vec![0.0; 4],
985 Provenance::new("short"),
986 )
987 .unwrap();
988 assert!(PredictiveDistribution::blend(&[&a, &short], &[1.0, 1.0], 3).is_err());
989 }
990
991 #[test]
992 fn blend_by_component_matches_blend_with_equal_weights() {
993 let pd = |v: f64| {
994 PredictiveDistribution::from_draws(
995 vec!["lob".into()],
996 vec![vec![KeyValue::from("a")], vec![KeyValue::from("b")]],
997 (0..300)
998 .flat_map(|i| [v + i as f64, v - i as f64])
999 .collect(),
1000 Provenance::new(format!("m{v}")),
1001 )
1002 .unwrap()
1003 };
1004 let (a, b) = (pd(0.0), pd(0.5));
1005 let same = PredictiveDistribution::blend_by_component(
1006 &[&a, &b],
1007 &[vec![1.0, 3.0], vec![1.0, 3.0]],
1008 4,
1009 )
1010 .unwrap();
1011 let plain = PredictiveDistribution::blend(&[&a, &b], &[1.0, 3.0], 4).unwrap();
1012 assert_eq!(same.draws, plain.draws);
1013 let split = PredictiveDistribution::blend_by_component(
1015 &[&a, &b],
1016 &[vec![0.0, 1.0], vec![1.0, 0.0]],
1017 4,
1018 )
1019 .unwrap();
1020 assert!((0..300).all(|i| split.draws[2 * i] == b.draws[2 * i]
1021 && split.draws[2 * i + 1] == a.draws[2 * i + 1]));
1022 assert!(
1023 PredictiveDistribution::blend_by_component(&[&a, &b], &[vec![1.0, 1.0]], 4).is_err()
1024 );
1025 }
1026
1027 #[test]
1028 fn total_quantile_is_not_the_sum_of_marginal_quantiles() {
1029 let pd = PredictiveDistribution::from_draws(
1031 vec!["line".into()],
1032 vec![key(&["A".into()]), key(&["B".into()])],
1033 vec![0.0, 0.0, 0.0, 0.0, 0.0, 100.0, 100.0, 0.0],
1034 Provenance::new("test"),
1035 )
1036 .unwrap();
1037 let a = pd.marginal(&key(&["A".into()])).unwrap();
1038 let b = pd.marginal(&key(&["B".into()])).unwrap();
1039 assert_eq!(a.var(0.75).unwrap() + b.var(0.75).unwrap(), 0.0);
1040 assert_eq!(pd.var(0.75), Ok(100.0));
1041 assert_eq!(pd.tvar(0.5), Ok(100.0));
1042 assert_eq!(pd.mean(), 50.0);
1043 }
1044
1045 #[test]
1046 fn rejects_bad_input() {
1047 let dims = || vec!["origin".to_string()];
1048 let comps = || vec![key(&[2023.into()]), key(&[2024.into()])];
1049 let p = || Provenance::new("test");
1050 let ok = PredictiveDistribution::from_draws(dims(), comps(), vec![1.0, 2.0], p());
1051 assert!(ok.is_ok());
1052 assert!(PredictiveDistribution::from_draws(dims(), comps(), vec![1.0; 3], p()).is_err());
1054 assert!(PredictiveDistribution::from_draws(dims(), comps(), vec![], p()).is_err());
1055 assert!(
1056 PredictiveDistribution::from_draws(dims(), comps(), vec![1.0, f64::NAN], p()).is_err()
1057 );
1058 assert!(PredictiveDistribution::from_draws(dims(), vec![], vec![1.0], p()).is_err());
1060 let short = vec![key(&[])];
1061 assert!(PredictiveDistribution::from_draws(dims(), short, vec![1.0], p()).is_err());
1062 let repeated = vec![key(&[2023.into()]), key(&[2023.into()])];
1063 assert!(PredictiveDistribution::from_draws(dims(), repeated, vec![1.0; 2], p()).is_err());
1064 let two_dims = vec!["origin".to_string(), "origin".to_string()];
1065 let two_keys = vec![key(&[2023.into(), 1.into()])];
1066 assert!(PredictiveDistribution::from_draws(two_dims, two_keys, vec![1.0], p()).is_err());
1067 }
1068
1069 fn uniform_rows(threads: usize) -> PredictiveDistribution {
1070 let pool = rayon::ThreadPoolBuilder::new()
1071 .num_threads(threads)
1072 .build()
1073 .unwrap();
1074 pool.install(|| {
1075 PredictiveDistribution::simulate(
1076 vec!["origin".into()],
1077 (2015..2025).map(|y| key(&[y.into()])).collect(),
1078 2_000,
1079 7,
1080 Provenance::new("test"),
1081 |rng, row| {
1082 for cell in row {
1083 *cell = rng.next_open01();
1084 }
1085 },
1086 )
1087 .unwrap()
1088 })
1089 }
1090
1091 #[test]
1092 fn simulate_is_identical_across_thread_counts() {
1093 let one = uniform_rows(1);
1094 assert_eq!(one.draw_matrix(), uniform_rows(4).draw_matrix());
1095 assert_eq!(one.draw_matrix(), uniform_rows(16).draw_matrix());
1096 assert_eq!(one.provenance().seed, Some(7));
1097 assert_eq!(
1098 one.provenance().stream_scheme.as_deref(),
1099 Some(SIM_INDEX_SCHEME)
1100 );
1101 }
1102
1103 #[test]
1104 fn simulate_row_i_replays_from_stream_i() {
1105 let pd = uniform_rows(4);
1106 let mut rng = StreamRng::new(7, 1234);
1107 let replay: Vec<f64> = (0..10).map(|_| rng.next_open01()).collect();
1108 assert_eq!(pd.row(1234).unwrap(), replay);
1109 }
1110
1111 #[test]
1112 fn simulate_rejects_zero_sims() {
1113 let r = PredictiveDistribution::simulate(
1114 vec![],
1115 vec![ComponentKey::new()],
1116 0,
1117 1,
1118 Provenance::new("test"),
1119 |_, _| {},
1120 );
1121 assert!(r.is_err());
1122 }
1123
1124 #[test]
1125 fn resample_keeps_rows_whole() {
1126 let pd = lob_origin();
1127 let r = pd.resample(&mut StreamRng::new(3, 0), 50).unwrap();
1128 assert_eq!(r.n_sims(), 50);
1129 for sim in 0..r.n_sims() {
1130 let row = r.row(sim).unwrap();
1131 assert!((0..pd.n_sims()).any(|s| pd.row(s).unwrap() == row));
1132 }
1133 let again = pd.resample(&mut StreamRng::new(3, 0), 50).unwrap();
1134 assert_eq!(r.draw_matrix(), again.draw_matrix());
1135 }
1136
1137 #[test]
1138 fn total_measures_come_from_row_sums() {
1139 let sev = Lognormal::new(0.0, 1.0).unwrap();
1140 let pd = PredictiveDistribution::simulate(
1141 vec!["origin".into()],
1142 (0..5).map(|y| key(&[y.into()])).collect(),
1143 5_000,
1144 11,
1145 Provenance::new("test"),
1146 |rng, row| {
1147 for cell in row {
1148 *cell = sev.quantile(rng.next_open01()).unwrap();
1149 }
1150 },
1151 )
1152 .unwrap();
1153 let sums = Sampled::new(
1154 (0..pd.n_sims())
1155 .map(|s| pd.row(s).unwrap().iter().sum())
1156 .collect(),
1157 )
1158 .unwrap();
1159 assert_eq!(pd.mean(), sums.mean());
1160 assert_eq!(pd.quantile(0.99), sums.quantile(0.99));
1161 assert_eq!(pd.tvar(0.99), sums.tvar(0.99));
1162 assert_eq!(Empirical::draws(&pd), sums.draws());
1163 }
1164
1165 #[test]
1166 fn key_values_display_and_order() {
1167 assert_eq!(KeyValue::from(2024).to_string(), "2024");
1168 assert_eq!(KeyValue::from("Auto").to_string(), "Auto");
1169 assert!(KeyValue::from(1) < KeyValue::from(2));
1170 }
1171
1172 #[test]
1173 fn period_keys_display_order_and_join() {
1174 use prospicio_core::{Grain, Month};
1175
1176 let q = |y, m| Period::containing(Month::new(y, m).unwrap(), Grain::Quarter);
1177 assert_eq!(KeyValue::from(q(2021, 8)).to_string(), "2021Q3");
1178 assert_eq!(KeyValue::from(Period::year(2019)).to_string(), "2019");
1179 assert!(KeyValue::from(q(2021, 3)) < KeyValue::from(q(2021, 4)));
1180 assert_eq!(KeyValue::from(q(2021, 7)), KeyValue::from(q(2021, 9)));
1182 assert_ne!(KeyValue::from(Period::year(2019)), KeyValue::from(2019));
1184
1185 let origins = [Period::year(2019), Period::year(2020)];
1186 let pd = PredictiveDistribution::from_draws(
1187 vec!["lob".into(), "origin".into()],
1188 vec![
1189 vec!["Auto".into(), origins[0].into()],
1190 vec!["Auto".into(), origins[1].into()],
1191 vec!["Home".into(), origins[1].into()],
1192 ],
1193 vec![1.0, 2.0, 4.0, 10.0, 20.0, 40.0],
1194 Provenance::new("test"),
1195 )
1196 .unwrap();
1197 let by_origin = pd.aggregate(&["origin"]).unwrap();
1198 assert_eq!(
1199 by_origin.components(),
1200 &[vec![origins[0].into()], vec![origins[1].into()]]
1201 );
1202 let o2020 = by_origin.marginal(&vec![origins[1].into()]).unwrap();
1203 assert_eq!(o2020.draws(), &[6.0, 60.0]);
1204 }
1205
1206 fn lines(draws: Vec<f64>, m: usize) -> PredictiveDistribution {
1207 let components = (0..m).map(|j| vec![KeyValue::Int(j as i64)]).collect();
1208 PredictiveDistribution::from_draws(
1209 vec!["lob".into()],
1210 components,
1211 draws,
1212 Provenance::new("test"),
1213 )
1214 .unwrap()
1215 }
1216
1217 fn simulated_lines() -> PredictiveDistribution {
1218 use crate::Lognormal;
1219 let a = Lognormal::from_mean_cv(100.0, 0.3).unwrap();
1220 let b = Lognormal::from_mean_cv(50.0, 1.2).unwrap();
1221 PredictiveDistribution::simulate(
1222 vec!["lob".into()],
1223 vec![vec![KeyValue::from("a")], vec![KeyValue::from("b")]],
1224 20_000,
1225 5,
1226 Provenance::new("test"),
1227 |rng, row| {
1228 let x = a.sample(rng, 1)[0];
1229 let y = b.sample(rng, 1)[0];
1230 row.copy_from_slice(&[x, y + 0.5 * x]);
1232 },
1233 )
1234 .unwrap()
1235 }
1236
1237 #[test]
1238 fn allocation_adds_up_to_the_measure_of_the_total() {
1239 let pd = simulated_lines();
1240 for d in [
1241 Distortion::tvar(0.99).unwrap(),
1242 Distortion::wang(0.5).unwrap(),
1243 Distortion::proportional_hazard(0.7).unwrap(),
1244 Distortion::dual_power(3.0).unwrap(),
1245 ] {
1246 let co = pd.allocate(&d);
1247 let whole = pd.distortion(&d);
1248 assert!(
1249 (co.iter().sum::<f64>() - whole).abs() <= 1e-9 * whole,
1250 "{d:?}"
1251 );
1252 }
1253 let co = pd.allocate(&Distortion::tvar(0.0).unwrap());
1255 for (j, c) in co.iter().enumerate() {
1256 let mean =
1257 (0..pd.n_sims()).map(|i| pd.row(i).unwrap()[j]).sum::<f64>() / pd.n_sims() as f64;
1258 assert!((c - mean).abs() <= 1e-9 * mean);
1259 }
1260 }
1261
1262 #[test]
1263 fn cotvar_is_the_conditional_tail_mean() {
1264 let pd = simulated_lines();
1265 let p = 0.95;
1266 let co = pd.allocate(&Distortion::tvar(p).unwrap());
1267 let mut rows: Vec<&[f64]> = (0..pd.n_sims()).map(|i| pd.row(i).unwrap()).collect();
1269 rows.sort_by(|a, b| a.iter().sum::<f64>().total_cmp(&b.iter().sum::<f64>()));
1270 let tail = &rows[19_000..];
1271 for (j, c) in co.iter().enumerate() {
1272 let want = tail.iter().map(|r| r[j]).sum::<f64>() / 1_000.0;
1273 assert!((c - want).abs() <= 1e-9 * want, "{c} vs {want}");
1274 }
1275 }
1276
1277 #[test]
1278 fn comonotonic_parts_get_their_own_measure() {
1279 let x = [3.0, 1.0, 4.0, 1.5, 5.0, 9.0, 2.0, 6.0];
1282 let draws: Vec<f64> = x.iter().flat_map(|&v| [v, 2.0 * v]).collect();
1283 let pd = lines(draws, 2);
1284 let mut sorted = x.to_vec();
1285 sorted.sort_by(f64::total_cmp);
1286 let d = Distortion::wang(0.8).unwrap();
1287 let co = pd.allocate(&d);
1288 let own = d.apply_sorted(&sorted);
1289 assert!((co[0] - own).abs() < 1e-12);
1290 assert!((co[1] - 2.0 * own).abs() < 1e-12);
1291 }
1292
1293 #[test]
1294 fn tied_totals_share_weights() {
1295 let a = lines(vec![1.0, 1.0, 4.0, 1.0, 0.0, 5.0], 2);
1299 let b = lines(vec![1.0, 1.0, 0.0, 5.0, 4.0, 1.0], 2);
1300 let d = Distortion::tvar(2.0 / 3.0).unwrap();
1301 for pd in [a, b] {
1302 let co = pd.allocate(&d);
1303 assert!(
1304 (co[0] - 2.0).abs() < 1e-12 && (co[1] - 3.0).abs() < 1e-12,
1305 "{co:?}"
1306 );
1307 }
1308 }
1309}