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