Skip to main content

prospicio_prob/
predictive.rs

1//! The joint predictive distribution every model returns.
2
3use 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/// One value of a component key: an integer (a layer number), text (a line
17/// of business) or a [`Period`] (an origin).
18///
19/// A `Period` key compares equal to the same origin in a Triangle, so a
20/// reserve component joins back to its triangle row without conversion.
21#[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
68/// The key of one component: one value per dimension of the distribution,
69/// in the distribution's dimension order, e.g. `["Auto", 2019]` for the
70/// dimensions `["lob", "origin"]`.
71pub type ComponentKey = Vec<KeyValue>;
72
73/// A joint distribution over components (origin years, lines, layers),
74/// stored as simulated draws.
75///
76/// Draws are simulation-major: row `i` holds every component's value in
77/// simulation `i`, so the components keep their dependence. The total's
78/// 99.5% quantile is computed from row sums, not by adding the components'
79/// 99.5% quantiles. See `docs/design/predictive-distribution.md`.
80///
81/// The [`Distribution`] and [`Empirical`] methods describe the **total**
82/// over all components. Use [`marginal`](Self::marginal) for one
83/// component, or [`aggregate`](Self::aggregate) to sum over dimensions.
84///
85/// # Example
86///
87/// ```
88/// use prospicio_prob::{Distribution, Empirical, Lognormal, PredictiveDistribution, Provenance};
89///
90/// let sev = Lognormal::from_mean_cv(100.0, 0.5).unwrap();
91/// let pd = PredictiveDistribution::simulate(
92///     vec!["origin".into()],
93///     vec![vec![2023.into()], vec![2024.into()]],
94///     10_000,
95///     42,
96///     Provenance::new("example"),
97///     |rng, row| {
98///         for cell in row {
99///             *cell = sev.quantile(rng.next_open01()).unwrap();
100///         }
101///     },
102/// )
103/// .unwrap();
104/// assert!((pd.mean() - 200.0).abs() < 2.0);
105/// let origin_2024 = pd.marginal(&vec![2024.into()]).unwrap();
106/// assert!(pd.tvar(0.995).unwrap() > origin_2024.tvar(0.995).unwrap());
107/// ```
108#[derive(Debug, Clone)]
109pub struct PredictiveDistribution {
110    dims: Vec<String>,
111    components: Vec<ComponentKey>,
112    n_sims: usize,
113    /// `n_sims × components.len()`, simulation-major.
114    draws: Vec<f64>,
115    provenance: Provenance,
116    /// Row sums, computed on first use.
117    total: OnceLock<Sampled>,
118}
119
120impl PredictiveDistribution {
121    /// A distribution from draws already laid out simulation-major:
122    /// `draws[i * n_components + j]` is component `j` in simulation `i`.
123    ///
124    /// Fails if `dims` repeats a name, a key does not have one value per
125    /// dimension, keys repeat, there are no components, `draws` is empty
126    /// or not a whole number of rows, or a draw is not finite.
127    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    /// Runs `n_sims` simulations in parallel and collects them.
160    ///
161    /// Simulation `i` calls `simulate(rng, row)` with
162    /// `rng = StreamRng::new(seed, i)` and fills `row`, one value per
163    /// component. Each simulation owns its stream, so the result is
164    /// identical for any number of threads and any row can be replayed
165    /// alone. The seed and [`SIM_INDEX_SCHEME`] are recorded in the
166    /// provenance.
167    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    /// [`PredictiveDistribution::simulate`], run in parallel only when
182    /// `parallel` is true; otherwise every simulation runs in order on the
183    /// calling thread (for a closure that calls a [`crate::Custom`] whose
184    /// callbacks must stay there). The draws are the same either way.
185    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    /// Dimension names, e.g. `["lob", "origin"]`.
225    pub fn dims(&self) -> &[String] {
226        &self.dims
227    }
228
229    /// Component keys, in column order.
230    pub fn components(&self) -> &[ComponentKey] {
231        &self.components
232    }
233
234    /// Number of simulations (rows).
235    pub fn n_sims(&self) -> usize {
236        self.n_sims
237    }
238
239    /// Number of components (columns).
240    pub fn n_components(&self) -> usize {
241        self.components.len()
242    }
243
244    /// Where this result came from.
245    pub fn provenance(&self) -> &Provenance {
246        &self.provenance
247    }
248
249    /// Every component's value in simulation `sim`, or `None` past the end.
250    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    /// All draws, simulation-major: `draw_matrix()[i * n_components() + j]`
256    /// is component `j` in simulation `i`. (The [`Empirical::draws`] of a
257    /// `PredictiveDistribution` are the per-simulation totals instead.)
258    pub fn draw_matrix(&self) -> &[f64] {
259        &self.draws
260    }
261
262    /// Column of the component with this key.
263    ///
264    /// A key that matches no component exactly may still name a
265    /// [`Period`] by its label, as text or an integer (`"2021"` or `2021`
266    /// for a year, `"2021Q3"` for a quarter): bindings and files carry
267    /// periods as labels. The label match is used only when it names one
268    /// component.
269    ///
270    /// ```
271    /// use prospicio_core::{Grain, Month, Period};
272    /// use prospicio_prob::{KeyValue, PredictiveDistribution, Provenance};
273    ///
274    /// let origin = |y| KeyValue::Period(Period::containing(Month::january(y), Grain::Year));
275    /// let pd = PredictiveDistribution::from_draws(
276    ///     vec!["origin".into()],
277    ///     vec![vec![origin(2020)], vec![origin(2021)]],
278    ///     vec![1.0, 2.0],
279    ///     Provenance::new("example"),
280    /// )
281    /// .unwrap();
282    /// assert_eq!(pd.component_index(&vec![KeyValue::from(2021)]), Some(1));
283    /// assert_eq!(pd.component_index(&vec![KeyValue::from("2020")]), Some(0));
284    /// assert_eq!(pd.component_index(&vec![KeyValue::from("2022")]), None);
285    /// ```
286    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    /// One component's draws, in simulation order, or `None` if no
309    /// component has this key.
310    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    /// Sums the components within each simulation over every dimension not
323    /// in `keep`, keeping the joint structure.
324    ///
325    /// `aggregate(&["lob"])` gives one component per line of business, summed
326    /// over origins. `aggregate(&[])` gives a single component, the total.
327    /// Groups appear in the order their first component appears.
328    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    /// `n` whole rows drawn with replacement from stream `rng`, so the
384    /// components keep their dependence.
385    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    /// The total over all components, one value per simulation.
400    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    /// Allocates the distortion risk measure of the [`total`](Self::total)
412    /// to the components by co-measure (Euler allocation): one
413    /// contribution per component, in [`components`](Self::components)
414    /// order, summing to `d` applied to the total.
415    ///
416    /// Simulations are ranked by their total, take the distortion's
417    /// weights by rank ([`Distortion::weights`]), and each component's
418    /// contribution is the weighted sum of its own draws. For
419    /// `Distortion::Tvar(p)` the contributions are the CoTVaRs,
420    /// `E[X_j | total in its top 1 - p]`. Simulations with equal totals
421    /// share their weights equally, so the result does not depend on how
422    /// ties are ordered.
423    ///
424    /// Components must add up to the portfolio being allocated: allocate
425    /// a set of segments, not a tower result that holds gross, ceded and
426    /// net side by side.
427    ///
428    /// ```
429    /// use prospicio_prob::{Distortion, PredictiveDistribution, Provenance, KeyValue};
430    ///
431    /// // Two lines over four simulations; the totals are 3, 5, 7, 9.
432    /// let pd = PredictiveDistribution::from_draws(
433    ///     vec!["lob".into()],
434    ///     vec![vec![KeyValue::from("motor")], vec![KeyValue::from("property")]],
435    ///     vec![1.0, 2.0, 4.0, 1.0, 2.0, 5.0, 3.0, 6.0],
436    ///     Provenance::new("example"),
437    /// )
438    /// .unwrap();
439    /// // TVaR at 50%: the two worst years, 7 = 2 + 5 and 9 = 3 + 6.
440    /// let co = pd.allocate(&Distortion::tvar(0.5).unwrap());
441    /// assert_eq!(co, [2.5, 5.5]);
442    /// ```
443    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        // Simulations tied on the total share their weights.
451        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    /// Marginal expected shortfall of each component at level `p`:
479    /// `E[X_j | total ≥ VaR_p(total)]` (Acharya et al., 2017), the
480    /// components' expected losses in the portfolio's worst `1 - p`. It is
481    /// the CoTVaR, the Euler allocation of the total's TVaR
482    /// ([`allocate`](Self::allocate) with [`Distortion::Tvar`]), and sums
483    /// to it.
484    pub fn marginal_expected_shortfall(&self, p: f64) -> Result<Vec<f64>> {
485        Ok(self.allocate(&Distortion::tvar(p)?))
486    }
487
488    /// CoVaR of a component (Adrian and Brunnermeier, 2016, in the form of
489    /// Girardi and Ergün, 2013): the total's VaR at level `q` over the
490    /// simulations where the component is in distress, at or above its own
491    /// VaR at level `p`. Compare it with the total's unconditional VaR at
492    /// `q` to see how much one segment's bad years drag the portfolio.
493    ///
494    /// ```
495    /// use prospicio_prob::{KeyValue, PredictiveDistribution, Provenance};
496    ///
497    /// // Two components over four simulations.
498    /// let pd = PredictiveDistribution::from_draws(
499    ///     vec!["lob".into()],
500    ///     vec![vec![KeyValue::from("a")], vec![KeyValue::from("b")]],
501    ///     vec![1.0, 0.0, 2.0, 1.0, 3.0, 5.0, 4.0, 1.0],
502    ///     Provenance::new("example"),
503    /// )
504    /// .unwrap();
505    /// // a is at or above its 75% VaR (3) in the last two simulations,
506    /// // whose totals are 8 and 5; their median (q = 0.5) is 5.
507    /// assert_eq!(pd.covar(&vec![KeyValue::from("a")], 0.75, 0.5).unwrap(), 5.0);
508    /// ```
509    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    /// Esscher allocation: `E[X_j e^(h·total)] / E[e^(h·total)]` per
531    /// component, the components' means under the Esscher transform of the
532    /// total. They sum to the total's Esscher premium
533    /// ([`risk::esscher`](crate::risk::esscher)), and with `h = 0` they are
534    /// the means.
535    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    /// Blends several models' predictive distributions with `weights`
557    /// (from stacking or pseudo-BMA, say): simulation `i` is simulation `i`
558    /// of model `k`, with `k` drawn with probability `weights[k]` from
559    /// stream `i` of `seed`. Each row stays a joint draw of one model, so
560    /// sums across components remain coherent.
561    ///
562    /// Every distribution must have the same dimensions, components (in
563    /// the same order) and number of simulations. Weights are normalized;
564    /// they must be non-negative and not all zero.
565    ///
566    /// ```
567    /// use prospicio_prob::{Empirical, KeyValue, PredictiveDistribution, Provenance};
568    ///
569    /// let one = |v: f64| {
570    ///     PredictiveDistribution::from_draws(
571    ///         vec!["lob".into()],
572    ///         vec![vec![KeyValue::from("a")]],
573    ///         vec![v; 1000],
574    ///         Provenance::new("example"),
575    ///     )
576    ///     .unwrap()
577    /// };
578    /// let blend = PredictiveDistribution::blend(&[&one(0.0), &one(1.0)], &[0.25, 0.75], 7).unwrap();
579    /// let share = blend.total().draws().iter().sum::<f64>() / 1000.0;
580    /// assert!((share - 0.75).abs() < 0.05);
581    /// ```
582    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    /// Blends models with weights that differ by component, as
642    /// hierarchical stacking gives them (`weights[j]` is component `j`'s
643    /// weight vector, one entry per model). In simulation `i` every
644    /// component draws its model from the same uniform (stream `i` of
645    /// `seed`) against its own cumulative weights, so components with the
646    /// same weights take the same model and dependence across components
647    /// is kept as far as the weights allow. With equal weights everywhere
648    /// it is [`blend`](Self::blend).
649    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
748/// Checks the dimension names and component keys shared by every
749/// constructor.
750fn 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        // A label must still name the component's own grain.
817        assert_eq!(pd.component_index(&key("auto", "2021")), None);
818        assert_eq!(pd.component_index(&vec![KeyValue::from("auto")]), None);
819
820        // An exact key wins over a label, and a label that names two
821        // components names neither.
822        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        // Distress in a, which drives the total, raises the total's VaR.
865        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    /// Two lines × two origins, three simulations.
878    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        // Each row comes whole from one model: its components still cancel.
970        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        // Component b all from model a, component a all from model b.
1014        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        // A pays in simulation 3, B in simulation 2: never together.
1030        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        // Not a whole number of rows, empty, non-finite.
1053        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        // No components, wrong key length, repeated key, repeated dimension.
1059        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        // Any month in the period gives the same key.
1181        assert_eq!(KeyValue::from(q(2021, 7)), KeyValue::from(q(2021, 9)));
1182        // A period key is not the integer year.
1183        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                // Some dependence: the second line moves with the first.
1231                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        // The mean allocates to the component means.
1254        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        // 20,000 × 0.05 = 1,000 whole simulations: the plain conditional mean.
1268        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        // The second line is twice the first: ρ is comonotonic additive, so
1280        // each line is allocated its own risk measure.
1281        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        // Simulations 1 and 2 tie on the total (5) but split it differently;
1296        // TVaR at 2/3 takes the top third of the mass, which is all on that
1297        // tie, so each line gets the mean of its two values.
1298        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}