1use 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#[derive(Clone, Debug)]
17pub struct Acc {
18 pub total: Weight,
20 pub yes: Weight,
22 pub facts: Weight,
24 pub values: FxHashMap<Value, Weight>,
26 pub missing: Weight,
28 runs: FxHashMap<u32, RunStat>,
30 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#[derive(Clone, Copy, Debug, Default)]
57struct Sums {
58 aa: Weight,
59 ab: Weight,
60 ab_negative: Weight,
61}
62
63#[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 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 if v <= 1e-12 * (aa + bb) {
111 return 0.0;
112 }
113 v.sqrt()
114 }
115}
116
117#[derive(Clone, Debug, Default)]
119struct Moments {
120 contributing_runs: u64,
121 not_bernoulli: bool,
124 bernoulli_weight: Option<Weight>,
125 successes: u64,
126 integrated: bool,
127 facts: Base,
129 yes: Sums,
130 numbers: Base,
133 sum: Sums,
134 sum_positive: Weight,
135 sum_negative: Weight,
136 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#[derive(Clone, Debug)]
169pub struct RunStat {
170 pub weight: Weight,
172 pub visits: u32,
173 integrated: bool,
176 pub yes: f64,
178 pub facts: f64,
180 pub sum: f64,
182 pub numbers: f64,
184 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 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 pub fn is_event(&self) -> bool {
263 self.values.is_empty() && !self.facts.is_zero()
264 }
265
266 pub fn chance(&self) -> f64 {
269 self.yes.ratio(self.facts)
270 }
271
272 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 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 fn unresolved_share(&self, unresolved: Weight) -> f64 {
297 let u = unresolved + self.missing;
298 u.ratio(self.total + unresolved)
299 }
300
301 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 pub fn sampled(&self) -> bool {
325 self.moments.is_some() || !self.runs.is_empty()
326 }
327
328 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 pub fn contributing_runs(&self) -> u64 {
381 self.moments.as_ref().map_or(0, |m| m.contributing_runs)
382 }
383
384 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 pub fn chance_se(&self) -> f64 {
417 let m = self.moments();
418 m.yes.standard_error(&m.facts, self.chance())
419 }
420
421 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 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#[derive(Clone, Debug, Default)]
442pub struct Sink {
443 pub groups: BTreeMap<Value, Acc>,
444 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 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 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 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 pub fn chance(&self) -> Option<f64> {
532 let acc = self.groups.get(&Value::Unit)?;
533 acc.is_event().then(|| acc.chance())
534 }
535
536 pub fn distribution(&self) -> Vec<(Value, f64)> {
538 self.groups.get(&Value::Unit).map(Acc::distribution).unwrap_or_default()
539 }
540
541 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 pub fractions: bool,
551 pub unresolved: Weight,
553 pub program_total: Weight,
555 pub run_squares: Option<Weight>,
557 pub weighted: bool,
560}
561
562pub fn render(sites: &[ReportSite], sinks: &[Sink], format: Format) -> String {
564 render_results(sites, &results(sites, sinks, format, format.unresolved), format)
565}
566
567pub fn render_results(sites: &[ReportSite], results: &[ReportResult], format: Format) -> String {
570 let mut out = String::new();
571 let mut simple: Vec<(String, String)> = Vec::new();
572 let flush = |simple: &mut Vec<(String, String)>, out: &mut String| {
573 let width = simple.iter().map(|(l, _)| l.chars().count()).max().unwrap_or(0);
574 for (label, text) in simple.drain(..) {
575 let pad = width - label.chars().count();
576 writeln!(out, "{label}{} {text}", " ".repeat(pad)).unwrap();
577 }
578 };
579 for (site, result) in sites.iter().zip(results) {
580 let mut label = site.label.clone();
581 if site.kind == ReportKind::PerVisit {
582 label.push_str(" (per visit)");
583 }
584 let reach = reach_note(result.reach, format);
585 if site.key_label.is_none() {
586 let text = match result.groups.first() {
587 None => "(never reached)".to_string(),
588 Some(group) => format!("{}{reach}", value_text(group, format)),
589 };
590 simple.push((label, text));
591 continue;
592 }
593 if !simple.is_empty() {
594 flush(&mut simple, &mut out);
595 }
596 if !out.is_empty() && !out.ends_with("\n\n") {
598 out.push('\n');
599 }
600 writeln!(out, "{label}{reach}").unwrap();
601 if result.groups.is_empty() {
602 writeln!(out, " (never reached)").unwrap();
603 } else {
604 out.push_str(&table(site.key_label.as_deref().unwrap_or(""), result, format));
605 }
606 out.push('\n');
607 }
608 flush(&mut simple, &mut out);
609 while out.ends_with("\n\n") {
610 out.pop();
611 }
612 out
613}
614
615fn reach_note(reach: Option<Reach>, format: Format) -> String {
617 let Some(reach) = reach else {
618 return String::new();
619 };
620 if reach.share >= 0.99995 {
621 return String::new();
622 }
623 if let Some(se) = reach.se {
624 return format!(" (reached in {} of runs)", estimate(reach.share, se));
625 }
626 format!(
627 " (reached in {} of worlds)",
628 pct(
629 reach.share,
630 Format {
631 fractions: false,
632 ..format
633 }
634 )
635 )
636}
637
638fn value_text(group: &GroupResult, format: Format) -> String {
639 if let Some(fact) = &group.fact {
640 return chance_text(fact, format);
641 }
642 let dist = &group.distribution;
643 let mut text = if let Some(numeric) = group.numeric.as_ref().filter(|n| n.mixture().is_some()) {
644 analytic_stats(numeric)
645 } else if dist.len() == 1 {
646 display(&dist[0].0)
647 } else if let Some(numeric) = &group.numeric {
648 numeric_stats(numeric, dist)
649 } else if dist.iter().all(|(v, _)| matches!(v, Value::Date(_))) {
650 let [a, b, c] =
651 [0.05, 0.5, 0.95].map(|q| summary_quantile(dist, q).map_or_else(|| "out of range".into(), |v| display(&v)));
652 format!("5% {a} · median {b} · 95% {c}")
653 } else if group.support.is_some() {
654 categorical_sampled(group)
655 } else {
656 categorical(dist, format)
657 };
658 let share = group.unresolved_share;
659 if share >= 0.00005 {
660 write!(
661 text,
662 " · {} unresolved",
663 pct(
664 share,
665 Format {
666 fractions: false,
667 ..format
668 }
669 )
670 )
671 .unwrap();
672 }
673 text.push_str(&reliability_note(group.support));
674 text
675}
676
677fn chance_text(fact: &Quantity, format: Format) -> String {
680 let p = fact.point.unwrap_or(f64::NAN);
681 if let Some(sampling) = fact.sampling {
682 let runs = thousands(sampling.support.contributing_runs as i64);
683 let mut text = if let Some((lo, hi)) = sampling.wilson {
684 format!(
685 "{} (95% Wilson interval {}–{}; {runs} contributing runs)",
686 pct(p, format),
687 pct(lo, format),
688 pct(hi, format),
689 )
690 } else if let (Status::Estimated, Some(se)) = (sampling.status, sampling.se) {
691 estimate(p, se)
692 } else {
693 let note = if sampling.status == Status::IntegratedZero {
694 "zero empirical MC error; integrated outcomes"
695 } else {
696 "MC error not estimable"
697 };
698 format!("{} ({note}; {runs} contributing runs)", pct(p, format))
699 };
700 text.push_str(&reliability_note(Some(sampling.support)));
701 return text;
702 }
703 if let Some((lo, hi)) = fact.bounds.filter(|(lo, hi)| hi - lo >= 0.00005) {
704 let plain = Format {
705 fractions: false,
706 ..format
707 };
708 return format!("{}–{}", pct(lo, plain), pct(hi, plain));
709 }
710 pct(p, format)
711}
712
713fn wilson95(successes: u64, trials: u64) -> (f64, f64) {
716 let n = trials as f64;
717 let p = successes as f64 / n;
718 let z2 = 1.959963984540054_f64.powi(2);
719 let denominator = 1.0 + z2 / n;
720 let center = (p + z2 / (2.0 * n)) / denominator;
721 let half = (z2 * (p * (1.0 - p) / n + z2 / (4.0 * n * n))).sqrt() / denominator;
722 (
723 if successes == 0 { 0.0 } else { (center - half).max(0.0) },
724 if successes == trials {
725 1.0
726 } else {
727 (center + half).min(1.0)
728 },
729 )
730}
731
732fn reliability_note(support: Option<Support>) -> String {
733 let Some(support) = support.filter(|s| s.effective < 30.0) else {
734 return String::new();
735 };
736 format!(
737 " (low sample support: {} contributing runs; effective sample size {})",
738 thousands(support.contributing_runs as i64),
739 fixed(support.effective, 1)
740 )
741}
742
743fn categorical(dist: &[(Value, f64)], format: Format) -> String {
744 let mut by_chance: Vec<&(Value, f64)> = dist.iter().collect();
745 by_chance.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
746 let shown = by_chance.len().min(12);
747 let mut parts: Vec<String> = by_chance[..shown]
748 .iter()
749 .map(|(v, p)| format!("{} {}", display(v), pct(*p, format)))
750 .collect();
751 if by_chance.len() > shown {
752 parts.push(format!("… {} more", by_chance.len() - shown));
753 }
754 parts.join(" · ")
755}
756
757fn categorical_sampled(group: &GroupResult) -> String {
759 let mut by_chance: Vec<&(Value, f64)> = group.distribution.iter().collect();
760 by_chance.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
761 let shown = by_chance.len().min(12);
762 let mut parts: Vec<String> = by_chance[..shown]
763 .iter()
764 .map(|(v, p)| format!("{} {}", display(v), sampled_value_text(group, v, *p)))
765 .collect();
766 if by_chance.len() > shown {
767 parts.push(format!("… {} more", by_chance.len() - shown));
768 }
769 parts.join(" · ")
770}
771
772fn sampled_value_text(group: &GroupResult, value: &Value, p: f64) -> String {
775 let quantity = group.values.iter().flatten().find(|(v, _)| v == value).map(|(_, q)| q);
776 match quantity
777 .and_then(|q| q.sampling)
778 .filter(|s| s.status == Status::Estimated)
779 {
780 Some(Uncertainty { se: Some(se), .. }) => estimate(p, se),
781 _ => format!("{}% (MC error not estimable)", fixed(p * 100.0, 2)),
782 }
783}
784
785pub fn estimate(p: f64, se: f64) -> String {
788 let se = se * 100.0;
789 let decimals = if se < 0.005 {
790 2
791 } else {
792 (-libm::log10(se).floor()).clamp(0.0, 2.0) as usize
793 };
794 format!("{} ± {}%", fmt_percent(p, decimals), fixed(se, decimals))
795}
796
797pub fn analytic_mixture(dist: &[(Value, f64)]) -> Option<crate::continuous::Mixture> {
800 use crate::continuous::{Mixture, Part};
801 if !dist
802 .iter()
803 .any(|(v, _)| matches!(v, Value::Analytic(_) | Value::Continuous(_)))
804 {
805 return None;
806 }
807 let parts = dist
808 .iter()
809 .map(|(v, p)| {
810 let part = match v {
811 Value::Analytic(a) => Part::Analytic((**a).clone()),
812 Value::Continuous(f) => Part::Continuous(**f),
813 v => Part::Point(v.as_f64()?),
814 };
815 Some((part, *p))
816 })
817 .collect::<Option<_>>()?;
818 Some(Mixture { parts })
819}
820
821fn mixture_quantile(numeric: &Numeric, q: f64, decimals: usize) -> String {
823 fixed(numeric.quantile(q).and_then(|x| x.point).unwrap_or(f64::NAN), decimals)
824}
825
826fn analytic_stats(numeric: &Numeric) -> String {
827 let mean = numeric.mean.point.unwrap_or(f64::NAN);
828 let sd = numeric.sd.point.unwrap_or(f64::NAN);
829 let decimals = if mean.abs().max(sd) < 100.0 { 2 } else { 0 };
830 let [a, b, c] = [0.05, 0.5, 0.95].map(|q| mixture_quantile(numeric, q, decimals));
831 format!(
832 "mean {} · sd {} · 5% {a} · median {b} · 95% {c}",
833 fixed(mean, decimals),
834 fixed(sd, decimals)
835 )
836}
837
838fn numeric_stats(numeric: &Numeric, dist: &[(Value, f64)]) -> String {
842 let nums = numeric.points().unwrap_or_default();
843 let percent = numeric.percent;
844 let total: f64 = nums.iter().map(|(_, p)| p).sum();
845 let mean = numeric.mean.point.unwrap_or(f64::NAN);
846 let sd = numeric.sd.point.unwrap_or(f64::NAN);
847 let mean_se = numeric.mean.sampling.and_then(|s| s.se);
848 let show = |x: f64, decimals: usize| {
849 if percent { fmt_percent(x, 2) } else { fixed(x, decimals) }
850 };
851 let decimals = if mean.abs().max(sd) < 100.0 { 2 } else { 0 };
852 let roundoff_factor = (nums.len() as f64 + 2.0) * f64::EPSILON;
856 let absolute_mean = nums.iter().map(|(x, p)| x.abs() * p).sum::<f64>() / total;
857 let cancellation = nums.iter().any(|(x, _)| *x < 0.0)
858 && nums.iter().any(|(x, _)| *x > 0.0)
859 && mean.abs() <= absolute_mean * roundoff_factor / (1.0 - roundoff_factor);
860 let shown_mean = if cancellation {
861 if percent { "≈0%" } else { "≈0" }.to_string()
862 } else {
863 show(mean, decimals)
864 };
865 let [a, b, c] = [0.05, 0.5, 0.95].map(|q| {
866 let Some(v) = numeric.quantile_value(q) else {
867 return "out of range".to_string();
868 };
869 if percent {
870 show(v.as_f64().unwrap(), 2)
871 } else {
872 number(&v, decimals)
873 }
874 });
875 let mean_text = match mean_se {
876 Some(se)
877 if se > 0.0
878 && (se * if percent { 100.0 } else { 1.0 } >= 0.5 * libm::pow(10.0, -(decimals as f64))
879 || (mean != 0.0 && mean.abs() * if percent { 100.0 } else { 1.0 } < 0.005)) =>
880 {
881 format!("{} ± {}", shown_mean, show(se, decimals))
882 }
883 _ => shown_mean,
884 };
885 let mut text = format!(
886 "mean {mean_text} · sd {} · 5% {a} · median {b} · 95% {c}",
887 show(sd, decimals)
888 );
889 if let Some(spark) = sparkline(dist) {
890 write!(text, " · {spark}").unwrap();
891 }
892 text
893}
894
895const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
896
897fn sparkline(dist: &[(Value, f64)]) -> Option<String> {
899 let ints: Vec<(i64, f64)> = dist
900 .iter()
901 .map(|(v, p)| match v {
902 Value::Int(i) => i.to_i64().map(|n| (n, *p)),
903 _ => None,
904 })
905 .collect::<Option<_>>()?;
906 let (lo, hi) = (ints.first()?.0, ints.last()?.0);
907 if hi as i128 - lo as i128 + 1 > 25 || hi == lo {
908 return None;
909 }
910 let max = ints.iter().map(|(_, p)| *p).fold(0.0, f64::max);
911 let by_value: BTreeMap<i64, f64> = ints.into_iter().collect();
912 let bars: String = (lo..=hi)
913 .map(|i| match by_value.get(&i) {
914 Some(&p) if p > 0.0 => BLOCKS[((p / max * 8.0).ceil() as usize).clamp(1, 8) - 1],
915 _ => ' ',
916 })
917 .collect();
918 Some(format!("{lo} {bars} {hi}"))
919}
920
921fn summary_quantile(dist: &[(Value, f64)], q: f64) -> Option<Value> {
926 if q != 0.5 {
927 return Some(quantile(dist, q));
928 }
929 let (lo, hi) = crate::stats::median_bounds(dist)?;
930 if lo == hi {
931 return Some(lo.clone());
932 }
933 crate::builtins::midpoint(lo, hi, &mut crate::dist::Budget::unlimited()).ok()
934}
935
936fn quantile(dist: &[(Value, f64)], q: f64) -> Value {
937 crate::stats::quantile(dist, q)
938 .expect("nonempty report population")
939 .clone()
940}
941
942fn table(key_label: &str, result: &ReportResult, format: Format) -> String {
944 let groups = &result.groups;
945 let keys: Vec<String> = groups.iter().map(|g| display(&g.key)).collect();
946 let mut rows: Vec<Vec<String>> = Vec::new();
947 let header: Vec<String>;
948 let all_facts = groups.iter().all(|g| g.fact.is_some());
949
950 if all_facts {
951 header = Vec::new();
952 for (key, group) in keys.iter().zip(groups) {
953 rows.push(vec![key.clone(), value_text(group, format)]);
954 }
955 } else {
956 let numeric = groups.iter().all(|g| {
957 g.distribution.iter().all(|(v, _)| {
958 matches!(
959 v,
960 Value::Int(_) | Value::Float(_) | Value::Analytic(_) | Value::Continuous(_)
961 )
962 })
963 });
964 if numeric {
965 header = ["5%", "25%", "median", "75%", "95%"].map(String::from).to_vec();
966 for (key, group) in keys.iter().zip(groups) {
967 if let Some(numeric) = group.numeric.as_ref().filter(|n| n.mixture().is_some()) {
968 let (mean, sd) = (
969 numeric.mean.point.unwrap_or(f64::NAN),
970 numeric.sd.point.unwrap_or(f64::NAN),
971 );
972 let decimals = if mean.abs().max(sd) < 100.0 { 2 } else { 0 };
973 let mut row = vec![key.clone()];
974 row.extend([0.05, 0.25, 0.5, 0.75, 0.95].map(|q| mixture_quantile(numeric, q, decimals)));
975 rows.push(row);
976 continue;
977 }
978 let d = &group.distribution;
979 let scale = d
980 .iter()
981 .filter_map(|(v, _)| v.as_f64())
982 .fold(0.0f64, |m, x| m.max(x.abs()));
983 let decimals = if scale < 100.0 { 2 } else { 0 };
984 let mut row = vec![key.clone()];
985 row.extend(
986 [0.05, 0.25, 0.5, 0.75, 0.95].map(|q| {
987 summary_quantile(d, q).map_or_else(|| "out of range".into(), |v| number(&v, decimals))
988 }),
989 );
990 rows.push(row);
991 }
992 } else {
993 let mut columns: Vec<Value> = groups
994 .iter()
995 .flat_map(|g| g.distribution.iter().map(|(v, _)| v.clone()))
996 .collect();
997 columns.sort();
998 columns.dedup();
999 header = columns.iter().map(display).collect();
1000 for (key, group) in keys.iter().zip(groups) {
1001 let mut row = vec![key.clone()];
1002 for c in &columns {
1003 let p = group.distribution.iter().find(|(v, _)| v == c).map_or(0.0, |(_, p)| *p);
1004 row.push(if group.support.is_some() {
1005 sampled_value_text(group, c, p)
1006 } else {
1007 pct(p, format)
1008 });
1009 }
1010 rows.push(row);
1011 }
1012 }
1013 }
1014
1015 let mut header = header;
1018 if !all_facts && groups.iter().any(|g| !reliability_note(g.support).is_empty()) {
1019 if !header.is_empty() {
1020 header.push("reliability".to_string());
1021 }
1022 for (row, group) in rows.iter_mut().zip(groups) {
1023 row.push(reliability_note(group.support).trim().to_string());
1024 }
1025 }
1026 let mut all = Vec::new();
1027 if !header.is_empty() {
1028 let mut h = vec![key_label.to_string()];
1029 h.extend(header);
1030 all.push(h);
1031 }
1032 all.extend(rows);
1033 let ncols = all.iter().map(Vec::len).max().unwrap_or(0);
1034 let widths: Vec<usize> = (0..ncols)
1035 .map(|c| {
1036 all.iter()
1037 .filter_map(|r| r.get(c))
1038 .map(|s| s.chars().count())
1039 .max()
1040 .unwrap_or(0)
1041 })
1042 .collect();
1043 let mut out = String::new();
1044 for row in all {
1045 out.push_str(" ");
1046 let cells: Vec<String> = row
1047 .iter()
1048 .enumerate()
1049 .map(|(c, cell)| format!("{}{cell}", " ".repeat(widths[c] - cell.chars().count())))
1050 .collect();
1051 out.push_str(&cells.join(" "));
1052 out.push('\n');
1053 }
1054 out
1055}
1056
1057pub fn pct(p: f64, format: Format) -> String {
1061 let text = fmt_percent(p, 2);
1062 let text = if text == "-0.00%" { "0.00%".to_string() } else { text };
1063 if format.fractions {
1064 if let Some((n, d)) = fraction(p) {
1065 if d > 1 {
1066 return format!("{text} (≈ {n}/{d})");
1067 }
1068 }
1069 }
1070 text
1071}
1072
1073fn fmt_percent(p: f64, decimals: usize) -> String {
1074 let decimals = if p > 0.0 && p < 1.0 && (1.0 - p) * 100.0 < 0.5 * libm::pow(10.0, -(decimals as f64)) {
1077 (-libm::log10((1.0 - p) * 100.0).floor() + 2.0).clamp(decimals as f64, 14.0) as usize
1078 } else {
1079 decimals
1080 };
1081 format!("{}%", fixed(p * 100.0, decimals))
1082}
1083
1084pub fn fraction(x: f64) -> Option<(u64, u64)> {
1088 if !(0.0..=1.0).contains(&x) {
1089 return None;
1090 }
1091 let (mut h0, mut h1, mut k0, mut k1) = (0f64, 1f64, 1f64, 0f64);
1092 let mut v = x;
1093 for _ in 0..64 {
1094 let a = v.floor();
1095 let (h2, k2) = (a * h1 + h0, a * k1 + k0);
1096 if k2 > 1e6 {
1097 break;
1098 }
1099 (h0, h1, k0, k1) = (h1, h2, k1, k2);
1100 if (h1 / k1 - x).abs() < 1e-13 {
1101 return Some((h1 as u64, k1 as u64));
1102 }
1103 let frac = v - a;
1104 if frac < 1e-15 {
1105 break;
1106 }
1107 v = 1.0 / frac;
1108 }
1109 None
1110}
1111
1112pub fn display(v: &Value) -> String {
1114 match v {
1115 Value::Int(i) => integer_text(i),
1116 Value::Float(f) if f.is_finite() => fmt_float(format!("{f:.11e}").parse().unwrap_or(*f)),
1118 Value::Float(f) => fmt_float(*f),
1119 other => other.to_string(),
1120 }
1121}
1122
1123fn number(v: &Value, decimals: usize) -> String {
1125 match v {
1126 Value::Int(i) => integer_text(i),
1127 Value::Float(f) => fixed(*f, decimals),
1128 other => other.to_string(),
1129 }
1130}
1131
1132fn fixed(x: f64, decimals: usize) -> String {
1133 if !x.is_finite() {
1134 return "out of range".into();
1135 }
1136 if x != 0.0 && x.abs() < 0.5 * libm::pow(10.0, -(decimals as f64)) {
1137 return format!("{x:.2e}");
1138 }
1139 let text = format!("{:.*}", decimals, x);
1140 let text = if text.starts_with('-') && text.trim_start_matches(['-', '0', '.']).is_empty() {
1141 text[1..].to_string()
1142 } else {
1143 text
1144 };
1145 let (int, frac) = match text.find('.') {
1146 Some(i) => (&text[..i], &text[i..]),
1147 None => (text.as_str(), ""),
1148 };
1149 let (sign, digits) = int.strip_prefix('-').map_or(("", int), |d| ("-", d));
1150 format!("{sign}{}{frac}", group(digits))
1151}
1152
1153pub fn thousands(i: i64) -> String {
1155 let digits = i.unsigned_abs().to_string();
1156 format!("{}{}", if i < 0 { "-" } else { "" }, group(&digits))
1157}
1158
1159fn group(digits: &str) -> String {
1160 if digits.len() <= 3 {
1161 return digits.to_string();
1162 }
1163 let mut out = String::new();
1164 for (i, c) in digits.chars().enumerate() {
1165 if i > 0 && (digits.len() - i) % 3 == 0 {
1166 out.push(',');
1167 }
1168 out.push(c);
1169 }
1170 out
1171}
1172
1173fn integer_text(i: &probl_number::Integer) -> String {
1174 let text = i.to_string();
1175 let (sign, digits) = text.strip_prefix('-').map_or(("", text.as_str()), |d| ("-", d));
1176 format!("{sign}{}", group(digits))
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182
1183 fn plain() -> Format {
1184 Format {
1185 fractions: false,
1186 unresolved: Weight::ZERO,
1187 program_total: Weight::ONE,
1188 run_squares: None,
1189 weighted: false,
1190 }
1191 }
1192
1193 #[test]
1194 fn fractions() {
1195 assert_eq!(fraction(244.0 / 495.0), Some((244, 495)));
1196 assert_eq!(fraction(1.0 / 6.0), Some((1, 6)));
1197 assert_eq!(fraction(0.5), Some((1, 2)));
1198 assert_eq!(fraction(std::f64::consts::FRAC_1_SQRT_2), None);
1199 let with = Format {
1200 fractions: true,
1201 ..plain()
1202 };
1203 assert_eq!(pct(244.0 / 495.0, with), "49.29% (≈ 244/495)");
1204 }
1205
1206 #[test]
1207 fn numbers() {
1208 assert_eq!(thousands(1234567), "1,234,567");
1209 assert_eq!(thousands(-1000), "-1,000");
1210 assert_eq!(thousands(i64::MIN), "-9,223,372,036,854,775,808");
1211 assert_eq!(fixed(3.375, 2), "3.38");
1212 assert_eq!(fixed(-0.001, 2), "-1.00e-3");
1213 assert_eq!(fixed(-0.0, 2), "0.00");
1214 assert_eq!(fixed(92282.9, 0), "92,283");
1215 assert_eq!(pct(0.4929292929, plain()), "49.29%");
1216 assert_eq!(pct(1e-10, plain()), "1.00e-8%");
1217 assert_eq!(estimate(1e-10, 1e-12), "1.00e-8% ± 1.00e-10%");
1218 assert_eq!(number(&Value::Float(-1e-10), 2), "-1.00e-10");
1219 assert_ne!(pct(1.0 - 1e-10, plain()), "100.00%");
1220 assert_eq!(pct(1.0, plain()), "100.00%");
1221 }
1222
1223 #[test]
1224 fn wilson_intervals_cover_boundaries_and_an_interior_reference() {
1225 let close = |a: f64, b: f64| assert!((a - b).abs() < 1e-12, "{a} != {b}");
1226 let (lo, hi) = wilson95(2, 2);
1227 close(lo, 0.342380227506653);
1228 assert_eq!(hi, 1.0);
1229 let (lo, hi) = wilson95(0, 1000);
1230 assert_eq!(lo, 0.0);
1231 close(hi, 0.0038267584855551234);
1232 let (lo, hi) = wilson95(50, 100);
1233 close(lo, 0.4038315303659956);
1234 close(hi, 0.5961684696340044);
1235 assert!(wilson95(0, 1).1 > 0.79);
1236 }
1237
1238 #[test]
1239 fn report_counts_and_intervals_merge_across_batches() {
1240 let mut combined = Sink::default();
1241 for _ in 0..3 {
1242 let mut batch = Sink::default();
1243 for run in 0..1000 {
1244 batch.add(Value::Unit, &Value::Bool(true), Weight::ONE, Some(run));
1245 }
1246 batch.end_batch();
1247 combined.absorb(batch);
1248 }
1249 let acc = &combined.groups[&Value::Unit];
1250 assert_eq!(acc.contributing_runs(), 3000);
1251 assert!((acc.effective() - 3000.0).abs() < 1e-8);
1252 assert_eq!(acc.chance_interval95(ReportKind::Once), Some(wilson95(3000, 3000)));
1253 assert_eq!(acc.chance_interval95(ReportKind::PerVisit), None);
1254
1255 let mut weighted = Sink::default();
1257 weighted.add(Value::Unit, &Value::Bool(true), Weight::new(0.1), Some(0));
1258 weighted.end_batch();
1259 combined.absorb(weighted);
1260 let acc = &combined.groups[&Value::Unit];
1261 assert_eq!(acc.contributing_runs(), 3001);
1262 assert_eq!(acc.chance_interval95(ReportKind::Once), None);
1263 }
1264
1265 #[test]
1266 fn repeated_visits_do_not_inflate_independent_sample_counts() {
1267 let mut sink = Sink::default();
1268 sink.add(Value::Unit, &Value::Bool(false), Weight::ONE, Some(0));
1269 for _ in 0..9 {
1270 sink.add(Value::Unit, &Value::Bool(true), Weight::ONE, Some(1));
1271 }
1272 sink.end_batch();
1273 let acc = &sink.groups[&Value::Unit];
1274 assert_eq!(acc.contributing_runs(), 2);
1275 assert!((acc.effective() - 100.0 / 82.0).abs() < 1e-12);
1276 assert_eq!(acc.chance_interval95(ReportKind::Once), None);
1277 let support = Support {
1278 contributing_runs: acc.contributing_runs(),
1279 effective: acc.effective(),
1280 };
1281 assert!(reliability_note(Some(support)).contains("2 contributing runs"));
1282
1283 let partial = crate::dist::Dist::from_pairs(vec![(Value::Float(1.0), 0.1)], 0.9).into_value();
1286 let mut numeric = Sink::default();
1287 numeric.add(Value::Unit, &partial, Weight::ONE, Some(0));
1288 numeric.add(Value::Unit, &Value::Float(1.0), Weight::ONE, Some(1));
1289 numeric.end_batch();
1290 assert!((numeric.groups[&Value::Unit].effective() - 1.21 / 1.01).abs() < 1e-12);
1291 }
1292
1293 #[test]
1294 fn bounds_widen_with_unresolved_weight() {
1295 let mut sink = Sink::default();
1296 sink.add(Value::Unit, &Value::Bool(false), Weight::new(1e-5), None);
1297 let acc = &sink.groups[&Value::Unit];
1298 let (lo, hi) = acc.chance_bounds(Weight::new(0.005));
1300 assert!(lo == 0.0 && (hi - 0.005 / (1e-5 + 0.005)).abs() < 1e-12);
1301 let format = Format {
1302 unresolved: Weight::new(0.005),
1303 ..plain()
1304 };
1305 let group = results::group(&Value::Unit, acc, format, ReportKind::Once, format.unresolved);
1306 let fact = group.fact.unwrap();
1307 assert!(!fact.complete && fact.bounds == Some((lo, hi)));
1308 assert_eq!(chance_text(&fact, format), "0.00%–99.80%");
1309 }
1310}