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        let results = Results::load(&dir.join("results.json"));
36        Ok(RunDir {
37            verdicts,
38            plan,
39            results,
40        })
41    }
42
43    pub fn path_of(dir: &str) -> PathBuf {
44        PathBuf::from(dir)
45    }
46}
47
48fn rules_of(candidate: &Value) -> Vec<RuleRef> {
49    let mut rules = Vec::new();
50    for key in ["records", "caveats"] {
51        if let Some(list) = candidate.get(key).and_then(Value::as_array) {
52            for r in list {
53                rules.push(RuleRef {
54                    rule: r["rule"].as_str().unwrap_or("?").to_string(),
55                    span: r["span"].as_str().map(String::from),
56                    reason: r
57                        .get("reason")
58                        .or_else(|| r.get("message"))
59                        .and_then(Value::as_str)
60                        .unwrap_or("")
61                        .to_string(),
62                });
63            }
64        }
65    }
66    rules
67}
68
69pub fn build_report(run: &RunDir) -> Result<Report, ReportError> {
70    let kernel = run.verdicts["kernel"].as_str().unwrap_or("?").to_string();
71    let gate_cc = run.verdicts["cc"].as_str().unwrap_or("?").to_string();
72    let convergence_gate = run.verdicts["gate"].as_str().unwrap_or("full").to_string();
73    let empty = Vec::new();
74    let verdict_candidates = run.verdicts["candidates"].as_array().unwrap_or(&empty);
75
76    let mut candidates = Vec::new();
77    let mut admitted = 0usize;
78    let mut refused = 0usize;
79    let mut measured_ok = 0usize;
80    let mut gpu_seconds = 0.0f64;
81
82    for vc in verdict_candidates {
83        let id = vc["id"].as_str().unwrap_or("?").to_string();
84        let verdict = vc["verdict"].as_str().unwrap_or("?").to_string();
85        match verdict.as_str() {
86            "clean" | "admitted_with_caveats" | "ungated" => admitted += 1,
87            "disqualified" => refused += 1,
88            _ => {}
89        }
90        let measurement = run
91            .results
92            .as_ref()
93            .and_then(|r| r.candidates.iter().find(|c| c.id == id));
94        let (status, summary, error, secs) = match measurement {
95            Some(m) => {
96                if m.status == "ok" {
97                    measured_ok += 1;
98                }
99                gpu_seconds += m.gpu_seconds;
100                (
101                    m.status.clone(),
102                    m.summary.clone(),
103                    m.error.clone(),
104                    m.gpu_seconds,
105                )
106            }
107            None => ("unmeasured".to_string(), None, None, 0.0),
108        };
109        candidates.push(CandidateReport {
110            id,
111            config: vc["config"].as_str().unwrap_or("?").to_string(),
112            verdict,
113            rules: rules_of(vc),
114            measurement_status: status,
115            summary,
116            measurement_error: error,
117            gpu_seconds: secs,
118        });
119    }
120
121    // The chosen configuration: best median among measured, admitted, ok.
122    let chosen = candidates
123        .iter()
124        .filter(|c| {
125            matches!(
126                c.verdict.as_str(),
127                "clean" | "admitted_with_caveats" | "ungated"
128            ) && c.measurement_status == "ok"
129        })
130        .filter_map(|c| c.summary.as_ref().map(|s| (c, s.median_ms)))
131        .min_by(|a, b| a.1.partial_cmp(&b.1).expect("no NaN medians"))
132        .map(|(c, _)| ChosenInfo {
133            id: c.id.clone(),
134            config: c.config.clone(),
135            summary: c.summary.clone().expect("chosen is measured"),
136        });
137
138    let mut indistinguishable_from_chosen = Vec::new();
139    let mut rejected_faster = Vec::new();
140    if let Some(chosen) = &chosen {
141        for c in &candidates {
142            let Some(summary) = &c.summary else { continue };
143            if c.id == chosen.id || c.measurement_status != "ok" {
144                continue;
145            }
146            let is_admitted = matches!(
147                c.verdict.as_str(),
148                "clean" | "admitted_with_caveats" | "ungated"
149            );
150            if is_admitted && indistinguishable(summary, &chosen.summary) {
151                indistinguishable_from_chosen.push(c.id.clone());
152            }
153            // Measurably faster: the whole interval sits below the chosen's.
154            if c.verdict == "disqualified" && summary.ci95_hi_ms < chosen.summary.ci95_lo_ms {
155                rejected_faster.push(RejectedFaster {
156                    id: c.id.clone(),
157                    config: c.config.clone(),
158                    summary: summary.clone(),
159                    speedup_vs_chosen: chosen.summary.median_ms / summary.median_ms,
160                    rules: c.rules.clone(),
161                });
162            }
163        }
164        rejected_faster.sort_by(|a, b| {
165            b.speedup_vs_chosen
166                .partial_cmp(&a.speedup_vs_chosen)
167                .expect("no NaN speedups")
168        });
169    }
170
171    let total = candidates.len();
172    Ok(Report {
173        schema: "report.v1".into(),
174        kernel,
175        gate_cc,
176        // A report with no measurements must say so — labelling it
177        // `measured` would be the exact dishonesty §1.9 forbids.
178        measurement_kind: if run.results.is_some() {
179            "measured".into()
180        } else {
181            "unmeasured".into()
182        },
183        convergence_gate,
184        device: run.results.as_ref().map(|r| DeviceInfo {
185            name: r.device_name.clone(),
186            cc: r.device_cc.clone(),
187            driver_version: r.driver_version.clone(),
188        }),
189        allow_unsafe_reason: run
190            .plan
191            .as_ref()
192            .and_then(|p| p.allow_unsafe_reason.clone()),
193        chosen,
194        indistinguishable_from_chosen,
195        rejected_faster,
196        candidates,
197        totals: Totals {
198            candidates: total,
199            admitted,
200            refused,
201            measured_ok,
202            gpu_seconds,
203        },
204    })
205}