1use super::{Acc, Format, Sink, summary_quantile};
6use crate::continuous::Mixture;
7use crate::value::Value;
8use crate::weight::Weight;
9use probl_sema::ir::{ReportKind, ReportSite};
10
11#[derive(Clone, Debug)]
13pub struct ReportResult {
14 pub reach: Option<Reach>,
19 pub groups: Vec<GroupResult>,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct Reach {
25 pub share: f64,
26 pub se: Option<f64>,
28}
29
30#[derive(Clone, Debug)]
32pub struct GroupResult {
33 pub key: Value,
34 pub fact: Option<Quantity>,
36 pub values: Option<Vec<(Value, Quantity)>>,
40 pub distribution: Vec<(Value, f64)>,
43 pub numeric: Option<Numeric>,
45 pub unresolved_share: f64,
48 pub support: Option<Support>,
50 pub fields: Option<Box<Fields>>,
55}
56
57#[derive(Clone, Debug)]
60pub struct Fields(pub Vec<(String, GroupResult)>);
61
62#[derive(Clone, Copy, Debug, PartialEq)]
64pub struct Support {
65 pub contributing_runs: u64,
67 pub effective: f64,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct Quantity {
74 pub point: Option<f64>,
76 pub complete: bool,
78 pub bounds: Option<(f64, f64)>,
81 pub sampling: Option<Uncertainty>,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq)]
86pub struct Uncertainty {
87 pub status: Status,
88 pub se: Option<f64>,
90 pub wilson: Option<(f64, f64)>,
92 pub support: Support,
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum Status {
98 Estimated,
100 NotEstimable,
103 NotComputed,
105 IntegratedZero,
107}
108
109#[derive(Clone, Debug)]
111pub struct Numeric {
112 pub mean: Quantity,
113 pub sd: Quantity,
114 pub percent: bool,
116 pub integrated: bool,
119 shape: Shape,
120 complete: bool,
122 support: Option<Support>,
123}
124
125#[derive(Clone, Debug)]
126enum Shape {
127 Mixture(Mixture),
129 Points(Vec<(Value, f64)>),
131}
132
133impl Numeric {
134 pub fn mixture(&self) -> Option<&Mixture> {
136 match &self.shape {
137 Shape::Mixture(m) => Some(m),
138 Shape::Points(_) => None,
139 }
140 }
141
142 pub fn quantile(&self, q: f64) -> Option<Quantity> {
146 let point = match &self.shape {
147 Shape::Mixture(m) => Some(if q == 0.5 { m.median() } else { m.quantile(q) }),
148 Shape::Points(points) => summary_quantile(points, q).and_then(|v| v.as_f64()),
149 }?;
150 Some(Quantity {
151 point: Some(point),
152 complete: self.complete,
153 bounds: None,
154 sampling: self.support.map(not_computed),
155 })
156 }
157
158 pub fn quantile_value(&self, q: f64) -> Option<Value> {
161 match &self.shape {
162 Shape::Points(points) => summary_quantile(points, q),
163 Shape::Mixture(_) => None,
164 }
165 }
166
167 pub(super) fn points(&self) -> Option<Vec<(f64, f64)>> {
169 match &self.shape {
170 Shape::Points(points) => Some(
171 points
172 .iter()
173 .map(|(v, p)| (v.as_f64().unwrap_or(f64::NAN), *p))
174 .collect(),
175 ),
176 Shape::Mixture(_) => None,
177 }
178 }
179}
180
181fn not_computed(support: Support) -> Uncertainty {
182 Uncertainty {
183 status: Status::NotComputed,
184 se: None,
185 wilson: None,
186 support,
187 }
188}
189
190pub fn results(sites: &[ReportSite], sinks: &[Sink], format: Format, unresolved: Weight) -> Vec<ReportResult> {
194 sites
195 .iter()
196 .zip(sinks)
197 .map(|(site, sink)| {
198 let kind = if site.key_label.is_none() {
201 ReportKind::Once
202 } else {
203 site.kind
204 };
205 ReportResult {
206 reach: reach(site.kind, sink, format),
207 groups: sink
208 .groups
209 .iter()
210 .map(|(key, acc)| group(key, acc, format, kind, unresolved))
211 .collect(),
212 }
213 })
214 .collect()
215}
216
217fn reach(kind: ReportKind, sink: &Sink, format: Format) -> Option<Reach> {
218 if kind == ReportKind::PerVisit || sink.groups.is_empty() || format.program_total.is_zero() || !format.reach_known {
219 return None;
220 }
221 if let Some(all_squares) = format.run_squares {
222 let total = format.program_total;
225 let p = sink.reached.ratio(total);
226 let squares = sink.reached_squares;
227 let spread = squares.scale((1.0 - p) * (1.0 - p)) + all_squares.saturating_sub(squares).scale(p * p);
228 let se = spread.ratio(total * total).sqrt();
229 return Some(Reach { share: p, se: Some(se) });
230 }
231 Some(Reach {
232 share: sink.reach().ratio(format.program_total),
233 se: None,
234 })
235}
236
237pub(super) fn group(key: &Value, acc: &Acc, format: Format, kind: ReportKind, unresolved: Weight) -> GroupResult {
238 if acc.structured {
239 return structured(key, acc, format, kind, unresolved);
240 }
241 let sampled = acc.sampled();
242 let support = sampled.then(|| Support {
243 contributing_runs: acc.contributing_runs(),
244 effective: acc.effective(),
245 });
246 let complete = (unresolved + acc.missing).is_zero();
247 let distribution = acc.distribution();
248 let fact = acc
249 .is_event()
250 .then(|| fact(acc, format, kind, complete, unresolved, support));
251 let continuous = distribution
252 .iter()
253 .any(|(v, _)| matches!(v, Value::Analytic(_) | Value::Continuous(_)));
254 let values = (!continuous).then(|| values(acc, &distribution, complete, unresolved, support));
255 let numeric = if acc.is_event() {
256 None
257 } else {
258 numeric(acc, &distribution, complete, support, format.integrate)
259 };
260 GroupResult {
261 key: key.clone(),
262 fact,
263 values,
264 numeric,
265 unresolved_share: acc.unresolved_share(format.unresolved),
266 distribution,
267 support,
268 fields: None,
269 }
270}
271
272fn structured(key: &Value, acc: &Acc, format: Format, kind: ReportKind, unresolved: Weight) -> GroupResult {
275 let mut values: Vec<(&Value, Weight)> = acc.values.iter().map(|(v, w)| (v, *w)).collect();
276 values.sort_by(|a, b| a.0.cmp(b.0));
277 let mut fields: Vec<(String, Acc)> = Vec::new();
278 for (v, w) in values {
279 for (path, leaf) in super::fields(v) {
280 let i = match fields.iter().position(|(p, _)| *p == path) {
281 Some(i) => i,
282 None => {
283 fields.push((path, Acc::default()));
284 fields.len() - 1
285 }
286 };
287 let field = &mut fields[i].1;
288 field.total += w;
289 field.add(&leaf, w);
290 }
291 }
292 let fields = fields
293 .into_iter()
294 .map(|(path, mut field)| {
295 field.missing = acc.missing;
296 let result = group(&Value::Str(path.as_str().into()), &field, format, kind, unresolved);
297 (path, result)
298 })
299 .collect();
300 GroupResult {
301 key: key.clone(),
302 fact: None,
303 values: None,
304 distribution: Vec::new(),
305 numeric: None,
306 unresolved_share: acc.unresolved_share(format.unresolved),
307 support: None,
308 fields: Some(Box::new(Fields(fields))),
309 }
310}
311
312fn fact(
314 acc: &Acc,
315 format: Format,
316 kind: ReportKind,
317 complete: bool,
318 unresolved: Weight,
319 support: Option<Support>,
320) -> Quantity {
321 let p = acc.chance();
322 let sampling = support.map(|support| {
323 let se = acc.chance_se();
324 let wilson = acc
328 .chance_interval95(kind)
329 .filter(|_| !format.weighted)
330 .filter(|_| p == 0.0 || p == 1.0 || support.contributing_runs < 30);
331 let integrated = acc.moments.as_ref().is_some_and(|m| m.integrated);
332 let (status, se) = if se != 0.0 {
333 (Status::Estimated, Some(se))
334 } else if integrated {
335 (Status::IntegratedZero, Some(0.0))
336 } else {
337 (Status::NotEstimable, None)
338 };
339 Uncertainty {
340 status,
341 se,
342 wilson,
343 support,
344 }
345 });
346 Quantity {
347 point: Some(p),
348 complete,
349 bounds: (!complete && support.is_none()).then(|| acc.chance_bounds(unresolved)),
350 sampling,
351 }
352}
353
354fn values(
357 acc: &Acc,
358 distribution: &[(Value, f64)],
359 complete: bool,
360 unresolved: Weight,
361 support: Option<Support>,
362) -> Vec<(Value, Quantity)> {
363 let u = unresolved + acc.missing;
364 let resolved = Weight::sum(acc.values.values().copied()) + acc.facts;
365 distribution
366 .iter()
367 .map(|(value, p)| {
368 let bounds = (!complete && support.is_none()).then(|| {
369 let w = match value {
371 Value::Bool(true) => acc.yes,
372 Value::Bool(false) => acc.facts.saturating_sub(acc.yes),
373 _ => acc.values.get(value).copied().unwrap_or_default(),
374 };
375 let total = resolved + u;
376 (w.ratio(total), (w + u).ratio(total).min(1.0))
377 });
378 let sampling = support.map(|support| {
379 let se = acc.value_se(value, *p);
380 let (status, se) = if se == 0.0 {
381 (Status::NotEstimable, None)
382 } else {
383 (Status::Estimated, Some(se))
384 };
385 Uncertainty {
386 status,
387 se,
388 wilson: None,
389 support,
390 }
391 });
392 let quantity = Quantity {
393 point: Some(*p),
394 complete,
395 bounds,
396 sampling,
397 };
398 (value.clone(), quantity)
399 })
400 .collect()
401}
402
403fn numeric(
406 acc: &Acc,
407 distribution: &[(Value, f64)],
408 complete: bool,
409 support: Option<Support>,
410 integrate: Option<f64>,
411) -> Option<Numeric> {
412 let summary = |mean: f64, sd: f64, mean_sampling: Option<Uncertainty>, percent: bool, shape: Shape, integrated| {
413 let quantity = |x: f64, sampling| Quantity {
415 point: (!x.is_nan()).then_some(x),
416 complete,
417 bounds: None,
418 sampling,
419 };
420 Some(Numeric {
421 mean: quantity(mean, mean_sampling),
422 sd: quantity(sd, support.map(not_computed)),
423 percent,
424 integrated,
425 shape,
426 complete,
427 support,
428 })
429 };
430 if let Some(m) = super::analytic_mixture(distribution) {
431 let (mean, sd, integrated) = mixture_moments(&m, integrate);
432 return summary(
433 mean,
434 sd,
435 support.map(not_computed),
436 false,
437 Shape::Mixture(m),
438 integrated,
439 );
440 }
441 let nums: Vec<(f64, f64)> = distribution
442 .iter()
443 .map(|(v, p)| match v {
444 Value::Int(n) => n
445 .to_f64()
446 .filter(|x| n.cmp_f64(*x).is_some_and(|c| c.is_eq()))
447 .map(|x| (x, *p)),
448 Value::Float(x) | Value::Prob(x) => Some((*x, *p)),
449 _ => None,
450 })
451 .collect::<Option<_>>()?;
452 let percent = distribution.iter().all(|(v, _)| matches!(v, Value::Prob(_)));
453 let total: f64 = nums.iter().map(|(_, p)| p).sum();
454 let mean = nums.iter().map(|(x, p)| x * p).sum::<f64>() / total;
455 let sd = (nums.iter().map(|(x, p)| (x - mean).powi(2) * p).sum::<f64>() / total).sqrt();
456 if !mean.is_finite() || !sd.is_finite() {
457 return None;
458 }
459 let mean_sampling = support.map(|support| {
460 let se = acc.mean_se().1;
461 let (status, se) = if se > 0.0 {
462 (Status::Estimated, Some(se))
463 } else {
464 (Status::NotEstimable, None)
465 };
466 Uncertainty {
467 status,
468 se,
469 wilson: None,
470 support,
471 }
472 });
473 summary(
474 mean,
475 sd,
476 mean_sampling,
477 percent,
478 Shape::Points(distribution.to_vec()),
479 false,
480 )
481}
482
483fn mixture_moments(m: &Mixture, integrate: Option<f64>) -> (f64, f64, bool) {
487 use crate::continuous::Part;
488 let (mean, sd) = (m.mean(), m.sd());
489 let Some(tolerance) = integrate.filter(|_| mean.is_nan()) else {
490 return (mean, sd, false);
491 };
492 let mut parts = Vec::with_capacity(m.parts.len());
493 for (part, p) in &m.parts {
494 let moments = match part {
495 Part::Analytic(a) => a.moments().or_else(|| a.integrated_moments(tolerance)),
496 Part::Continuous(f) => Some((f.mean(), f.variance())),
497 Part::Point(x) => Some((*x, 0.0)),
498 };
499 match moments {
500 Some(mv) => parts.push((mv, *p)),
501 None => return (f64::NAN, f64::NAN, false),
502 }
503 }
504 let total: f64 = parts.iter().map(|(_, p)| p).sum();
505 let mean = parts.iter().map(|((m, _), p)| m * p).sum::<f64>() / total;
506 let variance = parts
507 .iter()
508 .map(|((m, v), p)| p * (v + (m - mean).powi(2)))
509 .sum::<f64>()
510 / total;
511 (mean, variance.sqrt(), true)
512}