main
Caleb Publish 0.5.0 1984b40
//! Two overall figures instead of one that is always 0.
//!
//! Every category's score is still the lowest of its ceilings, and every ceiling is still computed from measurements and
//! independent audits (`confidence.rs`). What changes is the grouping. Each ceiling is one of two kinds:
//!
//! - **photo**: photos can earn it. Part identity and placement, visible copper, vias, the joins a crop can show, the values
//!   of marked parts, and how the drawn layers agree with the photos.
//! - **bench**: it needs the real board or an outside document. Hidden connectivity, the layer count, the values of unmarked
//!   parts, and the net of copper that reaches no pin on the outer faces.
//!
//! The **photo score** is the lowest photo ceiling of the run, the **bench score** the lowest bench ceiling. The review loop's
//! target applies to the photo score. The bench score is reported beside it with what would raise it. No formula got easier:
//! the lower of the two is exactly the lowest category, which is what the single overall figure was. docs/method.md, "Two
//! scores", gives the membership and how the two mixed categories were taken apart.
use crate::confidence::{CatScore, Ceil, Scores, DEFAULT_TARGET};
use serde_json::Value;

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind { Photo, Bench }

impl Kind { pub fn word(&self) -> &'static str { match self { Kind::Photo => "photo", Kind::Bench => "bench" } } }

pub const PHOTO_RULE: &str = "the lowest ceiling photos can earn: identity, values of marked parts, blocks, placement, copper, vias, the joins a crop can show, pins agreeing with their copper, the drawn layers against the photos";
pub const BENCH_RULE: &str = "the lowest ceiling that needs the real board or an outside document: values of unmarked parts, pins and connections no photo shows joined, the net of copper that reaches no pin, the layer count";

// Ceiling names. `confidence.rs` builds its ceilings with these, so a name cannot drift from its kind.
pub const AUDIT: &str = "independent audit";
pub const AUDIT_MARKED: &str = "independent audit, marked parts";
pub const AUDIT_UNMARKED: &str = "independent audit, unmarked parts";
pub const AUDIT_JOINS: &str = "independent audit, joins a crop shows";
pub const PINS_PROVEN: &str = "pins shown joined, by a crop or a bench probe";
pub const VALUES_MARKED: &str = "values tagged as seen, marked parts";
pub const VALUES_UNMARKED: &str = "values tagged as seen, unmarked parts";
pub const NET_AREA: &str = "copper with a net, by area";
pub const NET_LENGTH: &str = "copper with a net, by length";
pub const CONNECTIONS: &str = "connections the photos show or outside evidence proves";
pub const KICAD_CLOSED: &str = "connections KiCad finds closed";

/// The ceilings that need the real board or an outside document, by category. Every ceiling of `layers` is one. Anything not
/// listed is a photo ceiling, so a ceiling added later counts towards the loop's target until someone decides otherwise.
const BENCH: [(&str, &str); 6] = [
    ("values", AUDIT_UNMARKED), ("values", VALUES_UNMARKED),
    ("connectivity", PINS_PROVEN), ("connectivity", CONNECTIONS), ("connectivity", NET_AREA), ("connectivity", NET_LENGTH),
];

pub fn kind_of(cat: &str, ceiling: &str) -> Kind {
    if cat == "layers" || (cat == "connectivity" && ceiling == KICAD_CLOSED) || BENCH.contains(&(cat, ceiling)) { Kind::Bench } else { Kind::Photo }
}

/// Which stratum a part's value belongs to: `marked` when the part carries a marking that was read, `unmarked` when it does not.
/// A photo can show a marking and nothing else of a value.
pub fn value_stratum(part: &Value) -> &'static str { if part["marking"].as_str().unwrap_or("").trim().is_empty() { "unmarked" } else { "marked" } }

/// One category's figure of one kind: the lowest ceiling of that kind, with what limits it and what would raise it.
#[derive(Clone, Debug)]
pub struct Part { pub score: f64, pub limit: String, pub raise: String, pub by_audit: bool }

fn floor1(x: f64) -> f64 { (x.max(0.0) * 10.0 + 1e-9).floor() / 10.0 }

pub fn part(cat: &str, ceilings: &[Ceil], kind: Kind) -> Option<Part> {
    let low = ceilings.iter().filter(|c| kind_of(cat, &c.name) == kind).fold(None::<&Ceil>, |b, c| match b { Some(x) if x.value <= c.value => Some(x), _ => Some(c) })?;
    Some(Part { score: floor1(low.value), limit: format!("{}: {}", low.name, low.inputs), raise: low.raise.clone(), by_audit: low.name.starts_with(AUDIT) })
}

/// The overall figure of one kind: the lowest of the categories' figures of that kind. With no ceiling of the kind at all, nothing was earned.
pub fn total(cats: &[CatScore], kind: Kind) -> f64 {
    let v: Vec<f64> = cats.iter().filter_map(|c| match kind { Kind::Photo => c.photo.as_ref(), Kind::Bench => c.bench.as_ref() }).map(|p| p.score).collect();
    if v.is_empty() { 0.0 } else { v.into_iter().fold(100.0, f64::min) }
}

pub fn show(p: &Option<Part>) -> String { p.as_ref().map(|p| format!("{:.1}", p.score)).unwrap_or_else(|| "none".into()) }

/// The line report.md must carry word for word: both figures, and which one the target applies to.
pub fn line(s: &Scores, verdict: &str) -> String {
    let t = if s.target == DEFAULT_TARGET { format!("target {}", s.target) } else { format!("target {} (lowered or raised from the default {DEFAULT_TARGET})", s.target) };
    format!("Confidence: photo score {:.1} of 100, {t}: {verdict}; bench score {:.1} of 100, outside the target because it needs the real board or outside documents", s.photo, s.bench)
}

/// Both tables: the photo score with the target, then the bench score with what would raise it.
pub fn tables(s: &Scores) -> String {
    let mut t = String::from("Photo score: what photos can show. The review loop's target applies to it.\n\n| Category | Photo figure | Target | Limited by | Most likely to raise it |\n|---|---|---|---|---|\n");
    for c in &s.cats { if let Some(p) = &c.photo { t += &format!("| {} | {:.1} | {} | {} | {} |\n", c.category, p.score, s.target, p.limit, p.raise); } }
    t += &format!("| **photo score (the lowest)** | **{:.1}** | {} | | |\n", s.photo, s.target);
    t += "\nBench score: what needs the real board or outside documents. Reported beside the photo score; the loop does not wait for it.\n\n| Category | Bench figure | Limited by | What would raise it |\n|---|---|---|---|\n";
    for c in &s.cats { if let Some(b) = &c.bench { t += &format!("| {} | {:.1} | {} | {} |\n", c.category, b.score, b.limit, b.raise); } }
    t += &format!("| **bench score (the lowest)** | **{:.1}** | | |\n", s.bench);
    t
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ceil(name: &str, value: f64) -> Ceil { Ceil { name: name.into(), value, inputs: "x".into(), raise: "y".into() } }

    #[test]
    fn every_layers_ceiling_is_bench_and_an_unlisted_ceiling_counts_towards_the_target() {
        assert_eq!(kind_of("layers", AUDIT), Kind::Bench);
        assert_eq!(kind_of("layers", "anything"), Kind::Bench);
        assert_eq!(kind_of("copper", AUDIT), Kind::Photo);
        assert_eq!(kind_of("copper", "a ceiling added later"), Kind::Photo);
        assert_eq!(kind_of("connectivity", AUDIT_JOINS), Kind::Photo);
        assert_eq!(kind_of("connectivity", "pins that agree with their copper"), Kind::Photo);
        for n in [PINS_PROVEN, CONNECTIONS, NET_AREA, NET_LENGTH, KICAD_CLOSED] { assert_eq!(kind_of("connectivity", n), Kind::Bench, "{n}"); }
        assert_eq!(kind_of("values", AUDIT_MARKED), Kind::Photo);
        assert_eq!(kind_of("values", VALUES_MARKED), Kind::Photo);
        assert_eq!(kind_of("values", AUDIT_UNMARKED), Kind::Bench);
        assert_eq!(kind_of("values", VALUES_UNMARKED), Kind::Bench);
        // a bench name under another category is not bench there
        assert_eq!(kind_of("copper", CONNECTIONS), Kind::Photo);
    }

    #[test]
    fn a_part_is_the_lowest_ceiling_of_its_kind_and_the_lower_part_is_the_category() {
        let c = vec![ceil(AUDIT_JOINS, 46.2), ceil(PINS_PROVEN, 18.64), ceil(CONNECTIONS, 59.3), ceil("pins that agree with their copper", 100.0)];
        let (p, b) = (part("connectivity", &c, Kind::Photo).unwrap(), part("connectivity", &c, Kind::Bench).unwrap());
        assert_eq!(p.score, 46.2);
        assert!(p.by_audit);
        assert_eq!(b.score, 18.6);
        assert!(!b.by_audit);
        let lowest = c.iter().map(|x| x.value).fold(100.0, f64::min);
        assert_eq!(p.score.min(b.score), floor1(lowest));
        assert!(part("copper", &[ceil(AUDIT, 50.0)], Kind::Bench).is_none());
        assert!(part("layers", &[ceil(AUDIT, 30.0)], Kind::Photo).is_none());
    }

    #[test]
    fn a_value_is_marked_only_when_a_marking_was_read() {
        assert_eq!(value_stratum(&serde_json::json!({"marking": "103"})), "marked");
        assert_eq!(value_stratum(&serde_json::json!({"marking": " "})), "unmarked");
        assert_eq!(value_stratum(&serde_json::json!({"value": "100nF"})), "unmarked");
    }
}