Skip to main content

launchbound_report/
build.rs

1//! Build a report.v1 from a run directory: verdicts.json (the gate record)
2//! + plan.json + results.json (measurement, if the box has run).
3
4use crate::{
5    CandidateReport, ChosenInfo, DeviceInfo, RejectedFaster, Report, ReportError, RuleRef, Totals,
6};
7use launchbound_bench::{BenchPlan, Results, indistinguishable};
8use serde_json::Value;
9use std::path::{Path, PathBuf};
10
11pub struct RunDir {
12    pub verdicts: Value,
13    pub plan: Option<BenchPlan>,
14    pub results: Option<Results>,
15}
16
17impl RunDir {
18    pub fn load(dir: &Path) -> Result<Self, ReportError> {
19        let verdicts_path = dir.join("verdicts.json");
20        let text = std::fs::read_to_string(&verdicts_path).map_err(|e| {
21            ReportError::RunDir(format!(
22                "{}: {e} (stage writes it)",
23                verdicts_path.display()
24            ))
25        })?;
26        let verdicts: Value =
27            serde_json::from_str(&text).map_err(|e| ReportError::RunDir(e.to_string()))?;
28        if verdicts["schema"] != "verdicts.v1" {
29            return Err(ReportError::RunDir(format!(
30                "unsupported verdicts schema {}",
31                verdicts["schema"]
32            )));
33        }
34        let plan = BenchPlan::load(&dir.join("plan.json")).ok();
35        // A results.json that exists but cannot be read is an error, not
36        // "the box has not run yet". The two call for opposite actions โ€”
37        // wait, or go and look โ€” and rendering both as `unmeasured` at
38        // exit 0 told them apart for nobody. Same treatment `verdicts.v1`
39        // gets fifteen lines above.
40        let results = Results::load(&dir.join("results.json")).map_err(ReportError::RunDir)?;
41        Ok(RunDir {
42            verdicts,
43            plan,
44            results,
45        })
46    }
47
48    pub fn path_of(dir: &str) -> PathBuf {
49        PathBuf::from(dir)
50    }
51}
52
53fn rules_of(candidate: &Value) -> Vec<RuleRef> {
54    let mut rules = Vec::new();
55    for key in ["records", "caveats"] {
56        if let Some(list) = candidate.get(key).and_then(Value::as_array) {
57            for r in list {
58                rules.push(RuleRef {
59                    rule: r["rule"].as_str().unwrap_or("?").to_string(),
60                    span: r["span"].as_str().map(String::from),
61                    reason: r
62                        .get("reason")
63                        .or_else(|| r.get("message"))
64                        .and_then(Value::as_str)
65                        .unwrap_or("")
66                        .to_string(),
67                });
68            }
69        }
70    }
71    rules
72}
73
74pub fn build_report(run: &RunDir) -> Result<Report, ReportError> {
75    let kernel = run.verdicts["kernel"].as_str().unwrap_or("?").to_string();
76    let gate_cc = run.verdicts["cc"].as_str().unwrap_or("?").to_string();
77    let convergence_gate = run.verdicts["gate"].as_str().unwrap_or("full").to_string();
78    let empty = Vec::new();
79    let verdict_candidates = run.verdicts["candidates"].as_array().unwrap_or(&empty);
80
81    let mut candidates = Vec::new();
82    let mut admitted = 0usize;
83    let mut refused = 0usize;
84    let mut measured_ok = 0usize;
85    let mut gpu_seconds = 0.0f64;
86
87    for vc in verdict_candidates {
88        let id = vc["id"].as_str().unwrap_or("?").to_string();
89        let verdict = vc["verdict"].as_str().unwrap_or("?").to_string();
90        match verdict.as_str() {
91            "clean" | "admitted_with_caveats" | "ungated" => admitted += 1,
92            "disqualified" => refused += 1,
93            _ => {}
94        }
95        let measurement = run
96            .results
97            .as_ref()
98            .and_then(|r| r.candidates.iter().find(|c| c.id == id));
99        let (status, summary, error, secs) = match measurement {
100            Some(m) => {
101                if m.status == "ok" {
102                    measured_ok += 1;
103                }
104                gpu_seconds += m.gpu_seconds;
105                (
106                    m.status.clone(),
107                    m.summary.clone(),
108                    m.error.clone(),
109                    m.gpu_seconds,
110                )
111            }
112            None => ("unmeasured".to_string(), None, None, 0.0),
113        };
114        candidates.push(CandidateReport {
115            id,
116            config: vc["config"].as_str().unwrap_or("?").to_string(),
117            verdict,
118            rules: rules_of(vc),
119            measurement_status: status,
120            summary,
121            measurement_error: error,
122            gpu_seconds: secs,
123        });
124    }
125
126    // The chosen configuration: best median among measured, admitted, ok.
127    let chosen = candidates
128        .iter()
129        .filter(|c| {
130            matches!(
131                c.verdict.as_str(),
132                "clean" | "admitted_with_caveats" | "ungated"
133            ) && c.measurement_status == "ok"
134        })
135        .filter_map(|c| c.summary.as_ref().map(|s| (c, s.median_ms)))
136        .min_by(|a, b| a.1.partial_cmp(&b.1).expect("no NaN medians"))
137        .map(|(c, _)| ChosenInfo {
138            id: c.id.clone(),
139            config: c.config.clone(),
140            summary: c.summary.clone().expect("chosen is measured"),
141        });
142
143    let mut indistinguishable_from_chosen = Vec::new();
144    let mut rejected_faster = Vec::new();
145    if let Some(chosen) = &chosen {
146        for c in &candidates {
147            let Some(summary) = &c.summary else { continue };
148            if c.id == chosen.id || c.measurement_status != "ok" {
149                continue;
150            }
151            let is_admitted = matches!(
152                c.verdict.as_str(),
153                "clean" | "admitted_with_caveats" | "ungated"
154            );
155            if is_admitted && indistinguishable(summary, &chosen.summary) {
156                indistinguishable_from_chosen.push(c.id.clone());
157            }
158            // Measurably faster: the whole interval sits below the chosen's.
159            if c.verdict == "disqualified" && summary.ci95_hi_ms < chosen.summary.ci95_lo_ms {
160                rejected_faster.push(RejectedFaster {
161                    id: c.id.clone(),
162                    config: c.config.clone(),
163                    summary: summary.clone(),
164                    speedup_vs_chosen: chosen.summary.median_ms / summary.median_ms,
165                    rules: c.rules.clone(),
166                });
167            }
168        }
169        rejected_faster.sort_by(|a, b| {
170            b.speedup_vs_chosen
171                .partial_cmp(&a.speedup_vs_chosen)
172                .expect("no NaN speedups")
173        });
174    }
175
176    let total = candidates.len();
177    Ok(Report {
178        schema: "report.v1".into(),
179        kernel,
180        gate_cc,
181        // A report with no measurements must say so โ€” labelling it
182        // `measured` would be the exact dishonesty ยง1.9 forbids.
183        measurement_kind: if run.results.is_some() {
184            "measured".into()
185        } else {
186            "unmeasured".into()
187        },
188        convergence_gate,
189        device: run.results.as_ref().map(|r| DeviceInfo {
190            name: r.device_name.clone(),
191            cc: r.device_cc.clone(),
192            driver_version: r.driver_version.clone(),
193        }),
194        allow_unsafe_reason: run
195            .plan
196            .as_ref()
197            .and_then(|p| p.allow_unsafe_reason.clone()),
198        chosen,
199        indistinguishable_from_chosen,
200        rejected_faster,
201        candidates,
202        totals: Totals {
203            candidates: total,
204            admitted,
205            refused,
206            measured_ok,
207            gpu_seconds,
208        },
209    })
210}