Skip to main content

scx_forge_agent/
report.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: GPL-2.0-only
3//! Optimization run report: per-round history table, markdown and JSON renderers.
4
5use serde_json::{json, Value};
6
7use crate::usage::Usage;
8
9#[derive(Debug, Clone)]
10pub struct RoundRecord {
11    pub round: u32,
12    /// One-line change summary from the model.
13    pub summary: String,
14    /// Terminal outcome: "kept", "reverted", "build-failed", "attach-failed",
15    /// "metric-failed", "no-edit", "duplicate", "ineffective", "api-error",
16    /// or "interrupted".
17    pub outcome: String,
18    pub value: Option<f64>,
19    pub delta: Option<f64>,
20    /// Direction-normalized improvement: positive is better for both minimize
21    /// and maximize objectives.
22    pub improvement: Option<f64>,
23    pub policy_area: String,
24    pub direction: String,
25    pub kept: bool,
26}
27
28#[derive(Debug, Clone, Default)]
29pub struct Report {
30    pub objective: String,
31    pub goal: String,
32    pub metric_name: String,
33    pub start_value: Option<f64>,
34    pub best_value: Option<f64>,
35    pub best_round: Option<u32>,
36    pub rounds: Vec<RoundRecord>,
37    /// Token usage summed across every model call the run made.
38    pub usage: Usage,
39}
40
41fn fmt_opt(v: Option<f64>) -> String {
42    match v {
43        Some(x) => format!("{x:.3}"),
44        None => "-".to_string(),
45    }
46}
47
48/// Left-pad `s` to `width` display columns (right-aligned).
49fn pad_left(s: &str, width: usize) -> String {
50    let n = s.chars().count();
51    if n >= width {
52        s.to_string()
53    } else {
54        format!("{}{s}", " ".repeat(width - n))
55    }
56}
57
58/// Right-pad `s` to `width` display columns (left-aligned).
59fn pad_right(s: &str, width: usize) -> String {
60    let n = s.chars().count();
61    if n >= width {
62        s.to_string()
63    } else {
64        format!("{s}{}", " ".repeat(width - n))
65    }
66}
67
68impl Report {
69    pub fn render_markdown(&self) -> String {
70        let mut s = String::new();
71        s.push_str("# scx_forge_agent optimization report\n\n");
72        s.push_str(&format!(
73            "- Objective: **{} {}**\n",
74            self.goal, self.metric_name
75        ));
76        s.push_str(&format!(
77            "- Starting scheduler value: {}\n",
78            fmt_opt(self.start_value)
79        ));
80        s.push_str(&format!(
81            "- Best scheduler value: {}{}\n",
82            fmt_opt(self.best_value),
83            match self.best_round {
84                Some(r) => format!(" (round {r})"),
85                None => String::new(),
86            }
87        ));
88        if let (Some(start), Some(best)) = (self.start_value, self.best_value) {
89            if start != 0.0 {
90                let pct = if self.goal == "minimize" {
91                    (start - best) / start * 100.0
92                } else {
93                    (best - start) / start * 100.0
94                };
95                s.push_str(&format!("- Improvement over start: {pct:.2}%\n"));
96            }
97        }
98        s.push('\n');
99
100        // Aligned table: pad the short columns to their content width (numeric
101        // ones right-aligned) so it lines up in a terminal; the free-form Change
102        // column is left unpadded as the last column. Still valid markdown.
103        let cells: Vec<[String; 5]> = self
104            .rounds
105            .iter()
106            .map(|r| {
107                [
108                    r.round.to_string(),
109                    r.outcome.clone(),
110                    fmt_opt(r.value),
111                    fmt_opt(r.delta),
112                    r.summary.replace('|', "\\|").replace('\n', " "),
113                ]
114            })
115            .collect();
116
117        let width = |col: usize, header: &str| -> usize {
118            cells
119                .iter()
120                .map(|c| c[col].chars().count())
121                .chain(std::iter::once(header.chars().count()))
122                .max()
123                .unwrap_or(0)
124        };
125        let (wr, wo, wv, wd) = (
126            width(0, "Round"),
127            width(1, "Outcome"),
128            width(2, "Value"),
129            width(3, "Δ"),
130        );
131
132        // header + alignment separator (`---:` = right-aligned in markdown).
133        s.push_str(&format!(
134            "| {} | {} | {} | {} | Change |\n",
135            pad_left("Round", wr),
136            pad_right("Outcome", wo),
137            pad_left("Value", wv),
138            pad_left("Δ", wd),
139        ));
140        s.push_str(&format!(
141            "|{}:|{}|{}:|{}:|--------|\n",
142            "-".repeat(wr + 1),
143            "-".repeat(wo + 2),
144            "-".repeat(wv + 1),
145            "-".repeat(wd + 1),
146        ));
147        for c in &cells {
148            s.push_str(&format!(
149                "| {} | {} | {} | {} | {} |\n",
150                pad_left(&c[0], wr),
151                pad_right(&c[1], wo),
152                pad_left(&c[2], wv),
153                pad_left(&c[3], wd),
154                c[4],
155            ));
156        }
157        s
158    }
159
160    pub fn to_json(&self) -> Value {
161        json!({
162            "objective": self.objective,
163            "goal": self.goal,
164            "metric_name": self.metric_name,
165            "start_value": self.start_value,
166            "best_value": self.best_value,
167            "best_round": self.best_round,
168            "rounds": self.rounds.iter().map(|r| json!({
169                "round": r.round,
170                "outcome": r.outcome,
171                "value": r.value,
172                "delta": r.delta,
173                "improvement": r.improvement,
174                "policy_area": r.policy_area,
175                "direction": r.direction,
176                "kept": r.kept,
177                "summary": r.summary,
178            })).collect::<Vec<_>>(),
179            "usage": self.usage.to_json(),
180        })
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn rec(
189        round: u32,
190        outcome: &str,
191        value: Option<f64>,
192        delta: Option<f64>,
193        s: &str,
194    ) -> RoundRecord {
195        RoundRecord {
196            round,
197            summary: s.to_string(),
198            outcome: outcome.to_string(),
199            value,
200            delta,
201            improvement: delta.map(|d| -d),
202            policy_area: "slice".into(),
203            direction: "test_direction".into(),
204            kept: outcome == "kept",
205        }
206    }
207
208    #[test]
209    fn table_columns_are_aligned() {
210        let rep = Report {
211            objective: "minimize p99".into(),
212            goal: "minimize".into(),
213            metric_name: "p99_wakeup_latency_us".into(),
214            start_value: Some(5224.0),
215            best_value: Some(5208.0),
216            best_round: Some(3),
217            rounds: vec![
218                rec(1, "no-edit", None, None, "<tool_call>"),
219                rec(
220                    2,
221                    "reverted",
222                    Some(5224.0),
223                    Some(0.0),
224                    "Reduced SLEEP_VLAG_LIMIT_NS from 20ms to 5ms.",
225                ),
226                rec(
227                    3,
228                    "kept",
229                    Some(5208.0),
230                    Some(-16.0),
231                    "Reduced SLEEP_VLAG_LIMIT_NS from 20ms to 10ms.",
232                ),
233            ],
234            usage: Usage::default(),
235        };
236        let out = rep.render_markdown();
237        let table: Vec<&str> = out.lines().filter(|l| l.starts_with('|')).collect();
238        // header + separator + 3 rows
239        assert_eq!(table.len(), 5);
240        // Column boundaries ('|') must line up across rows by DISPLAY column (char
241        // position, not byte offset - the Δ header is multibyte).
242        let pipes = |l: &str| -> Vec<usize> {
243            l.chars()
244                .enumerate()
245                .filter(|(_, c)| *c == '|')
246                .map(|(i, _)| i)
247                .collect()
248        };
249        let header_pipes = pipes(table[0]);
250        // first 5 pipe positions (the 4 padded columns) must match on every row.
251        for line in &table {
252            let p = pipes(line);
253            assert!(p.len() >= 5, "row has too few columns: {line}");
254            assert_eq!(&p[..5], &header_pipes[..5], "misaligned row: {line}");
255        }
256    }
257}