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")).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 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 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 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}