1use 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 pub package: String,
42 #[serde(default = "default_profile")]
45 pub profile: String,
46 #[serde(default = "default_warmup_time")]
48 pub warmup_time: u64,
49 #[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 #[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 #[serde(default, alias = "base_url")]
71 pub backend: Option<String>,
72 #[serde(default)]
74 pub model: Option<String>,
75 #[serde(default, alias = "coding_base_url")]
80 pub coding_backend: Option<String>,
81 #[serde(default)]
84 pub coding_model: Option<String>,
85 #[serde(default = "default_max_tool_iterations")]
88 pub max_tool_iterations: u32,
89 #[serde(default = "default_max_turn_seconds")]
91 pub max_turn_seconds: u64,
92 #[serde(default = "default_rounds")]
96 pub rounds: u32,
97 #[serde(default)]
99 pub skip_knobs: bool,
100 #[serde(default)]
107 pub cross_scheduler_refs: bool,
108 #[serde(default = "default_build_fix_attempts")]
110 pub build_fix_attempts: u32,
111 #[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 #[serde(default = "default_true")]
146 pub enable_tracing: bool,
147 #[serde(default = "default_trace_events")]
149 pub trace_events: Vec<String>,
150 #[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 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
175fn 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 pub command: Option<String>,
203 #[serde(default = "default_duration")]
205 pub duration: u64,
206 #[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 pub prompt: Option<String>,
227 #[serde(default = "default_direction")]
229 pub direction: String,
230 #[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 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 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 self.tracing.max_trace_bytes()?;
551 Ok(())
552 }
553
554 pub fn runs(&self) -> u64 {
556 self.workload.runs.max(1)
557 }
558}