use pounce_common::types::Number;
use serde_json::{Value, json};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Info,
Warning,
Error,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Basis {
Proof,
Structural,
Numerical,
Heuristic,
}
impl Basis {
pub fn as_str(self) -> &'static str {
match self {
Basis::Proof => "proof",
Basis::Structural => "structural",
Basis::Numerical => "numerical",
Basis::Heuristic => "heuristic",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confidence {
Low,
Medium,
High,
Certain,
}
impl Confidence {
pub fn as_str(self) -> &'static str {
match self {
Confidence::Low => "low",
Confidence::Medium => "medium",
Confidence::High => "high",
Confidence::Certain => "certain",
}
}
}
#[derive(Debug, Clone)]
pub struct Finding {
pub code: &'static str,
pub check: &'static str,
pub severity: Severity,
pub basis: Basis,
pub confidence: Confidence,
pub observation: String,
pub why_it_matters: String,
pub suggested_next_check: String,
pub variables: Vec<String>,
pub constraints: Vec<String>,
pub evidence: Value,
}
impl Finding {
pub fn new(
check: &'static str,
code: &'static str,
severity: Severity,
basis: Basis,
confidence: Confidence,
observation: impl Into<String>,
) -> Self {
Finding {
code,
check,
severity,
basis,
confidence,
observation: observation.into(),
why_it_matters: String::new(),
suggested_next_check: String::new(),
variables: Vec::new(),
constraints: Vec::new(),
evidence: Value::Null,
}
}
pub fn why(mut self, s: impl Into<String>) -> Self {
self.why_it_matters = s.into();
self
}
pub fn next(mut self, s: impl Into<String>) -> Self {
self.suggested_next_check = s.into();
self
}
pub fn vars(mut self, v: Vec<String>) -> Self {
self.variables = v;
self
}
pub fn cons(mut self, c: Vec<String>) -> Self {
self.constraints = c;
self
}
pub fn evidence(mut self, e: Value) -> Self {
self.evidence = e;
self
}
fn to_json(&self) -> Value {
json!({
"code": self.code,
"check": self.check,
"severity": self.severity.as_str(),
"basis": self.basis.as_str(),
"confidence": self.confidence.as_str(),
"observation": self.observation,
"why_it_matters": self.why_it_matters,
"suggested_next_check": self.suggested_next_check,
"affected": {
"variables": self.variables,
"constraints": self.constraints,
},
"evidence": self.evidence,
})
}
}
#[derive(Debug, Clone)]
pub enum CheckStatus {
Ran,
Unavailable(String),
}
#[derive(Debug, Clone)]
pub struct CheckRecord {
pub name: &'static str,
pub status: CheckStatus,
}
pub struct Sink<'a> {
pub check: &'static str,
report: &'a mut Report,
}
impl Sink<'_> {
pub fn push(&mut self, f: Finding) {
debug_assert_eq!(f.check, self.check, "finding filed under the wrong check");
let kept = self.report.kept_per_code.entry(f.code).or_insert(0);
if *kept < self.report.max_per_code {
*kept += 1;
self.report.findings.push(f);
} else {
*self.report.truncated.entry(f.code).or_insert(0) += 1;
*self.report.dropped.entry(f.severity).or_insert(0) += 1;
}
}
}
#[derive(Debug, Clone)]
pub struct Report {
pub source: String,
pub sha256: Option<String>,
pub n_vars: usize,
pub n_cons: usize,
pub point: String,
pub checks: Vec<CheckRecord>,
pub findings: Vec<Finding>,
pub truncated: BTreeMap<&'static str, usize>,
dropped: BTreeMap<Severity, usize>,
kept_per_code: BTreeMap<&'static str, usize>,
pub max_per_code: usize,
pub tolerances: BTreeMap<&'static str, Number>,
pub sections: BTreeMap<&'static str, Value>,
}
impl Report {
pub fn new(source: String, n_vars: usize, n_cons: usize, max_per_code: usize) -> Self {
Report {
source,
sha256: None,
n_vars,
n_cons,
point: String::new(),
checks: Vec::new(),
findings: Vec::new(),
truncated: BTreeMap::new(),
dropped: BTreeMap::new(),
kept_per_code: BTreeMap::new(),
max_per_code: max_per_code.max(1),
tolerances: BTreeMap::new(),
sections: BTreeMap::new(),
}
}
pub fn ran(&mut self, check: &'static str) -> Sink<'_> {
self.checks.push(CheckRecord {
name: check,
status: CheckStatus::Ran,
});
Sink {
check,
report: self,
}
}
pub fn unavailable(&mut self, check: &'static str, reason: impl Into<String>) {
self.checks.push(CheckRecord {
name: check,
status: CheckStatus::Unavailable(reason.into()),
});
}
pub fn count(&self, sev: Severity) -> usize {
self.findings.iter().filter(|f| f.severity == sev).count()
+ self.dropped.get(&sev).copied().unwrap_or(0)
}
pub fn has_errors(&self) -> bool {
self.count(Severity::Error) > 0
}
pub fn verdict(&self) -> &'static str {
if self.has_errors() {
"ERRORS"
} else if self.count(Severity::Warning) > 0 {
"WARNINGS"
} else {
"CLEAN"
}
}
pub fn sorted(&self) -> Vec<&Finding> {
let mut v: Vec<&Finding> = self.findings.iter().collect();
v.sort_by_key(|f| std::cmp::Reverse(f.severity));
v
}
pub fn to_json(&self) -> Value {
let checks: Vec<Value> = self
.checks
.iter()
.map(|c| match &c.status {
CheckStatus::Ran => json!({"check": c.name, "status": "ran"}),
CheckStatus::Unavailable(r) => {
json!({"check": c.name, "status": "unavailable", "reason": r})
}
})
.collect();
let findings: Vec<Value> = self.sorted().iter().map(|f| f.to_json()).collect();
let mut sections = serde_json::Map::new();
for (k, v) in &self.sections {
sections.insert((*k).to_string(), v.clone());
}
json!({
"schema": "pounce.diagnose/v1",
"solver": format!("pounce {}", env!("CARGO_PKG_VERSION")),
"problem": {
"source": self.source,
"sha256": self.sha256,
"n_vars": self.n_vars,
"n_cons": self.n_cons,
},
"point": self.point,
"tolerances": self.tolerances,
"verdict": self.verdict(),
"summary": {
"errors": self.count(Severity::Error),
"warnings": self.count(Severity::Warning),
"info": self.count(Severity::Info),
},
"checks": checks,
"findings": findings,
"truncated": self.truncated,
"sections": Value::Object(sections),
})
}
pub fn to_text(&self) -> String {
use std::fmt::Write;
const INFO_SHOWN_PER_CODE: usize = 3;
let mut out = String::new();
let _ = writeln!(out, "pounce diagnose: {}", self.source);
let _ = writeln!(
out,
" {} variables, {} constraints; numerical checks at {}",
self.n_vars, self.n_cons, self.point
);
let _ = writeln!(
out,
" verdict: {} ({} error(s), {} warning(s), {} info)",
self.verdict(),
self.count(Severity::Error),
self.count(Severity::Warning),
self.count(Severity::Info)
);
let mut info_shown: BTreeMap<&str, usize> = BTreeMap::new();
let mut info_hidden: BTreeMap<&str, usize> = BTreeMap::new();
for f in self.sorted() {
if f.severity == Severity::Info {
let shown = info_shown.entry(f.code).or_insert(0);
if *shown >= INFO_SHOWN_PER_CODE {
*info_hidden.entry(f.code).or_insert(0) += 1;
continue;
}
*shown += 1;
}
let _ = writeln!(out);
let _ = writeln!(
out,
" [{}] {} ({}, {} confidence)",
f.severity.as_str().to_uppercase(),
f.code,
f.basis.as_str(),
f.confidence.as_str()
);
let _ = writeln!(out, " {}", f.observation);
if !f.why_it_matters.is_empty() {
let _ = writeln!(out, " why: {}", f.why_it_matters);
}
if !f.suggested_next_check.is_empty() {
let _ = writeln!(out, " next: {}", f.suggested_next_check);
}
}
if !info_hidden.is_empty() || !self.truncated.is_empty() {
let _ = writeln!(out);
for (code, n) in &info_hidden {
let _ = writeln!(out, " ... {n} more info `{code}` finding(s) (see --json)");
}
for (code, n) in &self.truncated {
let _ = writeln!(
out,
" ... {n} more `{code}` finding(s) dropped by --max-per-code {}",
self.max_per_code
);
}
}
let unavailable: Vec<&CheckRecord> = self
.checks
.iter()
.filter(|c| matches!(c.status, CheckStatus::Unavailable(_)))
.collect();
if !unavailable.is_empty() {
let _ = writeln!(out);
let _ = writeln!(out, " checks that did not run:");
for c in unavailable {
if let CheckStatus::Unavailable(r) = &c.status {
let _ = writeln!(out, " {}: {r}", c.name);
}
}
}
out
}
}
pub fn num(v: Number) -> Value {
if v.is_finite() {
json!(v)
} else {
json!(v.to_string())
}
}
pub fn fmt_num(v: Number) -> String {
if !v.is_finite() {
return v.to_string();
}
if v == 0.0 {
return "0".to_string();
}
let a = v.abs();
if (1e-4..1e6).contains(&a) {
let s = format!("{v:.6}");
let s = s.trim_end_matches('0').trim_end_matches('.');
s.to_string()
} else {
format!("{v:.3e}")
}
}
pub fn interval(lo: Number, hi: Number) -> Value {
json!([num(lo), num(hi)])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_error_dropped_by_the_cap_still_sets_the_verdict() {
let mut r = Report::new("t".into(), 0, 0, 1);
let mut sink = r.ran("c");
let f = |sev| Finding::new("c", "mixed", sev, Basis::Numerical, Confidence::High, "x");
sink.push(f(Severity::Warning));
sink.push(f(Severity::Error)); assert!(r.findings.iter().all(|f| f.severity == Severity::Warning));
assert!(r.has_errors());
assert_eq!(r.verdict(), "ERRORS");
assert_eq!(r.count(Severity::Error), 1);
assert_eq!(r.count(Severity::Warning), 1);
}
}