Skip to main content

probl_engine/
report.rs

1//! Collecting `report` values across worlds, and printing them
2//! (docs/semantics.md, sections 9, 10 and 14).
3
4use crate::value::{Value, fmt_float};
5use crate::weight::Weight;
6use probl_sema::ir::{ReportKind, ReportSite};
7use rustc_hash::FxHashMap;
8use std::collections::BTreeMap;
9use std::fmt::Write;
10
11mod results;
12
13pub use results::{GroupResult, Numeric, Quantity, Reach, ReportResult, Status, Support, Uncertainty, results};
14
15/// Everything one report site saw for one key.
16#[derive(Clone, Debug)]
17pub struct Acc {
18    /// Weight of the worlds that reached the report.
19    pub total: Weight,
20    /// Of the reported facts, the weight where they're true.
21    pub yes: Weight,
22    /// Weight of the reported facts.
23    pub facts: Weight,
24    /// Weight of every other reported value.
25    pub values: FxHashMap<Value, Weight>,
26    /// Weight of reported distributions' missing mass.
27    pub missing: Weight,
28    /// When sampling: what each run of the current batch reported.
29    runs: FxHashMap<u32, RunStat>,
30    /// When sampling: sums over the finished runs, for standard errors.
31    moments: Option<Moments>,
32    continuous: bool,
33    nonnumeric: bool,
34}
35
36impl Default for Acc {
37    fn default() -> Acc {
38        Acc {
39            total: Weight::ZERO,
40            yes: Weight::ZERO,
41            facts: Weight::ZERO,
42            values: FxHashMap::default(),
43            missing: Weight::ZERO,
44            runs: FxHashMap::default(),
45            moments: None,
46            continuous: false,
47            nonnumeric: false,
48        }
49    }
50}
51
52/// Sums over sampled runs, for the standard error of an estimate
53/// Σ wᵢaᵢ / Σ wᵢbᵢ by the delta method (docs/semantics.md, section 14):
54/// Σ wᵢ²aᵢ² and Σ wᵢ²aᵢbᵢ, the second split into its positive and negative
55/// terms (weights can't be negative).
56#[derive(Clone, Copy, Debug, Default)]
57struct Sums {
58    aa: Weight,
59    ab: Weight,
60    ab_negative: Weight,
61}
62
63/// Σ wᵢbᵢ and Σ wᵢ²bᵢ², shared by the estimates with the same bᵢ.
64#[derive(Clone, Copy, Debug, Default)]
65struct Base {
66    b: Weight,
67    bb: Weight,
68}
69
70impl Base {
71    fn add(&mut self, w: Weight, b: f64) {
72        self.b += w.scale(b);
73        self.bb += (w * w).scale(b * b);
74    }
75
76    fn absorb(&mut self, other: Base) {
77        self.b += other.b;
78        self.bb += other.bb;
79    }
80}
81
82impl Sums {
83    fn absorb(&mut self, other: Sums) {
84        self.aa += other.aa;
85        self.ab += other.ab;
86        self.ab_negative += other.ab_negative;
87    }
88
89    fn add(&mut self, w: Weight, a: f64, b: f64) {
90        let w2 = w * w;
91        self.aa += w2.scale(a * a);
92        if a * b >= 0.0 {
93            self.ab += w2.scale(a * b);
94        } else {
95            self.ab_negative += w2.scale(-a * b);
96        }
97    }
98
99    /// √(Σ wᵢ²(aᵢ − p bᵢ)²) / Σ wᵢbᵢ, expanded into the sums.
100    fn standard_error(&self, base: &Base, p: f64) -> f64 {
101        if base.b.is_zero() {
102            return 0.0;
103        }
104        let d = base.b * base.b;
105        let (aa, bb) = (self.aa.ratio(d), p * p * base.bb.ratio(d));
106        let ab = self.ab.ratio(d) - self.ab_negative.ratio(d);
107        let v = aa - 2.0 * p * ab + bb;
108        // Below the rounding error of its terms, the variance is zero: every
109        // run reported the same thing.
110        if v <= 1e-12 * (aa + bb) {
111            return 0.0;
112        }
113        v.sqrt()
114    }
115}
116
117/// What the finished runs reported for one key, summed.
118#[derive(Clone, Debug, Default)]
119struct Moments {
120    contributing_runs: u64,
121    /// Wilson intervals require one ordinary Bernoulli observation per run
122    /// and equal weights, rather than integrated or weighted contributions.
123    not_bernoulli: bool,
124    bernoulli_weight: Option<Weight>,
125    successes: u64,
126    integrated: bool,
127    /// Facts: a is P(true), b the probability of a fact.
128    facts: Base,
129    yes: Sums,
130    /// Numbers: a is the value (times its probability), b the probability of
131    /// a number; Σ wᵢaᵢ in positive and negative parts, for the mean.
132    numbers: Base,
133    sum: Sums,
134    sum_positive: Weight,
135    sum_negative: Weight,
136    /// Each value's probability: a is its share of a run's visits, b the
137    /// number of visits.
138    visits: Base,
139    values: Vec<(Value, Sums)>,
140}
141
142impl Moments {
143    fn absorb(&mut self, other: Moments) {
144        self.not_bernoulli |= other.not_bernoulli
145            || matches!((self.bernoulli_weight, other.bernoulli_weight), (Some(a), Some(b)) if a != b);
146        self.bernoulli_weight = self.bernoulli_weight.or(other.bernoulli_weight);
147        self.contributing_runs += other.contributing_runs;
148        self.successes += other.successes;
149        self.integrated |= other.integrated;
150        self.facts.absorb(other.facts);
151        self.yes.absorb(other.yes);
152        self.numbers.absorb(other.numbers);
153        self.sum.absorb(other.sum);
154        self.sum_positive += other.sum_positive;
155        self.sum_negative += other.sum_negative;
156        self.visits.absorb(other.visits);
157        for (v, sums) in other.values {
158            match self.values.iter_mut().find(|(x, _)| *x == v) {
159                Some((_, mine)) => mine.absorb(sums),
160                None => self.values.push((v, sums)),
161            }
162        }
163    }
164}
165
166/// What one sampled run reported for one key, added up over its visits: the
167/// aᵢ and bᵢ of docs/semantics.md, section 14.
168#[derive(Clone, Debug)]
169pub struct RunStat {
170    /// The run's weight (its final weight: no observation follows a report).
171    pub weight: Weight,
172    pub visits: u32,
173    /// At least one visit supplied a distribution whose outcomes were
174    /// integrated, instead of an ordinary sampled value.
175    integrated: bool,
176    /// The probability that the reported fact was true.
177    pub yes: f64,
178    /// The probability that the reported value was a fact.
179    pub facts: f64,
180    /// The reported numbers, times their probabilities.
181    pub sum: f64,
182    /// The probability that the reported value was a number.
183    pub numbers: f64,
184    /// The probability of each other value (dates excluded).
185    pub others: Vec<(Value, f64)>,
186}
187
188impl RunStat {
189    fn record(&mut self, value: &Value, share: f64) {
190        match value {
191            Value::Bool(b) => {
192                self.facts += share;
193                if *b {
194                    self.yes += share;
195                }
196            }
197            Value::Dist(d) => {
198                for (x, q) in &d.outcomes {
199                    self.record(x, share * q);
200                }
201            }
202            Value::Int(_) | Value::Float(_) | Value::Prob(_) if value.as_f64().is_some() => {
203                self.numbers += share;
204                self.sum += share * value.as_f64().unwrap();
205            }
206            Value::Date(_) => {}
207            other => match self.others.iter_mut().find(|(v, _)| v == other) {
208                Some((_, s)) => *s += share,
209                None => self.others.push((other.clone(), share)),
210            },
211        }
212    }
213}
214
215impl Acc {
216    fn add(&mut self, value: &Value, weight: Weight) {
217        self.continuous |= matches!(value, Value::Analytic(_) | Value::Continuous(_));
218        self.nonnumeric |= !matches!(
219            value,
220            Value::Int(_)
221                | Value::Float(_)
222                | Value::Prob(_)
223                | Value::Analytic(_)
224                | Value::Continuous(_)
225                | Value::Dist(_)
226        );
227        match value {
228            Value::Analytic(a) => {
229                // Reports retain marginals only; merging identical laws here
230                // avoids keeping one copy per independent draw/branch.
231                let mut marginal = (**a).clone();
232                marginal.id = 0;
233                *self
234                    .values
235                    .entry(Value::Analytic(std::sync::Arc::new(marginal)))
236                    .or_insert(Weight::ZERO) += weight;
237            }
238            Value::Event(e) => {
239                self.facts += weight;
240                self.yes += weight.scale(e.probability());
241            }
242            Value::Bool(b) => {
243                self.facts += weight;
244                if *b {
245                    self.yes += weight;
246                }
247            }
248            Value::Dist(d) => {
249                self.missing += weight.scale(d.missing);
250                for (x, q) in &d.outcomes {
251                    self.add(x, weight.scale(*q));
252                }
253            }
254            other => {
255                let slot = self.values.entry(other.clone()).or_insert(Weight::ZERO);
256                *slot += weight;
257            }
258        }
259    }
260
261    /// Whether every reported value was a fact (or a distribution of facts).
262    pub fn is_event(&self) -> bool {
263        self.values.is_empty() && !self.facts.is_zero()
264    }
265
266    /// The probability that the reported fact is true, among the resolved
267    /// worlds that reached the report.
268    pub fn chance(&self) -> f64 {
269        self.yes.ratio(self.facts)
270    }
271
272    /// Bounds on that probability, given `unresolved` weight elsewhere
273    /// (docs/semantics.md, section 10).
274    pub fn chance_bounds(&self, unresolved: Weight) -> (f64, f64) {
275        let u = unresolved + self.missing;
276        let denom = self.facts + u;
277        (self.yes.ratio(denom), (self.yes + u).ratio(denom).min(1.0))
278    }
279
280    /// The reported values as a sorted distribution, conditional on being
281    /// resolved (facts count as `true`/`false` when mixed with other values).
282    pub fn distribution(&self) -> Vec<(Value, f64)> {
283        let mut pairs: Vec<(Value, Weight)> = self.values.iter().map(|(v, w)| (v.clone(), *w)).collect();
284        if !self.facts.is_zero() {
285            pairs.push((Value::Bool(true), self.yes));
286            pairs.push((Value::Bool(false), self.facts.saturating_sub(self.yes)));
287        }
288        let total = Weight::sum(pairs.iter().map(|(_, w)| *w));
289        let mut out: Vec<(Value, f64)> = pairs.into_iter().map(|(v, w)| (v, w.ratio(total))).collect();
290        out.retain(|(_, p)| *p > 0.0);
291        out.sort_by(|a, b| a.0.cmp(&b.0));
292        out
293    }
294
295    /// The share of this report's weight that isn't resolved.
296    fn unresolved_share(&self, unresolved: Weight) -> f64 {
297        let u = unresolved + self.missing;
298        u.ratio(self.total + unresolved)
299    }
300
301    /// Add what another batch of runs reported for this key.
302    fn absorb(&mut self, other: Acc) {
303        debug_assert!(
304            self.runs.is_empty() && other.runs.is_empty(),
305            "batches end before they're combined"
306        );
307        self.total += other.total;
308        self.yes += other.yes;
309        self.facts += other.facts;
310        self.missing += other.missing;
311        self.continuous |= other.continuous;
312        self.nonnumeric |= other.nonnumeric;
313        for (v, w) in other.values {
314            *self.values.entry(v).or_insert(Weight::ZERO) += w;
315        }
316        match (&mut self.moments, other.moments) {
317            (Some(mine), Some(theirs)) => mine.absorb(theirs),
318            (mine @ None, theirs) => *mine = theirs,
319            (Some(_), None) => {}
320        }
321    }
322
323    /// Whether the values come from sampled runs.
324    pub fn sampled(&self) -> bool {
325        self.moments.is_some() || !self.runs.is_empty()
326    }
327
328    /// Fold the current batch's runs into the sums: they're finished.
329    fn end_batch(&mut self) {
330        if self.runs.is_empty() {
331            return;
332        }
333        let m = self.moments.get_or_insert_with(Moments::default);
334        for (_, run) in self.runs.drain() {
335            let w = run.weight;
336            m.contributing_runs += 1;
337            m.not_bernoulli |= run.visits != 1
338                || run.facts != 1.0
339                || run.integrated
340                || (run.yes != 0.0 && run.yes != 1.0)
341                || m.bernoulli_weight.is_some_and(|previous| previous != w);
342            m.bernoulli_weight.get_or_insert(w);
343            m.successes += (run.yes == 1.0) as u64;
344            m.integrated |= run.integrated;
345            m.facts.add(w, run.facts);
346            m.yes.add(w, run.yes, run.facts);
347            m.numbers.add(w, run.numbers);
348            m.sum.add(w, run.sum, run.numbers);
349            if run.sum >= 0.0 {
350                m.sum_positive += w.scale(run.sum);
351            } else {
352                m.sum_negative += w.scale(-run.sum);
353            }
354            let visits = run.visits as f64;
355            m.visits.add(w, visits);
356            let mut shares = run.others;
357            if run.facts > 0.0 {
358                shares.push((Value::Bool(true), run.yes));
359                shares.push((Value::Bool(false), run.facts - run.yes));
360            }
361            for (v, share) in shares {
362                match m.values.iter_mut().find(|(x, _)| *x == v) {
363                    Some((_, sums)) => sums.add(w, share, visits),
364                    None => {
365                        let mut sums = Sums::default();
366                        sums.add(w, share, visits);
367                        m.values.push((v, sums));
368                    }
369                }
370            }
371        }
372    }
373
374    fn moments(&self) -> Moments {
375        self.moments.clone().unwrap_or_default()
376    }
377
378    /// Number of independent runs contributing to this report/key. Multiple
379    /// visits in one run count once.
380    pub fn contributing_runs(&self) -> u64 {
381        self.moments.as_ref().map_or(0, |m| m.contributing_runs)
382    }
383
384    /// When sampling: effective sample size using the report denominator's
385    /// per-run contributions, including visit multiplicity.
386    pub fn effective(&self) -> f64 {
387        let m = self.moments();
388        let base = if self.is_event() {
389            m.facts
390        } else if self.facts.is_zero()
391            && !m.numbers.b.is_zero()
392            && self
393                .values
394                .keys()
395                .all(|v| matches!(v, Value::Int(_) | Value::Float(_) | Value::Prob(_)))
396        {
397            m.numbers
398        } else {
399            m.visits
400        };
401        if base.bb.is_zero() {
402            return 0.0;
403        }
404        (base.b * base.b).ratio(base.bb).min(m.contributing_runs as f64)
405    }
406
407    fn chance_interval95(&self, kind: ReportKind) -> Option<(f64, f64)> {
408        let m = self.moments.as_ref()?;
409        if kind == ReportKind::PerVisit || m.not_bernoulli || m.contributing_runs == 0 {
410            return None;
411        }
412        Some(wilson95(m.successes, m.contributing_runs))
413    }
414
415    /// When sampling: the standard error of `chance`.
416    pub fn chance_se(&self) -> f64 {
417        let m = self.moments();
418        m.yes.standard_error(&m.facts, self.chance())
419    }
420
421    /// When sampling: the mean of the reported numbers and its standard error.
422    pub fn mean_se(&self) -> (f64, f64) {
423        let m = self.moments();
424        let mean = m.sum_positive.ratio(m.numbers.b) - m.sum_negative.ratio(m.numbers.b);
425        (mean, m.sum.standard_error(&m.numbers, mean))
426    }
427
428    /// When sampling: the standard error of a value's probability `p`.
429    pub fn value_se(&self, value: &Value, p: f64) -> f64 {
430        let m = self.moments();
431        let sums = m
432            .values
433            .iter()
434            .find(|(x, _)| x == value)
435            .map_or(Sums::default(), |(_, s)| *s);
436        sums.standard_error(&m.visits, p)
437    }
438}
439
440/// All values reported at one site, per `by` key (`()` without `by`).
441#[derive(Clone, Debug, Default)]
442pub struct Sink {
443    pub groups: BTreeMap<Value, Acc>,
444    /// When sampling: Σ w and Σ w² over the runs that reached the report,
445    /// each counted once.
446    pub reached: Weight,
447    pub reached_squares: Weight,
448}
449
450impl Sink {
451    pub(crate) fn validate_analytic(&self, key: &Value, value: &Value) -> crate::error::OpResult<()> {
452        fn kinds(v: &Value) -> (bool, bool) {
453            match v {
454                Value::Analytic(_) | Value::Continuous(_) => (true, true),
455                Value::Int(_) | Value::Float(_) | Value::Prob(_) => (false, true),
456                Value::Dist(d) => d
457                    .outcomes
458                    .iter()
459                    .map(|(v, _)| kinds(v))
460                    .fold((false, true), |(a, b), (c, d)| (a || c, b && d)),
461                _ => (false, false),
462            }
463        }
464        let (mut analytic, mut numeric) = kinds(value);
465        if let Some(acc) = self.groups.get(key) {
466            analytic |= acc.continuous;
467            numeric &= !acc.nonnumeric;
468        }
469        if analytic && !numeric {
470            return Err(crate::analytic::unsupported(
471                "mixing a continuous report with nonnumeric outcomes",
472            ));
473        }
474        Ok(())
475    }
476
477    /// Record a value reported in a world, and when sampling, by which run.
478    pub fn add(&mut self, key: Value, value: &Value, weight: Weight, run: Option<u32>) {
479        let acc = self.groups.entry(key).or_default();
480        acc.total += weight;
481        acc.add(value, weight);
482        if let Some(run) = run {
483            let stat = acc.runs.entry(run).or_insert_with(|| RunStat {
484                weight,
485                visits: 0,
486                integrated: false,
487                yes: 0.0,
488                facts: 0.0,
489                sum: 0.0,
490                numbers: 0.0,
491                others: Vec::new(),
492            });
493            stat.visits += 1;
494            stat.integrated |= matches!(value, Value::Dist(_));
495            stat.record(value, 1.0);
496        }
497    }
498
499    /// When sampling: fold the batch's runs into the sums. They're finished,
500    /// so what's kept doesn't grow with the number of runs.
501    pub fn end_batch(&mut self) {
502        let mut runs: FxHashMap<u32, Weight> = FxHashMap::default();
503        for acc in self.groups.values() {
504            runs.extend(acc.runs.iter().map(|(id, r)| (*id, r.weight)));
505        }
506        for w in runs.into_values() {
507            self.reached += w;
508            self.reached_squares += w * w;
509        }
510        for acc in self.groups.values_mut() {
511            acc.end_batch();
512        }
513    }
514
515    /// Add what another batch of runs reported. Batches are combined in
516    /// order, so the sums don't depend on which thread ran which batch.
517    pub fn absorb(&mut self, other: Sink) {
518        self.reached += other.reached;
519        self.reached_squares += other.reached_squares;
520        for (key, acc) in other.groups {
521            match self.groups.entry(key) {
522                std::collections::btree_map::Entry::Occupied(mut mine) => mine.get_mut().absorb(acc),
523                std::collections::btree_map::Entry::Vacant(slot) => {
524                    slot.insert(acc);
525                }
526            }
527        }
528    }
529
530    /// For a report without `by` of facts: the probability they're true.
531    pub fn chance(&self) -> Option<f64> {
532        let acc = self.groups.get(&Value::Unit)?;
533        acc.is_event().then(|| acc.chance())
534    }
535
536    /// For a report without `by`: the reported values and their probabilities.
537    pub fn distribution(&self) -> Vec<(Value, f64)> {
538        self.groups.get(&Value::Unit).map(Acc::distribution).unwrap_or_default()
539    }
540
541    /// Total weight that reached the report.
542    pub fn reach(&self) -> Weight {
543        Weight::sum(self.groups.values().map(|a| a.total))
544    }
545}
546
547#[derive(Clone, Copy, Debug)]
548pub struct Format {
549    /// Also print the simplest nearby fraction of each probability.
550    pub fractions: bool,
551    /// Weight left unresolved by the whole run.
552    pub unresolved: Weight,
553    /// The total weight of the worlds that finished the program.
554    pub program_total: Weight,
555    /// When sampling: the sum of the runs' squared weights.
556    pub run_squares: Option<Weight>,
557    /// The model may condition outer worlds. Equal weights observed so far
558    /// alone do not establish an ordinary binomial sampling scheme.
559    pub weighted: bool,
560    /// Whether `program_total` is a denominator for each report's reach.
561    /// It isn't when worlds failed before evidence they'd have met
562    /// (docs/semantics.md, section 11): their weight doesn't count it.
563    pub reach_known: bool,
564}
565
566/// Render reports, each with what its sink collected, in source order.
567pub fn render(sites: &[ReportSite], sinks: &[Sink], format: Format) -> String {
568    render_results(sites, &results(sites, sinks, format, format.unresolved), format)
569}
570
571/// Render reports from their [`results`], in source order: the renderer
572/// only formats the numbers they hold.
573pub fn render_results(sites: &[ReportSite], results: &[ReportResult], format: Format) -> String {
574    let mut out = String::new();
575    let mut simple: Vec<(String, String)> = Vec::new();
576    let flush = |simple: &mut Vec<(String, String)>, out: &mut String| {
577        let width = simple.iter().map(|(l, _)| l.chars().count()).max().unwrap_or(0);
578        for (label, text) in simple.drain(..) {
579            let pad = width - label.chars().count();
580            writeln!(out, "{label}{}    {text}", " ".repeat(pad)).unwrap();
581        }
582    };
583    for (site, result) in sites.iter().zip(results) {
584        let mut label = site.label.clone();
585        if site.kind == ReportKind::PerVisit {
586            label.push_str(" (per visit)");
587        }
588        let reach = reach_note(result.reach, format);
589        if site.key_label.is_none() {
590            let text = match result.groups.first() {
591                None => "(never reached)".to_string(),
592                Some(group) => format!("{}{reach}", value_text(group, format)),
593            };
594            simple.push((label, text));
595            continue;
596        }
597        if !simple.is_empty() {
598            flush(&mut simple, &mut out);
599        }
600        // A blank line before the table, unless one ends the table before.
601        if !out.is_empty() && !out.ends_with("\n\n") {
602            out.push('\n');
603        }
604        writeln!(out, "{label}{reach}").unwrap();
605        if result.groups.is_empty() {
606            writeln!(out, "  (never reached)").unwrap();
607        } else {
608            out.push_str(&table(site.key_label.as_deref().unwrap_or(""), result, format));
609        }
610        out.push('\n');
611    }
612    flush(&mut simple, &mut out);
613    while out.ends_with("\n\n") {
614        out.pop();
615    }
616    out
617}
618
619/// " (reached in 1.00% of worlds)" when a report sees part of the weight.
620fn reach_note(reach: Option<Reach>, format: Format) -> String {
621    let Some(reach) = reach else {
622        return String::new();
623    };
624    if reach.share >= 0.99995 {
625        return String::new();
626    }
627    if let Some(se) = reach.se {
628        return format!(" (reached in {} of runs)", estimate(reach.share, se));
629    }
630    format!(
631        " (reached in {} of worlds)",
632        pct(
633            reach.share,
634            Format {
635                fractions: false,
636                ..format
637            }
638        )
639    )
640}
641
642fn value_text(group: &GroupResult, format: Format) -> String {
643    if let Some(fact) = &group.fact {
644        return chance_text(fact, format);
645    }
646    let dist = &group.distribution;
647    let mut text = if let Some(numeric) = group.numeric.as_ref().filter(|n| n.mixture().is_some()) {
648        analytic_stats(numeric)
649    } else if dist.len() == 1 {
650        display(&dist[0].0)
651    } else if let Some(numeric) = &group.numeric {
652        numeric_stats(numeric, dist)
653    } else if dist.iter().all(|(v, _)| matches!(v, Value::Date(_))) {
654        let [a, b, c] =
655            [0.05, 0.5, 0.95].map(|q| summary_quantile(dist, q).map_or_else(|| "out of range".into(), |v| display(&v)));
656        format!("5% {a} · median {b} · 95% {c}")
657    } else if group.support.is_some() {
658        categorical_sampled(group)
659    } else {
660        categorical(dist, format)
661    };
662    let share = group.unresolved_share;
663    if share >= 0.00005 {
664        write!(
665            text,
666            " · {} unresolved",
667            pct(
668                share,
669                Format {
670                    fractions: false,
671                    ..format
672                }
673            )
674        )
675        .unwrap();
676    }
677    text.push_str(&reliability_note(group.support));
678    text
679}
680
681/// A probability, or the range it lies in when unresolved weight is visible;
682/// when sampling, an estimate and its standard error.
683fn chance_text(fact: &Quantity, format: Format) -> String {
684    let p = fact.point.unwrap_or(f64::NAN);
685    if let Some(sampling) = fact.sampling {
686        let runs = thousands(sampling.support.contributing_runs as i64);
687        let mut text = if let Some((lo, hi)) = sampling.wilson {
688            format!(
689                "{} (95% Wilson interval {}–{}; {runs} contributing runs)",
690                pct(p, format),
691                pct(lo, format),
692                pct(hi, format),
693            )
694        } else if let (Status::Estimated, Some(se)) = (sampling.status, sampling.se) {
695            estimate(p, se)
696        } else {
697            let note = if sampling.status == Status::IntegratedZero {
698                "zero empirical MC error; integrated outcomes"
699            } else {
700                "MC error not estimable"
701            };
702            format!("{} ({note}; {runs} contributing runs)", pct(p, format))
703        };
704        text.push_str(&reliability_note(Some(sampling.support)));
705        return text;
706    }
707    if let Some((lo, hi)) = fact.bounds.filter(|(lo, hi)| hi - lo >= 0.00005) {
708        let plain = Format {
709            fractions: false,
710            ..format
711        };
712        return format!("{}–{}", pct(lo, plain), pct(hi, plain));
713    }
714    pct(p, format)
715}
716
717/// Two-sided 95% Wilson score interval for ordinary independent Bernoulli
718/// observations. Not an interval for importance-weighted or integrated data.
719fn wilson95(successes: u64, trials: u64) -> (f64, f64) {
720    let n = trials as f64;
721    let p = successes as f64 / n;
722    let z2 = 1.959963984540054_f64.powi(2);
723    let denominator = 1.0 + z2 / n;
724    let center = (p + z2 / (2.0 * n)) / denominator;
725    let half = (z2 * (p * (1.0 - p) / n + z2 / (4.0 * n * n))).sqrt() / denominator;
726    (
727        if successes == 0 { 0.0 } else { (center - half).max(0.0) },
728        if successes == trials {
729            1.0
730        } else {
731            (center + half).min(1.0)
732        },
733    )
734}
735
736fn reliability_note(support: Option<Support>) -> String {
737    let Some(support) = support.filter(|s| s.effective < 30.0) else {
738        return String::new();
739    };
740    format!(
741        " (low sample support: {} contributing runs; effective sample size {})",
742        thousands(support.contributing_runs as i64),
743        fixed(support.effective, 1)
744    )
745}
746
747fn categorical(dist: &[(Value, f64)], format: Format) -> String {
748    let mut by_chance: Vec<&(Value, f64)> = dist.iter().collect();
749    by_chance.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
750    let shown = by_chance.len().min(12);
751    let mut parts: Vec<String> = by_chance[..shown]
752        .iter()
753        .map(|(v, p)| format!("{} {}", display(v), pct(*p, format)))
754        .collect();
755    if by_chance.len() > shown {
756        parts.push(format!("… {} more", by_chance.len() - shown));
757    }
758    parts.join(" · ")
759}
760
761/// Sampled values with their probabilities and standard errors.
762fn categorical_sampled(group: &GroupResult) -> String {
763    let mut by_chance: Vec<&(Value, f64)> = group.distribution.iter().collect();
764    by_chance.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
765    let shown = by_chance.len().min(12);
766    let mut parts: Vec<String> = by_chance[..shown]
767        .iter()
768        .map(|(v, p)| format!("{} {}", display(v), sampled_value_text(group, v, *p)))
769        .collect();
770    if by_chance.len() > shown {
771        parts.push(format!("… {} more", by_chance.len() - shown));
772    }
773    parts.join(" · ")
774}
775
776/// A sampled value's probability `p` (zero for a value the group never
777/// reported), with its standard error.
778fn sampled_value_text(group: &GroupResult, value: &Value, p: f64) -> String {
779    let quantity = group.values.iter().flatten().find(|(v, _)| v == value).map(|(_, q)| q);
780    match quantity
781        .and_then(|q| q.sampling)
782        .filter(|s| s.status == Status::Estimated)
783    {
784        Some(Uncertainty { se: Some(se), .. }) => estimate(p, se),
785        _ => format!("{}% (MC error not estimable)", fixed(p * 100.0, 2)),
786    }
787}
788
789/// A sampled probability and its standard error, rounded to the error's
790/// precision: `46.1% ± 0.3%` (docs/semantics.md, section 14).
791pub fn estimate(p: f64, se: f64) -> String {
792    let se = se * 100.0;
793    let decimals = if se < 0.005 {
794        2
795    } else {
796        (-libm::log10(se).floor()).clamp(0.0, 2.0) as usize
797    };
798    format!("{} ± {}%", fmt_percent(p, decimals), fixed(se, decimals))
799}
800
801/// Numeric components of a report containing a continuous marginal.
802/// These are report summaries, not conversions of scalar outcomes in programs.
803pub fn analytic_mixture(dist: &[(Value, f64)]) -> Option<crate::continuous::Mixture> {
804    use crate::continuous::{Mixture, Part};
805    if !dist
806        .iter()
807        .any(|(v, _)| matches!(v, Value::Analytic(_) | Value::Continuous(_)))
808    {
809        return None;
810    }
811    let parts = dist
812        .iter()
813        .map(|(v, p)| {
814            let part = match v {
815                Value::Analytic(a) => Part::Analytic((**a).clone()),
816                Value::Continuous(f) => Part::Continuous(**f),
817                v => Part::Point(v.as_f64()?),
818            };
819            Some((part, *p))
820        })
821        .collect::<Option<_>>()?;
822    Some(Mixture { parts })
823}
824
825/// A continuous marginal's quantile, by the report convention.
826fn mixture_quantile(numeric: &Numeric, q: f64, decimals: usize) -> String {
827    fixed(numeric.quantile(q).and_then(|x| x.point).unwrap_or(f64::NAN), decimals)
828}
829
830fn analytic_stats(numeric: &Numeric) -> String {
831    let mean = numeric.mean.point.unwrap_or(f64::NAN);
832    let sd = numeric.sd.point.unwrap_or(f64::NAN);
833    let decimals = if mean.abs().max(sd) < 100.0 { 2 } else { 0 };
834    let [a, b, c] = [0.05, 0.5, 0.95].map(|q| mixture_quantile(numeric, q, decimals));
835    format!(
836        "mean {} · sd {} · 5% {a} · median {b} · 95% {c}",
837        fixed(mean, decimals),
838        fixed(sd, decimals)
839    )
840}
841
842/// `mean · sd · 5% · median · 95%`, and a sparkline for small integer ranges.
843/// Probabilities (as values, not facts) are shown as percentages. A sampled
844/// mean shows its standard error when it's visible at the printed precision.
845fn numeric_stats(numeric: &Numeric, dist: &[(Value, f64)]) -> String {
846    let nums = numeric.points().unwrap_or_default();
847    let percent = numeric.percent;
848    let total: f64 = nums.iter().map(|(_, p)| p).sum();
849    let mean = numeric.mean.point.unwrap_or(f64::NAN);
850    let sd = numeric.sd.point.unwrap_or(f64::NAN);
851    let mean_se = numeric.mean.sampling.and_then(|s| s.se);
852    let show = |x: f64, decimals: usize| {
853        if percent { fmt_percent(x, 2) } else { fixed(x, decimals) }
854    };
855    let decimals = if mean.abs().max(sd) < 100.0 { 2 } else { 0 };
856    // Mixed-sign sums can leave a platform-dependent residue below their
857    // floating-point summation resolution. Mark that summary approximately
858    // zero, without changing the value or hiding genuinely small-scale data.
859    let roundoff_factor = (nums.len() as f64 + 2.0) * f64::EPSILON;
860    let absolute_mean = nums.iter().map(|(x, p)| x.abs() * p).sum::<f64>() / total;
861    let cancellation = nums.iter().any(|(x, _)| *x < 0.0)
862        && nums.iter().any(|(x, _)| *x > 0.0)
863        && mean.abs() <= absolute_mean * roundoff_factor / (1.0 - roundoff_factor);
864    let shown_mean = if cancellation {
865        if percent { "≈0%" } else { "≈0" }.to_string()
866    } else {
867        show(mean, decimals)
868    };
869    let [a, b, c] = [0.05, 0.5, 0.95].map(|q| {
870        let Some(v) = numeric.quantile_value(q) else {
871            return "out of range".to_string();
872        };
873        if percent {
874            show(v.as_f64().unwrap(), 2)
875        } else {
876            number(&v, decimals)
877        }
878    });
879    let mean_text = match mean_se {
880        Some(se)
881            if se > 0.0
882                && (se * if percent { 100.0 } else { 1.0 } >= 0.5 * libm::pow(10.0, -(decimals as f64))
883                    || (mean != 0.0 && mean.abs() * if percent { 100.0 } else { 1.0 } < 0.005)) =>
884        {
885            format!("{} ± {}", shown_mean, show(se, decimals))
886        }
887        _ => shown_mean,
888    };
889    let mut text = format!(
890        "mean {mean_text} · sd {} · 5% {a} · median {b} · 95% {c}",
891        show(sd, decimals)
892    );
893    if let Some(spark) = sparkline(dist) {
894        write!(text, " · {spark}").unwrap();
895    }
896    text
897}
898
899const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
900
901/// `2 ▂▃▄▆▇█▇▆▄▃▂ 12` for integers spanning at most 25 values.
902fn sparkline(dist: &[(Value, f64)]) -> Option<String> {
903    let ints: Vec<(i64, f64)> = dist
904        .iter()
905        .map(|(v, p)| match v {
906            Value::Int(i) => i.to_i64().map(|n| (n, *p)),
907            _ => None,
908        })
909        .collect::<Option<_>>()?;
910    let (lo, hi) = (ints.first()?.0, ints.last()?.0);
911    if hi as i128 - lo as i128 + 1 > 25 || hi == lo {
912        return None;
913    }
914    let max = ints.iter().map(|(_, p)| *p).fold(0.0, f64::max);
915    let by_value: BTreeMap<i64, f64> = ints.into_iter().collect();
916    let bars: String = (lo..=hi)
917        .map(|i| match by_value.get(&i) {
918            Some(&p) if p > 0.0 => BLOCKS[((p / max * 8.0).ceil() as usize).clamp(1, 8) - 1],
919            _ => ' ',
920        })
921        .collect();
922    Some(format!("{lo} {bars} {hi}"))
923}
924
925/// The `q` quantile of a report's values, for its summary. The median uses
926/// the same midpoint rule as `median()`; the other columns select outcomes.
927/// An unrepresentable fractional bigint midpoint is labelled rather than
928/// silently replaced with the lower bound.
929fn summary_quantile(dist: &[(Value, f64)], q: f64) -> Option<Value> {
930    if q != 0.5 {
931        return Some(quantile(dist, q));
932    }
933    let (lo, hi) = crate::stats::median_bounds(dist)?;
934    if lo == hi {
935        return Some(lo.clone());
936    }
937    crate::builtins::midpoint(lo, hi, &mut crate::dist::Budget::unlimited()).ok()
938}
939
940fn quantile(dist: &[(Value, f64)], q: f64) -> Value {
941    crate::stats::quantile(dist, q)
942        .expect("nonempty report population")
943        .clone()
944}
945
946/// A report table: one row per `by` key.
947fn table(key_label: &str, result: &ReportResult, format: Format) -> String {
948    let groups = &result.groups;
949    let keys: Vec<String> = groups.iter().map(|g| display(&g.key)).collect();
950    let mut rows: Vec<Vec<String>> = Vec::new();
951    let header: Vec<String>;
952    let all_facts = groups.iter().all(|g| g.fact.is_some());
953
954    if all_facts {
955        header = Vec::new();
956        for (key, group) in keys.iter().zip(groups) {
957            rows.push(vec![key.clone(), value_text(group, format)]);
958        }
959    } else {
960        let numeric = groups.iter().all(|g| {
961            g.distribution.iter().all(|(v, _)| {
962                matches!(
963                    v,
964                    Value::Int(_) | Value::Float(_) | Value::Analytic(_) | Value::Continuous(_)
965                )
966            })
967        });
968        if numeric {
969            header = ["5%", "25%", "median", "75%", "95%"].map(String::from).to_vec();
970            for (key, group) in keys.iter().zip(groups) {
971                if let Some(numeric) = group.numeric.as_ref().filter(|n| n.mixture().is_some()) {
972                    let (mean, sd) = (
973                        numeric.mean.point.unwrap_or(f64::NAN),
974                        numeric.sd.point.unwrap_or(f64::NAN),
975                    );
976                    let decimals = if mean.abs().max(sd) < 100.0 { 2 } else { 0 };
977                    let mut row = vec![key.clone()];
978                    row.extend([0.05, 0.25, 0.5, 0.75, 0.95].map(|q| mixture_quantile(numeric, q, decimals)));
979                    rows.push(row);
980                    continue;
981                }
982                let d = &group.distribution;
983                let scale = d
984                    .iter()
985                    .filter_map(|(v, _)| v.as_f64())
986                    .fold(0.0f64, |m, x| m.max(x.abs()));
987                let decimals = if scale < 100.0 { 2 } else { 0 };
988                let mut row = vec![key.clone()];
989                row.extend(
990                    [0.05, 0.25, 0.5, 0.75, 0.95].map(|q| {
991                        summary_quantile(d, q).map_or_else(|| "out of range".into(), |v| number(&v, decimals))
992                    }),
993                );
994                rows.push(row);
995            }
996        } else {
997            let mut columns: Vec<Value> = groups
998                .iter()
999                .flat_map(|g| g.distribution.iter().map(|(v, _)| v.clone()))
1000                .collect();
1001            columns.sort();
1002            columns.dedup();
1003            header = columns.iter().map(display).collect();
1004            for (key, group) in keys.iter().zip(groups) {
1005                let mut row = vec![key.clone()];
1006                for c in &columns {
1007                    let p = group.distribution.iter().find(|(v, _)| v == c).map_or(0.0, |(_, p)| *p);
1008                    row.push(if group.support.is_some() {
1009                        sampled_value_text(group, c, p)
1010                    } else {
1011                        pct(p, format)
1012                    });
1013                }
1014                rows.push(row);
1015            }
1016        }
1017    }
1018
1019    // Reliability belongs to a key, not to the whole table. Include it for
1020    // numeric/date/categorical rows too, where no probability cell carries it.
1021    let mut header = header;
1022    if !all_facts && groups.iter().any(|g| !reliability_note(g.support).is_empty()) {
1023        if !header.is_empty() {
1024            header.push("reliability".to_string());
1025        }
1026        for (row, group) in rows.iter_mut().zip(groups) {
1027            row.push(reliability_note(group.support).trim().to_string());
1028        }
1029    }
1030    let mut all = Vec::new();
1031    if !header.is_empty() {
1032        let mut h = vec![key_label.to_string()];
1033        h.extend(header);
1034        all.push(h);
1035    }
1036    all.extend(rows);
1037    let ncols = all.iter().map(Vec::len).max().unwrap_or(0);
1038    let widths: Vec<usize> = (0..ncols)
1039        .map(|c| {
1040            all.iter()
1041                .filter_map(|r| r.get(c))
1042                .map(|s| s.chars().count())
1043                .max()
1044                .unwrap_or(0)
1045        })
1046        .collect();
1047    let mut out = String::new();
1048    for row in all {
1049        out.push_str("  ");
1050        let cells: Vec<String> = row
1051            .iter()
1052            .enumerate()
1053            .map(|(c, cell)| format!("{}{cell}", " ".repeat(widths[c] - cell.chars().count())))
1054            .collect();
1055        out.push_str(&cells.join("   "));
1056        out.push('\n');
1057    }
1058    out
1059}
1060
1061// ── Number formatting ────────────────────────────────────────────────────
1062
1063/// A probability as a percentage with two decimals.
1064pub fn pct(p: f64, format: Format) -> String {
1065    let text = fmt_percent(p, 2);
1066    let text = if text == "-0.00%" { "0.00%".to_string() } else { text };
1067    if format.fractions {
1068        if let Some((n, d)) = fraction(p) {
1069            if d > 1 {
1070                return format!("{text} (≈ {n}/{d})");
1071            }
1072        }
1073    }
1074    text
1075}
1076
1077fn fmt_percent(p: f64, decimals: usize) -> String {
1078    // Keep a nonzero tail visible rather than rounding an interval endpoint
1079    // or a nearly certain event to exactly 100%.
1080    let decimals = if p > 0.0 && p < 1.0 && (1.0 - p) * 100.0 < 0.5 * libm::pow(10.0, -(decimals as f64)) {
1081        (-libm::log10((1.0 - p) * 100.0).floor() + 2.0).clamp(decimals as f64, 14.0) as usize
1082    } else {
1083        decimals
1084    };
1085    format!("{}%", fixed(p * 100.0, decimals))
1086}
1087
1088/// The simplest fraction within 1e-13 of `x` with a denominator of at most a
1089/// million. It helps recognize an answer; it doesn't prove the answer is
1090/// exactly that fraction (docs/semantics.md, section 10).
1091pub fn fraction(x: f64) -> Option<(u64, u64)> {
1092    if !(0.0..=1.0).contains(&x) {
1093        return None;
1094    }
1095    let (mut h0, mut h1, mut k0, mut k1) = (0f64, 1f64, 1f64, 0f64);
1096    let mut v = x;
1097    for _ in 0..64 {
1098        let a = v.floor();
1099        let (h2, k2) = (a * h1 + h0, a * k1 + k0);
1100        if k2 > 1e6 {
1101            break;
1102        }
1103        (h0, h1, k0, k1) = (h1, h2, k1, k2);
1104        if (h1 / k1 - x).abs() < 1e-13 {
1105            return Some((h1 as u64, k1 as u64));
1106        }
1107        let frac = v - a;
1108        if frac < 1e-15 {
1109            break;
1110        }
1111        v = 1.0 / frac;
1112    }
1113    None
1114}
1115
1116/// A value as reports print it.
1117pub fn display(v: &Value) -> String {
1118    match v {
1119        Value::Int(i) => integer_text(i),
1120        // Twelve significant digits hide rounding like 0.30000000000000004.
1121        Value::Float(f) if f.is_finite() => fmt_float(format!("{f:.11e}").parse().unwrap_or(*f)),
1122        Value::Float(f) => fmt_float(*f),
1123        other => other.to_string(),
1124    }
1125}
1126
1127/// Integers with separators; floats with `decimals` decimals.
1128fn number(v: &Value, decimals: usize) -> String {
1129    match v {
1130        Value::Int(i) => integer_text(i),
1131        Value::Float(f) => fixed(*f, decimals),
1132        other => other.to_string(),
1133    }
1134}
1135
1136fn fixed(x: f64, decimals: usize) -> String {
1137    if !x.is_finite() {
1138        return "out of range".into();
1139    }
1140    if x != 0.0 && x.abs() < 0.5 * libm::pow(10.0, -(decimals as f64)) {
1141        return format!("{x:.2e}");
1142    }
1143    let text = format!("{:.*}", decimals, x);
1144    let text = if text.starts_with('-') && text.trim_start_matches(['-', '0', '.']).is_empty() {
1145        text[1..].to_string()
1146    } else {
1147        text
1148    };
1149    let (int, frac) = match text.find('.') {
1150        Some(i) => (&text[..i], &text[i..]),
1151        None => (text.as_str(), ""),
1152    };
1153    let (sign, digits) = int.strip_prefix('-').map_or(("", int), |d| ("-", d));
1154    format!("{sign}{}{frac}", group(digits))
1155}
1156
1157/// An integer with thousands separators: `50,000`.
1158pub fn thousands(i: i64) -> String {
1159    let digits = i.unsigned_abs().to_string();
1160    format!("{}{}", if i < 0 { "-" } else { "" }, group(&digits))
1161}
1162
1163fn group(digits: &str) -> String {
1164    if digits.len() <= 3 {
1165        return digits.to_string();
1166    }
1167    let mut out = String::new();
1168    for (i, c) in digits.chars().enumerate() {
1169        if i > 0 && (digits.len() - i) % 3 == 0 {
1170            out.push(',');
1171        }
1172        out.push(c);
1173    }
1174    out
1175}
1176
1177fn integer_text(i: &probl_number::Integer) -> String {
1178    let text = i.to_string();
1179    let (sign, digits) = text.strip_prefix('-').map_or(("", text.as_str()), |d| ("-", d));
1180    format!("{sign}{}", group(digits))
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::*;
1186
1187    fn plain() -> Format {
1188        Format {
1189            fractions: false,
1190            unresolved: Weight::ZERO,
1191            program_total: Weight::ONE,
1192            run_squares: None,
1193            weighted: false,
1194            reach_known: true,
1195        }
1196    }
1197
1198    #[test]
1199    fn fractions() {
1200        assert_eq!(fraction(244.0 / 495.0), Some((244, 495)));
1201        assert_eq!(fraction(1.0 / 6.0), Some((1, 6)));
1202        assert_eq!(fraction(0.5), Some((1, 2)));
1203        assert_eq!(fraction(std::f64::consts::FRAC_1_SQRT_2), None);
1204        let with = Format {
1205            fractions: true,
1206            ..plain()
1207        };
1208        assert_eq!(pct(244.0 / 495.0, with), "49.29% (≈ 244/495)");
1209    }
1210
1211    #[test]
1212    fn numbers() {
1213        assert_eq!(thousands(1234567), "1,234,567");
1214        assert_eq!(thousands(-1000), "-1,000");
1215        assert_eq!(thousands(i64::MIN), "-9,223,372,036,854,775,808");
1216        assert_eq!(fixed(3.375, 2), "3.38");
1217        assert_eq!(fixed(-0.001, 2), "-1.00e-3");
1218        assert_eq!(fixed(-0.0, 2), "0.00");
1219        assert_eq!(fixed(92282.9, 0), "92,283");
1220        assert_eq!(pct(0.4929292929, plain()), "49.29%");
1221        assert_eq!(pct(1e-10, plain()), "1.00e-8%");
1222        assert_eq!(estimate(1e-10, 1e-12), "1.00e-8% ± 1.00e-10%");
1223        assert_eq!(number(&Value::Float(-1e-10), 2), "-1.00e-10");
1224        assert_ne!(pct(1.0 - 1e-10, plain()), "100.00%");
1225        assert_eq!(pct(1.0, plain()), "100.00%");
1226    }
1227
1228    #[test]
1229    fn wilson_intervals_cover_boundaries_and_an_interior_reference() {
1230        let close = |a: f64, b: f64| assert!((a - b).abs() < 1e-12, "{a} != {b}");
1231        let (lo, hi) = wilson95(2, 2);
1232        close(lo, 0.342380227506653);
1233        assert_eq!(hi, 1.0);
1234        let (lo, hi) = wilson95(0, 1000);
1235        assert_eq!(lo, 0.0);
1236        close(hi, 0.0038267584855551234);
1237        let (lo, hi) = wilson95(50, 100);
1238        close(lo, 0.4038315303659956);
1239        close(hi, 0.5961684696340044);
1240        assert!(wilson95(0, 1).1 > 0.79);
1241    }
1242
1243    #[test]
1244    fn report_counts_and_intervals_merge_across_batches() {
1245        let mut combined = Sink::default();
1246        for _ in 0..3 {
1247            let mut batch = Sink::default();
1248            for run in 0..1000 {
1249                batch.add(Value::Unit, &Value::Bool(true), Weight::ONE, Some(run));
1250            }
1251            batch.end_batch();
1252            combined.absorb(batch);
1253        }
1254        let acc = &combined.groups[&Value::Unit];
1255        assert_eq!(acc.contributing_runs(), 3000);
1256        assert!((acc.effective() - 3000.0).abs() < 1e-8);
1257        assert_eq!(acc.chance_interval95(ReportKind::Once), Some(wilson95(3000, 3000)));
1258        assert_eq!(acc.chance_interval95(ReportKind::PerVisit), None);
1259
1260        // Each batch has equal weights, but their union does not.
1261        let mut weighted = Sink::default();
1262        weighted.add(Value::Unit, &Value::Bool(true), Weight::new(0.1), Some(0));
1263        weighted.end_batch();
1264        combined.absorb(weighted);
1265        let acc = &combined.groups[&Value::Unit];
1266        assert_eq!(acc.contributing_runs(), 3001);
1267        assert_eq!(acc.chance_interval95(ReportKind::Once), None);
1268    }
1269
1270    #[test]
1271    fn repeated_visits_do_not_inflate_independent_sample_counts() {
1272        let mut sink = Sink::default();
1273        sink.add(Value::Unit, &Value::Bool(false), Weight::ONE, Some(0));
1274        for _ in 0..9 {
1275            sink.add(Value::Unit, &Value::Bool(true), Weight::ONE, Some(1));
1276        }
1277        sink.end_batch();
1278        let acc = &sink.groups[&Value::Unit];
1279        assert_eq!(acc.contributing_runs(), 2);
1280        assert!((acc.effective() - 100.0 / 82.0).abs() < 1e-12);
1281        assert_eq!(acc.chance_interval95(ReportKind::Once), None);
1282        let support = Support {
1283            contributing_runs: acc.contributing_runs(),
1284            effective: acc.effective(),
1285        };
1286        assert!(reliability_note(Some(support)).contains("2 contributing runs"));
1287
1288        // A partially resolved numeric distribution contributes its resolved
1289        // mass to the mean's denominator, rather than one whole visit.
1290        let partial = crate::dist::Dist::from_pairs(vec![(Value::Float(1.0), 0.1)], 0.9).into_value();
1291        let mut numeric = Sink::default();
1292        numeric.add(Value::Unit, &partial, Weight::ONE, Some(0));
1293        numeric.add(Value::Unit, &Value::Float(1.0), Weight::ONE, Some(1));
1294        numeric.end_batch();
1295        assert!((numeric.groups[&Value::Unit].effective() - 1.21 / 1.01).abs() < 1e-12);
1296    }
1297
1298    #[test]
1299    fn bounds_widen_with_unresolved_weight() {
1300        let mut sink = Sink::default();
1301        sink.add(Value::Unit, &Value::Bool(false), Weight::new(1e-5), None);
1302        let acc = &sink.groups[&Value::Unit];
1303        // Half a percent of the weight was cut before it could reach the report.
1304        let (lo, hi) = acc.chance_bounds(Weight::new(0.005));
1305        assert!(lo == 0.0 && (hi - 0.005 / (1e-5 + 0.005)).abs() < 1e-12);
1306        let format = Format {
1307            unresolved: Weight::new(0.005),
1308            ..plain()
1309        };
1310        let group = results::group(&Value::Unit, acc, format, ReportKind::Once, format.unresolved);
1311        let fact = group.fact.unwrap();
1312        assert!(!fact.complete && fact.bounds == Some((lo, hi)));
1313        assert_eq!(chance_text(&fact, format), "0.00%–99.80%");
1314    }
1315}