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
246fn default_profile() -> String {
247    "release".to_string()
248}
249fn default_warmup_time() -> u64 {
250    2
251}
252fn default_stats_interval() -> u64 {
253    1
254}
255fn default_build_fix_attempts() -> u32 {
256    10
257}
258fn default_runtime_fix_attempts() -> u32 {
259    5
260}
261fn default_duration() -> u64 {
262    30
263}
264fn default_direction() -> String {
265    "minimize".to_string()
266}
267fn default_runs() -> u64 {
268    1
269}
270fn default_accept_threshold_stddev() -> f64 {
271    1.0
272}
273fn default_max_tool_iterations() -> u32 {
274    20
275}
276fn default_max_turn_seconds() -> u64 {
277    crate::model_timeout::DEFAULT_TURN_TIMEOUT_SECS
278}
279fn default_rounds() -> u32 {
280    32
281}
282fn default_true() -> bool {
283    true
284}
285fn default_max_trace_size() -> String {
286    "256M".to_string()
287}
288fn default_trace_events() -> Vec<String> {
289    [
290        "sched:sched_wakeup",
291        "sched:sched_wakeup_new",
292        "sched:sched_switch",
293        "sched:sched_migrate_task",
294    ]
295    .into_iter()
296    .map(str::to_string)
297    .collect()
298}
299
300impl Spec {
301    /// Read and parse a TOML spec file.
302    pub fn load(path: &Path) -> Result<Spec> {
303        let text = std::fs::read_to_string(path)
304            .with_context(|| format!("read spec {}", path.display()))?;
305        let spec: Spec =
306            toml::from_str(&text).with_context(|| format!("parse spec {}", path.display()))?;
307        spec.validate()?;
308        Ok(spec)
309    }
310
311    fn validate(&self) -> Result<()> {
312        if self.ai.max_turn_seconds == 0 {
313            anyhow::bail!("[ai].max_turn_seconds must be greater than 0");
314        }
315        if self.tracing.enable_tracing && self.tracing.trace_events.is_empty() {
316            anyhow::bail!("[tracing].trace_events must not be empty when tracing is enabled");
317        }
318        if self
319            .tracing
320            .trace_events
321            .iter()
322            .any(|event| event.trim().is_empty())
323        {
324            anyhow::bail!("[tracing].trace_events must not contain empty event names");
325        }
326        // Validate the trace-size cap up front so a bad value fails the run early
327        // rather than mid-recording.
328        self.tracing.max_trace_bytes()?;
329        Ok(())
330    }
331
332    /// Number of measured runs, clamped to at least 1 (matches Python max(1, runs)).
333    pub fn runs(&self) -> u64 {
334        self.workload.runs.max(1)
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn parses_optional_system_sudo_passwd_file() {
344        let spec: Spec = toml::from_str(
345            r#"
346[scheduler]
347package = "scx_forge"
348
349[system]
350sudo_passwd_file = "secrets/sudo-pass"
351
352[goal]
353prompt = "minimize the measured value"
354direction = "minimize"
355"#,
356        )
357        .unwrap();
358
359        assert_eq!(
360            spec.system.sudo_passwd_file.as_deref(),
361            Some(Path::new("secrets/sudo-pass"))
362        );
363    }
364
365    #[test]
366    fn parses_workload_fields() {
367        let spec: Spec = toml::from_str(
368            r#"
369[scheduler]
370package = "scx_forge"
371warmup_time = 7
372
373[workload]
374duration = 42
375runs = 3
376
377[goal]
378prompt = "minimize tail latency"
379direction = "minimize"
380"#,
381        )
382        .unwrap();
383
384        assert_eq!(spec.goal.prompt.as_deref(), Some("minimize tail latency"));
385        assert_eq!(spec.goal.direction, "minimize");
386        assert_eq!(spec.runs(), 3);
387        assert_eq!(spec.scheduler.warmup_time, 7);
388        assert_eq!(spec.workload.duration, 42);
389    }
390
391    #[test]
392    fn parses_ai_models() {
393        let spec: Spec = toml::from_str(
394            r#"
395[scheduler]
396package = "scx_forge"
397
398[ai]
399backend = "https://example.com/v1"
400model = "planner"
401coding_backend = "https://coder.example.com/v1"
402coding_model = "coder"
403max_tool_iterations = 7
404max_turn_seconds = 123
405
406[goal]
407prompt = "minimize tail latency"
408direction = "minimize"
409"#,
410        )
411        .unwrap();
412
413        assert_eq!(spec.ai.backend.as_deref(), Some("https://example.com/v1"));
414        assert_eq!(spec.ai.model.as_deref(), Some("planner"));
415        assert_eq!(
416            spec.ai.coding_backend.as_deref(),
417            Some("https://coder.example.com/v1")
418        );
419        assert_eq!(spec.ai.coding_model.as_deref(), Some("coder"));
420        assert_eq!(spec.ai.max_tool_iterations, 7);
421        assert_eq!(spec.ai.max_turn_seconds, 123);
422    }
423
424    #[test]
425    fn accepts_legacy_base_url_aliases() {
426        // Older specs used base_url / coding_base_url; serde aliases keep them
427        // working after the rename to backend / coding_backend.
428        let spec: Spec = toml::from_str(
429            r#"
430[scheduler]
431package = "scx_forge"
432
433[ai]
434base_url = "https://example.com/v1"
435coding_base_url = "claude"
436
437[goal]
438prompt = "minimize tail latency"
439direction = "minimize"
440"#,
441        )
442        .unwrap();
443
444        assert_eq!(spec.ai.backend.as_deref(), Some("https://example.com/v1"));
445        assert_eq!(spec.ai.coding_backend.as_deref(), Some("claude"));
446    }
447
448    #[test]
449    fn workload_and_ai_defaults() {
450        let spec: Spec = toml::from_str(
451            r#"
452[scheduler]
453package = "scx_forge"
454"#,
455        )
456        .unwrap();
457
458        assert_eq!(spec.goal.accept_threshold_stddev, 1.0);
459        assert_eq!(spec.ai.max_tool_iterations, 20);
460        assert_eq!(spec.ai.max_turn_seconds, 300);
461        assert_eq!(spec.ai.rounds, 32);
462        assert_eq!(spec.ai.build_fix_attempts, 10);
463        assert_eq!(spec.ai.runtime_fix_attempts, 5);
464    }
465
466    #[test]
467    fn tracing_defaults() {
468        let spec: Spec = toml::from_str(
469            r#"
470[scheduler]
471package = "scx_forge"
472"#,
473        )
474        .unwrap();
475
476        assert!(spec.tracing.enable_tracing);
477        assert_eq!(
478            spec.tracing.trace_events,
479            vec![
480                "sched:sched_wakeup",
481                "sched:sched_wakeup_new",
482                "sched:sched_switch",
483                "sched:sched_migrate_task",
484            ]
485        );
486        assert_eq!(spec.tracing.max_trace_size, "256M");
487        assert_eq!(spec.tracing.max_trace_bytes().unwrap(), 256 * 1024 * 1024);
488    }
489
490    #[test]
491    fn parses_tracing_section() {
492        let spec: Spec = toml::from_str(
493            r#"
494[scheduler]
495package = "scx_forge"
496
497[tracing]
498enable_tracing = false
499trace_events = ["sched:sched_switch", "sched:sched_process_exit"]
500max_trace_size = "1G"
501"#,
502        )
503        .unwrap();
504
505        assert!(!spec.tracing.enable_tracing);
506        assert_eq!(
507            spec.tracing.trace_events,
508            vec!["sched:sched_switch", "sched:sched_process_exit"]
509        );
510        assert_eq!(spec.tracing.max_trace_bytes().unwrap(), 1024 * 1024 * 1024);
511    }
512
513    #[test]
514    fn validates_tracing_events() {
515        let spec: Spec = toml::from_str(
516            r#"
517[scheduler]
518package = "scx_forge"
519
520[tracing]
521trace_events = []
522"#,
523        )
524        .unwrap();
525
526        assert!(
527            spec.validate()
528                .unwrap_err()
529                .to_string()
530                .contains("[tracing].trace_events must not be empty when tracing is enabled")
531        );
532
533        let disabled: Spec = toml::from_str(
534            r#"
535[scheduler]
536package = "scx_forge"
537
538[tracing]
539enable_tracing = false
540trace_events = []
541"#,
542        )
543        .unwrap();
544
545        disabled.validate().unwrap();
546    }
547
548    #[test]
549    fn parse_size_accepts_suffixes_and_rejects_garbage() {
550        assert_eq!(parse_size("1024").unwrap(), 1024);
551        assert_eq!(parse_size("512K").unwrap(), 512 * 1024);
552        assert_eq!(parse_size("256M").unwrap(), 256 * 1024 * 1024);
553        assert_eq!(parse_size("256MB").unwrap(), 256 * 1024 * 1024);
554        assert_eq!(parse_size("2g").unwrap(), 2 * 1024 * 1024 * 1024);
555        assert!(parse_size("").is_err());
556        assert!(parse_size("M").is_err());
557        assert!(parse_size("10T").is_err());
558    }
559}