Skip to main content

probl_engine/report/
results.rs

1//! What each report says, as numbers. The renderer formats these, and the
2//! `probl` library gives them to programs that embed Probl, so the two can't
3//! disagree (docs/library.md).
4
5use super::{Acc, Format, Sink, summary_quantile};
6use crate::continuous::Mixture;
7use crate::value::Value;
8use crate::weight::Weight;
9use probl_sema::ir::{ReportKind, ReportSite};
10
11/// One report's results.
12#[derive(Clone, Debug)]
13pub struct ReportResult {
14    /// The share of the worlds' weight (or of the runs) that reached the
15    /// report. `None` when every visit counts, when nothing reached it, when
16    /// no world finished, or when worlds failed before evidence they'd have
17    /// met, so their weight can't be compared.
18    pub reach: Option<Reach>,
19    /// One group per `by` key, in key order; one keyed `()` without `by`.
20    pub groups: Vec<GroupResult>,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct Reach {
25    pub share: f64,
26    /// When sampling: its standard error.
27    pub se: Option<f64>,
28}
29
30/// What one report saw for one key.
31#[derive(Clone, Debug)]
32pub struct GroupResult {
33    pub key: Value,
34    /// When every reported value was a fact: the chance that it's true.
35    pub fact: Option<Quantity>,
36    /// Unless a value is continuous: each value's probability, in the order
37    /// of the values (facts as `true` and `false`, for tables that mix them
38    /// with other values).
39    pub values: Option<Vec<(Value, Quantity)>>,
40    /// The values and their probabilities, among the resolved worlds, in the
41    /// order of the values (facts count as `true` and `false`).
42    pub distribution: Vec<(Value, f64)>,
43    /// For real numbers, and continuous values: their summaries.
44    pub numeric: Option<Numeric>,
45    /// The share of the group's weight that isn't resolved, as the renderer
46    /// counts it.
47    pub unresolved_share: f64,
48    /// When sampling: how many runs it rests on.
49    pub support: Option<Support>,
50    /// For records or lists that hold outcomes of draws: each field's path
51    /// (`.x`, `[0]`), with what it says reported alone. Fields can depend on
52    /// each other, which their marginals don't show. The other summaries
53    /// are then empty.
54    pub fields: Option<Box<Fields>>,
55}
56
57/// A structured report group's fields: each one's path, with what it says
58/// reported alone.
59#[derive(Clone, Debug)]
60pub struct Fields(pub Vec<(String, GroupResult)>);
61
62/// How many sampled runs a group rests on.
63#[derive(Clone, Copy, Debug, PartialEq)]
64pub struct Support {
65    /// Independent runs that reached it; several visits in one run count once.
66    pub contributing_runs: u64,
67    /// The effective sample size of its denominator.
68    pub effective: f64,
69}
70
71/// One reported quantity: a probability, a mean, a quantile.
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct Quantity {
74    /// Over the resolved worlds (or runs).
75    pub point: Option<f64>,
76    /// No unresolved weight, or missing mass, affects it.
77    pub complete: bool,
78    /// When it isn't complete and enumerating: bounds on the quantity over
79    /// all the weight, as if the unresolved weight had gone either way.
80    pub bounds: Option<(f64, f64)>,
81    /// When sampling: its Monte Carlo error.
82    pub sampling: Option<Uncertainty>,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq)]
86pub struct Uncertainty {
87    pub status: Status,
88    /// When `Estimated`, and zero when `IntegratedZero`.
89    pub se: Option<f64>,
90    /// A 95% Wilson interval, where its rules allow one.
91    pub wilson: Option<(f64, f64)>,
92    pub support: Support,
93}
94
95/// What's known about a sampled quantity's Monte Carlo error.
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum Status {
98    /// Its standard error is estimated.
99    Estimated,
100    /// The runs can't establish a useful estimate: they all agreed, without
101    /// integrated outcomes.
102    NotEstimable,
103    /// The engine doesn't compute one for this statistic.
104    NotComputed,
105    /// No empirical error: every run integrated the same outcomes.
106    IntegratedZero,
107}
108
109/// The summaries of a group of real numbers, or of continuous values.
110#[derive(Clone, Debug)]
111pub struct Numeric {
112    pub mean: Quantity,
113    pub sd: Quantity,
114    /// Every value was a probability (shown as percentages).
115    pub percent: bool,
116    /// The mean and sd came from numerical integration, with `@integrate`,
117    /// where there's no formula.
118    pub integrated: bool,
119    shape: Shape,
120    /// For the quantiles: the group's completeness and sampling support.
121    complete: bool,
122    support: Option<Support>,
123}
124
125#[derive(Clone, Debug)]
126enum Shape {
127    /// A continuous marginal, possibly mixed with point masses.
128    Mixture(Mixture),
129    /// Point masses, as values (integers keep their exact value for printing).
130    Points(Vec<(Value, f64)>),
131}
132
133impl Numeric {
134    /// The continuous marginal, when there's one.
135    pub fn mixture(&self) -> Option<&Mixture> {
136        match &self.shape {
137            Shape::Mixture(m) => Some(m),
138            Shape::Points(_) => None,
139        }
140    }
141
142    /// The `q` quantile, by the report convention: the median is the
143    /// midpoint of the middle values, the others select an outcome. `q` must
144    /// be in [0, 1].
145    pub fn quantile(&self, q: f64) -> Option<Quantity> {
146        let point = match &self.shape {
147            Shape::Mixture(m) => Some(if q == 0.5 { m.median() } else { m.quantile(q) }),
148            Shape::Points(points) => summary_quantile(points, q).and_then(|v| v.as_f64()),
149        }?;
150        Some(Quantity {
151            point: Some(point),
152            complete: self.complete,
153            bounds: None,
154            sampling: self.support.map(not_computed),
155        })
156    }
157
158    /// The `q` quantile of point masses as a value, for printing integers
159    /// exactly.
160    pub fn quantile_value(&self, q: f64) -> Option<Value> {
161        match &self.shape {
162            Shape::Points(points) => summary_quantile(points, q),
163            Shape::Mixture(_) => None,
164        }
165    }
166
167    /// The values and their probabilities, as `f64`.
168    pub(super) fn points(&self) -> Option<Vec<(f64, f64)>> {
169        match &self.shape {
170            Shape::Points(points) => Some(
171                points
172                    .iter()
173                    .map(|(v, p)| (v.as_f64().unwrap_or(f64::NAN), *p))
174                    .collect(),
175            ),
176            Shape::Mixture(_) => None,
177        }
178    }
179}
180
181fn not_computed(support: Support) -> Uncertainty {
182    Uncertainty {
183        status: Status::NotComputed,
184        se: None,
185        wilson: None,
186        support,
187    }
188}
189
190/// The results of every report, in source order. `unresolved` is the weight
191/// the run left unresolved, which decides what's complete; `format` is what
192/// the renderer uses.
193pub fn results(sites: &[ReportSite], sinks: &[Sink], format: Format, unresolved: Weight) -> Vec<ReportResult> {
194    sites
195        .iter()
196        .zip(sinks)
197        .map(|(site, sink)| {
198            // A simple report's facts are judged as reached once per world;
199            // a table's by how its site is reached.
200            let kind = if site.key_label.is_none() {
201                ReportKind::Once
202            } else {
203                site.kind
204            };
205            ReportResult {
206                reach: reach(site.kind, sink, format),
207                groups: sink
208                    .groups
209                    .iter()
210                    .map(|(key, acc)| group(key, acc, format, kind, unresolved))
211                    .collect(),
212            }
213        })
214        .collect()
215}
216
217fn reach(kind: ReportKind, sink: &Sink, format: Format) -> Option<Reach> {
218    if kind == ReportKind::PerVisit || sink.groups.is_empty() || format.program_total.is_zero() || !format.reach_known {
219        return None;
220    }
221    if let Some(all_squares) = format.run_squares {
222        // The share of the runs' weight that reached the report, counting
223        // each run once, and its standard error (section 14).
224        let total = format.program_total;
225        let p = sink.reached.ratio(total);
226        let squares = sink.reached_squares;
227        let spread = squares.scale((1.0 - p) * (1.0 - p)) + all_squares.saturating_sub(squares).scale(p * p);
228        let se = spread.ratio(total * total).sqrt();
229        return Some(Reach { share: p, se: Some(se) });
230    }
231    Some(Reach {
232        share: sink.reach().ratio(format.program_total),
233        se: None,
234    })
235}
236
237pub(super) fn group(key: &Value, acc: &Acc, format: Format, kind: ReportKind, unresolved: Weight) -> GroupResult {
238    if acc.structured {
239        return structured(key, acc, format, kind, unresolved);
240    }
241    let sampled = acc.sampled();
242    let support = sampled.then(|| Support {
243        contributing_runs: acc.contributing_runs(),
244        effective: acc.effective(),
245    });
246    let complete = (unresolved + acc.missing).is_zero();
247    let distribution = acc.distribution();
248    let fact = acc
249        .is_event()
250        .then(|| fact(acc, format, kind, complete, unresolved, support));
251    let continuous = distribution
252        .iter()
253        .any(|(v, _)| matches!(v, Value::Analytic(_) | Value::Continuous(_)));
254    let values = (!continuous).then(|| values(acc, &distribution, complete, unresolved, support));
255    let numeric = if acc.is_event() {
256        None
257    } else {
258        numeric(acc, &distribution, complete, support, format.integrate)
259    };
260    GroupResult {
261        key: key.clone(),
262        fact,
263        values,
264        numeric,
265        unresolved_share: acc.unresolved_share(format.unresolved),
266        distribution,
267        support,
268        fields: None,
269    }
270}
271
272/// A group of records or lists of outcomes of draws: each field summarized
273/// as if it were reported alone.
274fn structured(key: &Value, acc: &Acc, format: Format, kind: ReportKind, unresolved: Weight) -> GroupResult {
275    let mut values: Vec<(&Value, Weight)> = acc.values.iter().map(|(v, w)| (v, *w)).collect();
276    values.sort_by(|a, b| a.0.cmp(b.0));
277    let mut fields: Vec<(String, Acc)> = Vec::new();
278    for (v, w) in values {
279        for (path, leaf) in super::fields(v) {
280            let i = match fields.iter().position(|(p, _)| *p == path) {
281                Some(i) => i,
282                None => {
283                    fields.push((path, Acc::default()));
284                    fields.len() - 1
285                }
286            };
287            let field = &mut fields[i].1;
288            field.total += w;
289            field.add(&leaf, w);
290        }
291    }
292    let fields = fields
293        .into_iter()
294        .map(|(path, mut field)| {
295            field.missing = acc.missing;
296            let result = group(&Value::Str(path.as_str().into()), &field, format, kind, unresolved);
297            (path, result)
298        })
299        .collect();
300    GroupResult {
301        key: key.clone(),
302        fact: None,
303        values: None,
304        distribution: Vec::new(),
305        numeric: None,
306        unresolved_share: acc.unresolved_share(format.unresolved),
307        support: None,
308        fields: Some(Box::new(Fields(fields))),
309    }
310}
311
312/// The chance that a reported fact is true.
313fn fact(
314    acc: &Acc,
315    format: Format,
316    kind: ReportKind,
317    complete: bool,
318    unresolved: Weight,
319    support: Option<Support>,
320) -> Quantity {
321    let p = acc.chance();
322    let sampling = support.map(|support| {
323        let se = acc.chance_se();
324        // A Wilson interval needs ordinary, equally weighted observations, and
325        // is given when the standard error isn't useful: every run agreed, or
326        // too few of them contributed.
327        let wilson = acc
328            .chance_interval95(kind)
329            .filter(|_| !format.weighted)
330            .filter(|_| p == 0.0 || p == 1.0 || support.contributing_runs < 30);
331        let integrated = acc.moments.as_ref().is_some_and(|m| m.integrated);
332        let (status, se) = if se != 0.0 {
333            (Status::Estimated, Some(se))
334        } else if integrated {
335            (Status::IntegratedZero, Some(0.0))
336        } else {
337            (Status::NotEstimable, None)
338        };
339        Uncertainty {
340            status,
341            se,
342            wilson,
343            support,
344        }
345    });
346    Quantity {
347        point: Some(p),
348        complete,
349        bounds: (!complete && support.is_none()).then(|| acc.chance_bounds(unresolved)),
350        sampling,
351    }
352}
353
354/// Each reported value's probability, with bounds over the unresolved
355/// weight: as if it had all gone to that value, or none of it.
356fn values(
357    acc: &Acc,
358    distribution: &[(Value, f64)],
359    complete: bool,
360    unresolved: Weight,
361    support: Option<Support>,
362) -> Vec<(Value, Quantity)> {
363    let u = unresolved + acc.missing;
364    let resolved = Weight::sum(acc.values.values().copied()) + acc.facts;
365    distribution
366        .iter()
367        .map(|(value, p)| {
368            let bounds = (!complete && support.is_none()).then(|| {
369                // Facts are counted apart from the other values.
370                let w = match value {
371                    Value::Bool(true) => acc.yes,
372                    Value::Bool(false) => acc.facts.saturating_sub(acc.yes),
373                    _ => acc.values.get(value).copied().unwrap_or_default(),
374                };
375                let total = resolved + u;
376                (w.ratio(total), (w + u).ratio(total).min(1.0))
377            });
378            let sampling = support.map(|support| {
379                let se = acc.value_se(value, *p);
380                let (status, se) = if se == 0.0 {
381                    (Status::NotEstimable, None)
382                } else {
383                    (Status::Estimated, Some(se))
384                };
385                Uncertainty {
386                    status,
387                    se,
388                    wilson: None,
389                    support,
390                }
391            });
392            let quantity = Quantity {
393                point: Some(*p),
394                complete,
395                bounds,
396                sampling,
397            };
398            (value.clone(), quantity)
399        })
400        .collect()
401}
402
403/// The mean and standard deviation of real numbers, or of a continuous
404/// marginal mixed with point masses, as the renderer prints them.
405fn numeric(
406    acc: &Acc,
407    distribution: &[(Value, f64)],
408    complete: bool,
409    support: Option<Support>,
410    integrate: Option<f64>,
411) -> Option<Numeric> {
412    let summary = |mean: f64, sd: f64, mean_sampling: Option<Uncertainty>, percent: bool, shape: Shape, integrated| {
413        // NaN: a continuous outcome whose moments have no formula.
414        let quantity = |x: f64, sampling| Quantity {
415            point: (!x.is_nan()).then_some(x),
416            complete,
417            bounds: None,
418            sampling,
419        };
420        Some(Numeric {
421            mean: quantity(mean, mean_sampling),
422            sd: quantity(sd, support.map(not_computed)),
423            percent,
424            integrated,
425            shape,
426            complete,
427            support,
428        })
429    };
430    if let Some(m) = super::analytic_mixture(distribution) {
431        let (mean, sd, integrated) = mixture_moments(&m, integrate);
432        return summary(
433            mean,
434            sd,
435            support.map(not_computed),
436            false,
437            Shape::Mixture(m),
438            integrated,
439        );
440    }
441    let nums: Vec<(f64, f64)> = distribution
442        .iter()
443        .map(|(v, p)| match v {
444            Value::Int(n) => n
445                .to_f64()
446                .filter(|x| n.cmp_f64(*x).is_some_and(|c| c.is_eq()))
447                .map(|x| (x, *p)),
448            Value::Float(x) | Value::Prob(x) => Some((*x, *p)),
449            _ => None,
450        })
451        .collect::<Option<_>>()?;
452    let percent = distribution.iter().all(|(v, _)| matches!(v, Value::Prob(_)));
453    let total: f64 = nums.iter().map(|(_, p)| p).sum();
454    let mean = nums.iter().map(|(x, p)| x * p).sum::<f64>() / total;
455    let sd = (nums.iter().map(|(x, p)| (x - mean).powi(2) * p).sum::<f64>() / total).sqrt();
456    if !mean.is_finite() || !sd.is_finite() {
457        return None;
458    }
459    let mean_sampling = support.map(|support| {
460        let se = acc.mean_se().1;
461        let (status, se) = if se > 0.0 {
462            (Status::Estimated, Some(se))
463        } else {
464            (Status::NotEstimable, None)
465        };
466        Uncertainty {
467            status,
468            se,
469            wilson: None,
470            support,
471        }
472    });
473    summary(
474        mean,
475        sd,
476        mean_sampling,
477        percent,
478        Shape::Points(distribution.to_vec()),
479        false,
480    )
481}
482
483/// A mixture's mean and sd: from formulas, or with `@integrate`, by
484/// numerical integration for the parts that have none. Whether it took
485/// integration.
486fn mixture_moments(m: &Mixture, integrate: Option<f64>) -> (f64, f64, bool) {
487    use crate::continuous::Part;
488    let (mean, sd) = (m.mean(), m.sd());
489    let Some(tolerance) = integrate.filter(|_| mean.is_nan()) else {
490        return (mean, sd, false);
491    };
492    let mut parts = Vec::with_capacity(m.parts.len());
493    for (part, p) in &m.parts {
494        let moments = match part {
495            Part::Analytic(a) => a.moments().or_else(|| a.integrated_moments(tolerance)),
496            Part::Continuous(f) => Some((f.mean(), f.variance())),
497            Part::Point(x) => Some((*x, 0.0)),
498        };
499        match moments {
500            Some(mv) => parts.push((mv, *p)),
501            None => return (f64::NAN, f64::NAN, false),
502        }
503    }
504    let total: f64 = parts.iter().map(|(_, p)| p).sum();
505    let mean = parts.iter().map(|((m, _), p)| m * p).sum::<f64>() / total;
506    let variance = parts
507        .iter()
508        .map(|((m, v), p)| p * (v + (m - mean).powi(2)))
509        .sum::<f64>()
510        / total;
511    (mean, variance.sqrt(), true)
512}