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