launchbound_report/
build.rs1use 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 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 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 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}