Skip to main content

scx_forge_agent/
spec.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: GPL-2.0-only
3//! Validation spec model (parsed from the TOML the agent points the harness at).
4//!
5//! Mirrors `tools/scx_forge_agent/spec.toml`: `[scheduler]` (what to build and run),
6//! `[system]` (host/runtime settings), `[ai]` (model selection), `[tracing]`
7//! (optional perf profiling, event list, and size cap), `[workload]`
8//! (the load to apply, the numeric metric to emit, and how many times to repeat
9//! the measurement), and `[goal]` (what the number means, which direction is
10//! better, and the accept threshold).
11//! Defaults match the previous Python harness's `.get(...)` fallbacks.
12
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16use anyhow::{Context, Result};
17use serde::Deserialize;
18
19pub const METRIC_NAME: &str = "score";
20
21#[derive(Debug, Clone, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Spec {
24    pub scheduler: Scheduler,
25    #[serde(default)]
26    pub system: System,
27    #[serde(default)]
28    pub ai: Ai,
29    #[serde(default)]
30    pub tracing: Tracing,
31    #[serde(default)]
32    pub workload: Workload,
33    #[serde(default)]
34    pub goal: Goal,
35}
36
37#[derive(Debug, Clone, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Scheduler {
40    /// Cargo package name of the scheduler crate to build and run.
41    pub package: String,
42    /// cargo profile: "release" -> target/release, "dev" -> target/debug,
43    /// named -> target/<profile>.
44    #[serde(default = "default_profile")]
45    pub profile: String,
46    /// Time in seconds to let the scheduler settle after it reports "enabled".
47    #[serde(default = "default_warmup_time")]
48    pub warmup_time: u64,
49    /// Interval (seconds) for the scheduler's own --stats output.
50    #[serde(default = "default_stats_interval")]
51    pub stats_interval: u64,
52}
53
54#[derive(Debug, Clone, Default, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct System {
57    /// Optional file containing the sudo password. Relative paths are resolved
58    /// relative to the spec file directory by the optimizer. Empty means unset.
59    #[serde(default)]
60    pub sudo_passwd_file: Option<PathBuf>,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct Ai {
66    /// Backend for the planner/reasoner role: an OpenAI-compatible API base URL
67    /// (built-in `openai` backend), or one of the keywords `claude`, `codex`,
68    /// `opencode`, `cursor-agent` (shell out to that agent CLI). Falls back to
69    /// `$SCX_FORGE_BACKEND`.
70    #[serde(default, alias = "base_url")]
71    pub backend: Option<String>,
72    /// Planner/reasoner model for the built-in `openai` backend.
73    #[serde(default)]
74    pub model: Option<String>,
75    /// Backend for the patch/apply (coding) role: an OpenAI-compatible API base
76    /// URL or one of `claude`, `codex`, `opencode`, `cursor-agent`. Falls back to
77    /// `$SCX_FORGE_CODING_BACKEND`, then to the planner `backend`. Set this (with
78    /// `coding_model`) to run the coding role on a separate backend.
79    #[serde(default, alias = "coding_base_url")]
80    pub coding_backend: Option<String>,
81    /// Patch/apply model for the built-in `openai` backend. Empty means use
82    /// `model`.
83    #[serde(default)]
84    pub coding_model: Option<String>,
85    /// Max tool-calling iterations per LLM turn for the built-in `openai`
86    /// backend.
87    #[serde(default = "default_max_tool_iterations")]
88    pub max_tool_iterations: u32,
89    /// Wall-clock cap per planner or coding model turn.
90    #[serde(default = "default_max_turn_seconds")]
91    pub max_turn_seconds: u64,
92    /// Maximum optimization rounds, shared across both phases. The AI-driven
93    /// knob-tuning phase ends when the model decides it is done; the remaining
94    /// rounds (up to this cap) go to code changes.
95    #[serde(default = "default_rounds")]
96    pub rounds: u32,
97    /// Skip the AI-driven knob-tuning phase and go straight to code changes.
98    #[serde(default)]
99    pub skip_knobs: bool,
100    /// Allow read-only cross-scheduler reference tools (list_schedulers,
101    /// grep_schedulers, read_scheduler_file) that inspect other scheduler crates
102    /// under scheds/rust. Disabled by default: the model focuses only on the
103    /// target crate, which keeps the prompt and tool surface (and thus token
104    /// usage) smaller. Enable to let the planner port mechanisms from other
105    /// schedulers.
106    #[serde(default)]
107    pub cross_scheduler_refs: bool,
108    /// LLM edit attempts to fix a broken build before reverting the round.
109    #[serde(default = "default_build_fix_attempts")]
110    pub build_fix_attempts: u32,
111    /// LLM edit attempts to fix a runtime scheduler failure before reverting.
112    #[serde(default = "default_runtime_fix_attempts")]
113    pub runtime_fix_attempts: u32,
114}
115
116impl Default for Ai {
117    fn default() -> Self {
118        Ai {
119            backend: None,
120            model: None,
121            coding_backend: None,
122            coding_model: None,
123            max_tool_iterations: default_max_tool_iterations(),
124            max_turn_seconds: default_max_turn_seconds(),
125            rounds: default_rounds(),
126            skip_knobs: false,
127            cross_scheduler_refs: false,
128            build_fix_attempts: default_build_fix_attempts(),
129            runtime_fix_attempts: default_runtime_fix_attempts(),
130        }
131    }
132}
133
134impl Ai {
135    pub fn turn_timeout(&self) -> Duration {
136        Duration::from_secs(self.max_turn_seconds)
137    }
138}
139
140#[derive(Debug, Clone, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct Tracing {
143    /// Enable optional perf recording during workloads (only used when perf is
144    /// available). On by default.
145    #[serde(default = "default_true")]
146    pub enable_tracing: bool,
147    /// Events passed to `perf record` with `-e`.
148    #[serde(default = "default_trace_events")]
149    pub trace_events: Vec<String>,
150    /// Cap passed to `perf record --max-size` for perf.data. Accepts a plain byte
151    /// count or a human-readable size with a binary suffix (`K`, `M`, `G`,
152    /// optionally followed by `B`), e.g. `256M`, `1G`.
153    #[serde(default = "default_max_trace_size")]
154    pub max_trace_size: String,
155}
156
157impl Default for Tracing {
158    fn default() -> Self {
159        Tracing {
160            enable_tracing: true,
161            trace_events: default_trace_events(),
162            max_trace_size: default_max_trace_size(),
163        }
164    }
165}
166
167impl Tracing {
168    /// Parse [`Tracing::max_trace_size`] into a byte count.
169    pub fn max_trace_bytes(&self) -> Result<u64> {
170        parse_size(&self.max_trace_size)
171            .with_context(|| format!("[tracing].max_trace_size = {:?}", self.max_trace_size))
172    }
173}
174
175/// Parse a size string into bytes: a plain integer, or an integer with a binary
176/// suffix `K`/`M`/`G` (case-insensitive, optional trailing `B`). 1 K = 1024.
177fn parse_size(s: &str) -> Result<u64> {
178    let s = s.trim();
179    let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
180    let (num, suffix) = s.split_at(digits_end);
181    let value: u64 = num
182        .parse()
183        .with_context(|| format!("invalid size {s:?}: expected a leading number"))?;
184    let suffix = suffix.trim().trim_end_matches(['b', 'B']);
185    let mult: u64 = match suffix {
186        "" => 1,
187        "k" | "K" => 1024,
188        "m" | "M" => 1024 * 1024,
189        "g" | "G" => 1024 * 1024 * 1024,
190        other => anyhow::bail!("invalid size {s:?}: unknown suffix {other:?} (use K, M, or G)"),
191    };
192    value
193        .checked_mul(mult)
194        .with_context(|| format!("size {s:?} overflows u64"))
195}
196
197#[derive(Debug, Clone, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct Workload {
200    /// Shell command run while the scheduler is attached. Optional: when absent,
201    /// the harness just observes for `duration` seconds (load runs externally).
202    pub command: Option<String>,
203    /// Hard cap in seconds; the workload is killed after this duration.
204    #[serde(default = "default_duration")]
205    pub duration: u64,
206    /// Repeat the measured run N times; result reports median + stddev.
207    #[serde(default = "default_runs")]
208    pub runs: u64,
209}
210
211impl Default for Workload {
212    fn default() -> Self {
213        Workload {
214            command: None,
215            duration: default_duration(),
216            runs: default_runs(),
217        }
218    }
219}
220
221#[derive(Debug, Clone, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct Goal {
224    /// Plain-language statement of the goal (e.g. "minimize tail latency",
225    /// "improve throughput"), shown to the model to frame the objective.
226    pub prompt: Option<String>,
227    /// "minimize" or "maximize". Direction of improvement for the metric.
228    #[serde(default = "default_direction")]
229    pub direction: String,
230    /// Accept a round only if it improves the metric by more than this many
231    /// stddevs.
232    #[serde(default = "default_accept_threshold_stddev")]
233    pub accept_threshold_stddev: f64,
234}
235
236impl Default for Goal {
237    fn default() -> Self {
238        Goal {
239            prompt: None,
240            direction: default_direction(),
241            accept_threshold_stddev: default_accept_threshold_stddev(),
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn parses_optional_system_sudo_passwd_file() {
252        let spec: Spec = toml::from_str(
253            r#"
254[scheduler]
255package = "scx_forge"
256
257[system]
258sudo_passwd_file = "secrets/sudo-pass"
259
260[goal]
261prompt = "minimize the measured value"
262direction = "minimize"
263"#,
264        )
265        .unwrap();
266
267        assert_eq!(
268            spec.system.sudo_passwd_file.as_deref(),
269            Some(Path::new("secrets/sudo-pass"))
270        );
271    }
272
273    #[test]
274    fn parses_workload_fields() {
275        let spec: Spec = toml::from_str(
276            r#"
277[scheduler]
278package = "scx_forge"
279warmup_time = 7
280
281[workload]
282duration = 42
283runs = 3
284
285[goal]
286prompt = "minimize tail latency"
287direction = "minimize"
288"#,
289        )
290        .unwrap();
291
292        assert_eq!(spec.goal.prompt.as_deref(), Some("minimize tail latency"));
293        assert_eq!(spec.goal.direction, "minimize");
294        assert_eq!(spec.runs(), 3);
295        assert_eq!(spec.scheduler.warmup_time, 7);
296        assert_eq!(spec.workload.duration, 42);
297    }
298
299    #[test]
300    fn parses_ai_models() {
301        let spec: Spec = toml::from_str(
302            r#"
303[scheduler]
304package = "scx_forge"
305
306[ai]
307backend = "https://example.com/v1"
308model = "planner"
309coding_backend = "https://coder.example.com/v1"
310coding_model = "coder"
311max_tool_iterations = 7
312max_turn_seconds = 123
313
314[goal]
315prompt = "minimize tail latency"
316direction = "minimize"
317"#,
318        )
319        .unwrap();
320
321        assert_eq!(spec.ai.backend.as_deref(), Some("https://example.com/v1"));
322        assert_eq!(spec.ai.model.as_deref(), Some("planner"));
323        assert_eq!(
324            spec.ai.coding_backend.as_deref(),
325            Some("https://coder.example.com/v1")
326        );
327        assert_eq!(spec.ai.coding_model.as_deref(), Some("coder"));
328        assert_eq!(spec.ai.max_tool_iterations, 7);
329        assert_eq!(spec.ai.max_turn_seconds, 123);
330    }
331
332    #[test]
333    fn accepts_legacy_base_url_aliases() {
334        // Older specs used base_url / coding_base_url; serde aliases keep them
335        // working after the rename to backend / coding_backend.
336        let spec: Spec = toml::from_str(
337            r#"
338[scheduler]
339package = "scx_forge"
340
341[ai]
342base_url = "https://example.com/v1"
343coding_base_url = "claude"
344
345[goal]
346prompt = "minimize tail latency"
347direction = "minimize"
348"#,
349        )
350        .unwrap();
351
352        assert_eq!(spec.ai.backend.as_deref(), Some("https://example.com/v1"));
353        assert_eq!(spec.ai.coding_backend.as_deref(), Some("claude"));
354    }
355
356    #[test]
357    fn workload_and_ai_defaults() {
358        let spec: Spec = toml::from_str(
359            r#"
360[scheduler]
361package = "scx_forge"
362"#,
363        )
364        .unwrap();
365
366        assert_eq!(spec.goal.accept_threshold_stddev, 1.0);
367        assert_eq!(spec.ai.max_tool_iterations, 20);
368        assert_eq!(spec.ai.max_turn_seconds, 300);
369        assert_eq!(spec.ai.rounds, 32);
370        assert_eq!(spec.ai.build_fix_attempts, 10);
371        assert_eq!(spec.ai.runtime_fix_attempts, 5);
372    }
373
374    #[test]
375    fn tracing_defaults() {
376        let spec: Spec = toml::from_str(
377            r#"
378[scheduler]
379package = "scx_forge"
380"#,
381        )
382        .unwrap();
383
384        assert!(spec.tracing.enable_tracing);
385        assert_eq!(
386            spec.tracing.trace_events,
387            vec![
388                "sched:sched_wakeup",
389                "sched:sched_wakeup_new",
390                "sched:sched_switch",
391                "sched:sched_migrate_task",
392            ]
393        );
394        assert_eq!(spec.tracing.max_trace_size, "256M");
395        assert_eq!(spec.tracing.max_trace_bytes().unwrap(), 256 * 1024 * 1024);
396    }
397
398    #[test]
399    fn parses_tracing_section() {
400        let spec: Spec = toml::from_str(
401            r#"
402[scheduler]
403package = "scx_forge"
404
405[tracing]
406enable_tracing = false
407trace_events = ["sched:sched_switch", "sched:sched_process_exit"]
408max_trace_size = "1G"
409"#,
410        )
411        .unwrap();
412
413        assert!(!spec.tracing.enable_tracing);
414        assert_eq!(
415            spec.tracing.trace_events,
416            vec!["sched:sched_switch", "sched:sched_process_exit"]
417        );
418        assert_eq!(spec.tracing.max_trace_bytes().unwrap(), 1024 * 1024 * 1024);
419    }
420
421    #[test]
422    fn validates_tracing_events() {
423        let spec: Spec = toml::from_str(
424            r#"
425[scheduler]
426package = "scx_forge"
427
428[tracing]
429trace_events = []
430"#,
431        )
432        .unwrap();
433
434        assert!(spec
435            .validate()
436            .unwrap_err()
437            .to_string()
438            .contains("[tracing].trace_events must not be empty when tracing is enabled"));
439
440        let disabled: Spec = toml::from_str(
441            r#"
442[scheduler]
443package = "scx_forge"
444
445[tracing]
446enable_tracing = false
447trace_events = []
448"#,
449        )
450        .unwrap();
451
452        disabled.validate().unwrap();
453    }
454
455    #[test]
456    fn parse_size_accepts_suffixes_and_rejects_garbage() {
457        assert_eq!(parse_size("1024").unwrap(), 1024);
458        assert_eq!(parse_size("512K").unwrap(), 512 * 1024);
459        assert_eq!(parse_size("256M").unwrap(), 256 * 1024 * 1024);
460        assert_eq!(parse_size("256MB").unwrap(), 256 * 1024 * 1024);
461        assert_eq!(parse_size("2g").unwrap(), 2 * 1024 * 1024 * 1024);
462        assert!(parse_size("").is_err());
463        assert!(parse_size("M").is_err());
464        assert!(parse_size("10T").is_err());
465    }
466}
467
468fn default_profile() -> String {
469    "release".to_string()
470}
471fn default_warmup_time() -> u64 {
472    2
473}
474fn default_stats_interval() -> u64 {
475    1
476}
477fn default_build_fix_attempts() -> u32 {
478    10
479}
480fn default_runtime_fix_attempts() -> u32 {
481    5
482}
483fn default_duration() -> u64 {
484    30
485}
486fn default_direction() -> String {
487    "minimize".to_string()
488}
489fn default_runs() -> u64 {
490    1
491}
492fn default_accept_threshold_stddev() -> f64 {
493    1.0
494}
495fn default_max_tool_iterations() -> u32 {
496    20
497}
498fn default_max_turn_seconds() -> u64 {
499    crate::model_timeout::DEFAULT_TURN_TIMEOUT_SECS
500}
501fn default_rounds() -> u32 {
502    32
503}
504fn default_true() -> bool {
505    true
506}
507fn default_max_trace_size() -> String {
508    "256M".to_string()
509}
510fn default_trace_events() -> Vec<String> {
511    [
512        "sched:sched_wakeup",
513        "sched:sched_wakeup_new",
514        "sched:sched_switch",
515        "sched:sched_migrate_task",
516    ]
517    .into_iter()
518    .map(str::to_string)
519    .collect()
520}
521
522impl Spec {
523    /// Read and parse a TOML spec file.
524    pub fn load(path: &Path) -> Result<Spec> {
525        let text = std::fs::read_to_string(path)
526            .with_context(|| format!("read spec {}", path.display()))?;
527        let spec: Spec =
528            toml::from_str(&text).with_context(|| format!("parse spec {}", path.display()))?;
529        spec.validate()?;
530        Ok(spec)
531    }
532
533    fn validate(&self) -> Result<()> {
534        if self.ai.max_turn_seconds == 0 {
535            anyhow::bail!("[ai].max_turn_seconds must be greater than 0");
536        }
537        if self.tracing.enable_tracing && self.tracing.trace_events.is_empty() {
538            anyhow::bail!("[tracing].trace_events must not be empty when tracing is enabled");
539        }
540        if self
541            .tracing
542            .trace_events
543            .iter()
544            .any(|event| event.trim().is_empty())
545        {
546            anyhow::bail!("[tracing].trace_events must not contain empty event names");
547        }
548        // Validate the trace-size cap up front so a bad value fails the run early
549        // rather than mid-recording.
550        self.tracing.max_trace_bytes()?;
551        Ok(())
552    }
553
554    /// Number of measured runs, clamped to at least 1 (matches Python max(1, runs)).
555    pub fn runs(&self) -> u64 {
556        self.workload.runs.max(1)
557    }
558}