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::{Fields, 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 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
56fn is_aggregate(v: &Value) -> bool {
59 matches!(v, Value::Record(_) | Value::List(_) | Value::Map(_))
60}
61
62pub(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#[derive(Clone, Copy, Debug, Default)]
105struct Sums {
106 aa: Weight,
107 ab: Weight,
108 ab_negative: Weight,
109}
110
111#[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 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 if v <= 1e-12 * (aa + bb) {
159 return 0.0;
160 }
161 v.sqrt()
162 }
163}
164
165#[derive(Clone, Debug, Default)]
167struct Moments {
168 contributing_runs: u64,
169 not_bernoulli: bool,
172 bernoulli_weight: Option<Weight>,
173 successes: u64,
174 integrated: bool,
175 facts: Base,
177 yes: Sums,
178 numbers: Base,
181 sum: Sums,
182 sum_positive: Weight,
183 sum_negative: Weight,
184 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#[derive(Clone, Debug)]
217pub struct RunStat {
218 pub weight: Weight,
220 pub visits: u32,
221 integrated: bool,
224 pub yes: f64,
226 pub facts: f64,
228 pub sum: f64,
230 pub numbers: f64,
232 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 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 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 pub fn is_event(&self) -> bool {
330 self.values.is_empty() && !self.facts.is_zero()
331 }
332
333 pub fn chance(&self) -> f64 {
336 self.yes.ratio(self.facts)
337 }
338
339 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 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 fn unresolved_share(&self, unresolved: Weight) -> f64 {
364 let u = unresolved + self.missing;
365 u.ratio(self.total + unresolved)
366 }
367
368 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 pub fn sampled(&self) -> bool {
393 self.moments.is_some() || !self.runs.is_empty()
394 }
395
396 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 pub fn contributing_runs(&self) -> u64 {
449 self.moments.as_ref().map_or(0, |m| m.contributing_runs)
450 }
451
452 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 pub fn chance_se(&self) -> f64 {
485 let m = self.moments();
486 m.yes.standard_error(&m.facts, self.chance())
487 }
488
489 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 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#[derive(Clone, Debug, Default)]
510pub struct Sink {
511 pub groups: BTreeMap<Value, Acc>,
512 structured: bool,
514 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 #[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 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 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 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 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 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 pub fn chance(&self) -> Option<f64> {
666 let acc = self.groups.get(&Value::Unit)?;
667 acc.is_event().then(|| acc.chance())
668 }
669
670 pub fn distribution(&self) -> Vec<(Value, f64)> {
672 self.groups.get(&Value::Unit).map(Acc::distribution).unwrap_or_default()
673 }
674
675 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 pub fractions: bool,
685 pub unresolved: Weight,
687 pub program_total: Weight,
689 pub run_squares: Option<Weight>,
691 pub weighted: bool,
694 pub reach_known: bool,
698 pub integrate: Option<f64>,
701}
702
703pub fn render(sites: &[ReportSite], sinks: &[Sink], format: Format) -> String {
705 render_results(sites, &results(sites, sinks, format, format.unresolved), format)
706}
707
708pub 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 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
762fn 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
788fn 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
850fn 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
886fn 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
930fn 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
945fn 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
958pub 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
970pub 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
994fn 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 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
1016fn 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 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
1075fn 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
1099fn 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
1120fn 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 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
1235pub 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 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
1262pub 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
1290pub fn display(v: &Value) -> String {
1292 match v {
1293 Value::Int(i) => integer_text(i),
1294 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
1301fn 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
1331pub 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 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 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 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}