1use std::collections::BTreeMap;
5
6use super::cluster::{DdpConfig, OutputConfig, TrainingConfig};
7use super::schema::{CommandConfig, CommandKind, CommandSpec, Schema};
8
9const STRICT_UNIVERSAL_LONGS: &[(&str, Option<char>, bool)] = &[
15 ("help", Some('h'), false),
17 ("version", Some('V'), false),
18 ("fdl-schema", None, false),
19 ("refresh-schema", None, false),
20];
21
22pub fn schema_to_args_spec(schema: &Schema) -> crate::args::parser::ArgsSpec {
28 use crate::args::parser::{ArgsSpec, OptionDecl, PositionalDecl};
29
30 let mut options: Vec<OptionDecl> = schema
31 .options
32 .iter()
33 .map(|(long, spec)| OptionDecl {
34 long: long.clone(),
35 short: spec.short.as_deref().and_then(|s| s.chars().next()),
36 takes_value: spec.ty != "bool",
37 allows_bare: true,
42 repeatable: spec.ty.starts_with("list["),
43 choices: spec
44 .choices
45 .as_ref()
46 .map(|cs| strict_choices_to_strings(cs)),
47 })
48 .collect();
49
50 for (long, short, takes_value) in STRICT_UNIVERSAL_LONGS {
53 options.push(OptionDecl {
54 long: (*long).to_string(),
55 short: *short,
56 takes_value: *takes_value,
57 allows_bare: true,
58 repeatable: false,
59 choices: None,
60 });
61 }
62
63 let mut positionals: Vec<PositionalDecl> = schema
66 .args
67 .iter()
68 .map(|a| PositionalDecl {
69 name: a.name.clone(),
70 required: false,
71 variadic: a.variadic,
72 choices: a.choices.as_ref().map(|cs| strict_choices_to_strings(cs)),
73 })
74 .collect();
75 positionals.push(PositionalDecl {
80 name: "rest".to_string(),
81 required: false,
82 variadic: true,
83 choices: None,
84 });
85
86 ArgsSpec {
87 options,
88 positionals,
89 lenient_unknowns: !schema.strict,
93 }
94}
95
96fn strict_choices_to_strings(cs: &[serde_json::Value]) -> Vec<String> {
97 cs.iter()
98 .map(|v| match v {
99 serde_json::Value::String(s) => s.clone(),
100 other => other.to_string(),
101 })
102 .collect()
103}
104
105pub fn validate_tail(tail: &[String], schema: &Schema) -> Result<(), String> {
113 if !schema.commands.is_empty() {
124 let Some(sub) = tail.first().filter(|t| !t.starts_with('-')) else {
125 return Ok(());
126 };
127 let Some(child) = schema.commands.get(sub) else {
128 let names: Vec<&str> = schema.commands.keys().map(String::as_str).collect();
129 return Err(match crate::args::parser::suggest(&names, sub) {
130 Some(s) => format!("unknown command `{sub}`, did you mean `{s}`?"),
131 None => format!(
132 "unknown command `{sub}`, expected one of: {}",
133 names.join(", ")
134 ),
135 });
136 };
137 return validate_tail(&tail[1..], child);
138 }
139
140 let spec = schema_to_args_spec(schema);
141 let mut argv = Vec::with_capacity(tail.len() + 1);
142 argv.push("fdl".to_string());
143 argv.extend(tail.iter().cloned());
144 crate::args::parser::parse(&spec, &argv).map(|_| ())
145}
146
147pub fn validate_preset_for_exec(
153 preset_name: &str,
154 spec: &CommandSpec,
155 schema: &Schema,
156) -> Result<(), String> {
157 for (key, value) in &spec.options {
158 let Some(opt) = schema.options.get(key) else {
159 if schema.strict {
160 return Err(format!(
161 "preset `{preset_name}` pins option `{key}` which is not declared in schema.options"
162 ));
163 }
164 continue;
165 };
166 let Some(choices) = &opt.choices else {
167 continue;
168 };
169 if !choices.iter().any(|c| values_equal(c, value)) {
170 let allowed: Vec<String> = choices
171 .iter()
172 .map(|c| match c {
173 serde_json::Value::String(s) => s.clone(),
174 other => other.to_string(),
175 })
176 .collect();
177 return Err(format!(
178 "preset `{preset_name}` sets option `{key}` to `{}` -- allowed: {}",
179 display_json(value),
180 allowed.join(", "),
181 ));
182 }
183 }
184 Ok(())
185}
186
187pub fn validate_preset_values(
197 commands: &BTreeMap<String, CommandSpec>,
198 schema: &Schema,
199) -> Result<(), String> {
200 for (preset_name, spec) in commands {
201 match spec.kind() {
202 Ok(CommandKind::Preset) => {}
203 _ => continue,
204 }
205 for (key, value) in &spec.options {
206 let Some(opt) = schema.options.get(key) else {
207 continue; };
209 let Some(choices) = &opt.choices else {
210 continue; };
212 if !choices.iter().any(|c| values_equal(c, value)) {
213 let allowed: Vec<String> = choices
214 .iter()
215 .map(|c| match c {
216 serde_json::Value::String(s) => s.clone(),
217 other => other.to_string(),
218 })
219 .collect();
220 return Err(format!(
221 "preset `{preset_name}` sets option `{key}` to `{}` -- allowed: {}",
222 display_json(value),
223 allowed.join(", "),
224 ));
225 }
226 }
227 }
228 Ok(())
229}
230
231fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
235 if a == b {
236 return true;
237 }
238 match (a, b) {
240 (serde_json::Value::String(s), other) | (other, serde_json::Value::String(s)) => {
241 s == &other.to_string()
242 }
243 _ => false,
244 }
245}
246
247fn display_json(v: &serde_json::Value) -> String {
248 match v {
249 serde_json::Value::String(s) => s.clone(),
250 other => other.to_string(),
251 }
252}
253
254pub fn validate_presets_strict(
259 commands: &BTreeMap<String, CommandSpec>,
260 schema: &Schema,
261) -> Result<(), String> {
262 for (preset_name, spec) in commands {
263 match spec.kind() {
264 Ok(CommandKind::Preset) => {}
265 _ => continue,
266 }
267 for key in spec.options.keys() {
268 if !schema.options.contains_key(key) {
269 return Err(format!(
270 "preset `{preset_name}` pins option `{key}` which is not declared in schema.options"
271 ));
272 }
273 }
274 }
275 Ok(())
276}
277
278pub fn merge_preset(root: &CommandConfig, preset: &CommandSpec) -> ResolvedConfig {
284 ResolvedConfig {
285 ddp: merge_ddp(&root.ddp, &preset.ddp),
286 training: merge_training(&root.training, &preset.training),
287 output: merge_output(&root.output, &preset.output),
288 options: preset.options.clone(),
289 }
290}
291
292pub fn defaults_only(root: &CommandConfig) -> ResolvedConfig {
294 ResolvedConfig {
295 ddp: root.ddp.clone().unwrap_or_default(),
296 training: root.training.clone().unwrap_or_default(),
297 output: root.output.clone().unwrap_or_default(),
298 options: BTreeMap::new(),
299 }
300}
301
302pub struct ResolvedConfig {
304 pub ddp: DdpConfig,
305 pub training: TrainingConfig,
306 pub output: OutputConfig,
307 pub options: BTreeMap<String, serde_json::Value>,
308}
309
310macro_rules! merge_field {
311 ($base:expr, $over:expr, $field:ident) => {
312 $over
313 .as_ref()
314 .and_then(|o| o.$field.clone())
315 .or_else(|| $base.as_ref().and_then(|b| b.$field.clone()))
316 };
317}
318
319fn merge_ddp(base: &Option<DdpConfig>, over: &Option<DdpConfig>) -> DdpConfig {
320 DdpConfig {
321 mode: merge_field!(base, over, mode),
322 policy: merge_field!(base, over, policy),
323 backend: merge_field!(base, over, backend),
324 anchor: merge_field!(base, over, anchor),
325 max_anchor: merge_field!(base, over, max_anchor),
326 overhead_target: merge_field!(base, over, overhead_target),
327 divergence_threshold: merge_field!(base, over, divergence_threshold),
328 max_batch_diff: merge_field!(base, over, max_batch_diff),
329 speed_hint: merge_field!(base, over, speed_hint),
330 partition_ratios: merge_field!(base, over, partition_ratios),
331 progressive: merge_field!(base, over, progressive),
332 max_grad_norm: merge_field!(base, over, max_grad_norm),
333 lr_scale_ratio: merge_field!(base, over, lr_scale_ratio),
334 snapshot_timeout: merge_field!(base, over, snapshot_timeout),
335 checkpoint_every: merge_field!(base, over, checkpoint_every),
336 timeline: merge_field!(base, over, timeline),
337 }
338}
339
340fn merge_training(base: &Option<TrainingConfig>, over: &Option<TrainingConfig>) -> TrainingConfig {
341 TrainingConfig {
342 epochs: merge_field!(base, over, epochs),
343 batch_size: merge_field!(base, over, batch_size),
344 batches_per_epoch: merge_field!(base, over, batches_per_epoch),
345 lr: merge_field!(base, over, lr),
346 seed: merge_field!(base, over, seed),
347 }
348}
349
350fn merge_output(base: &Option<OutputConfig>, over: &Option<OutputConfig>) -> OutputConfig {
351 OutputConfig {
352 dir: merge_field!(base, over, dir),
353 timeline: merge_field!(base, over, timeline),
354 monitor: merge_field!(base, over, monitor),
355 }
356}