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
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 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 self.tracing.max_trace_bytes()?;
329 Ok(())
330 }
331
332 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 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}