1use std::collections::BTreeMap;
5
6
7use super::cluster::{DdpConfig, OutputConfig, TrainingConfig};
8use super::schema::{
9 CommandConfig, CommandKind, CommandSpec, Schema,
10};
11
12const STRICT_UNIVERSAL_LONGS: &[(&str, Option<char>, bool)] = &[
18 ("help", Some('h'), false),
20 ("version", Some('V'), false),
21 ("fdl-schema", None, false),
22 ("refresh-schema", None, false),
23];
24
25pub fn schema_to_args_spec(schema: &Schema) -> crate::args::parser::ArgsSpec {
31 use crate::args::parser::{ArgsSpec, OptionDecl, PositionalDecl};
32
33 let mut options: Vec<OptionDecl> = schema
34 .options
35 .iter()
36 .map(|(long, spec)| OptionDecl {
37 long: long.clone(),
38 short: spec
39 .short
40 .as_deref()
41 .and_then(|s| s.chars().next()),
42 takes_value: spec.ty != "bool",
43 allows_bare: true,
48 repeatable: spec.ty.starts_with("list["),
49 choices: spec
50 .choices
51 .as_ref()
52 .map(|cs| strict_choices_to_strings(cs)),
53 })
54 .collect();
55
56 for (long, short, takes_value) in STRICT_UNIVERSAL_LONGS {
59 options.push(OptionDecl {
60 long: (*long).to_string(),
61 short: *short,
62 takes_value: *takes_value,
63 allows_bare: true,
64 repeatable: false,
65 choices: None,
66 });
67 }
68
69 let mut positionals: Vec<PositionalDecl> = schema
72 .args
73 .iter()
74 .map(|a| PositionalDecl {
75 name: a.name.clone(),
76 required: false,
77 variadic: a.variadic,
78 choices: a
79 .choices
80 .as_ref()
81 .map(|cs| strict_choices_to_strings(cs)),
82 })
83 .collect();
84 positionals.push(PositionalDecl {
89 name: "rest".to_string(),
90 required: false,
91 variadic: true,
92 choices: None,
93 });
94
95 ArgsSpec {
96 options,
97 positionals,
98 lenient_unknowns: !schema.strict,
102 }
103}
104
105fn strict_choices_to_strings(cs: &[serde_json::Value]) -> Vec<String> {
106 cs.iter()
107 .map(|v| match v {
108 serde_json::Value::String(s) => s.clone(),
109 other => other.to_string(),
110 })
111 .collect()
112}
113
114pub fn validate_tail(tail: &[String], schema: &Schema) -> Result<(), String> {
122 if !schema.commands.is_empty() {
133 let Some(sub) = tail.first().filter(|t| !t.starts_with('-')) else {
134 return Ok(());
135 };
136 let Some(child) = schema.commands.get(sub) else {
137 let names: Vec<&str> = schema.commands.keys().map(String::as_str).collect();
138 return Err(match crate::args::parser::suggest(&names, sub) {
139 Some(s) => format!("unknown command `{sub}`, did you mean `{s}`?"),
140 None => format!(
141 "unknown command `{sub}`, expected one of: {}",
142 names.join(", ")
143 ),
144 });
145 };
146 return validate_tail(&tail[1..], child);
147 }
148
149 let spec = schema_to_args_spec(schema);
150 let mut argv = Vec::with_capacity(tail.len() + 1);
151 argv.push("fdl".to_string());
152 argv.extend(tail.iter().cloned());
153 crate::args::parser::parse(&spec, &argv).map(|_| ())
154}
155
156pub fn validate_preset_for_exec(
162 preset_name: &str,
163 spec: &CommandSpec,
164 schema: &Schema,
165) -> Result<(), String> {
166 for (key, value) in &spec.options {
167 let Some(opt) = schema.options.get(key) else {
168 if schema.strict {
169 return Err(format!(
170 "preset `{preset_name}` pins option `{key}` which is not declared in schema.options"
171 ));
172 }
173 continue;
174 };
175 let Some(choices) = &opt.choices else {
176 continue;
177 };
178 if !choices.iter().any(|c| values_equal(c, value)) {
179 let allowed: Vec<String> = choices
180 .iter()
181 .map(|c| match c {
182 serde_json::Value::String(s) => s.clone(),
183 other => other.to_string(),
184 })
185 .collect();
186 return Err(format!(
187 "preset `{preset_name}` sets option `{key}` to `{}` -- allowed: {}",
188 display_json(value),
189 allowed.join(", "),
190 ));
191 }
192 }
193 Ok(())
194}
195
196pub fn validate_preset_values(
206 commands: &BTreeMap<String, CommandSpec>,
207 schema: &Schema,
208) -> Result<(), String> {
209 for (preset_name, spec) in commands {
210 match spec.kind() {
211 Ok(CommandKind::Preset) => {}
212 _ => continue,
213 }
214 for (key, value) in &spec.options {
215 let Some(opt) = schema.options.get(key) else {
216 continue; };
218 let Some(choices) = &opt.choices else {
219 continue; };
221 if !choices.iter().any(|c| values_equal(c, value)) {
222 let allowed: Vec<String> = choices
223 .iter()
224 .map(|c| match c {
225 serde_json::Value::String(s) => s.clone(),
226 other => other.to_string(),
227 })
228 .collect();
229 return Err(format!(
230 "preset `{preset_name}` sets option `{key}` to `{}` -- allowed: {}",
231 display_json(value),
232 allowed.join(", "),
233 ));
234 }
235 }
236 }
237 Ok(())
238}
239
240fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
244 if a == b {
245 return true;
246 }
247 match (a, b) {
249 (serde_json::Value::String(s), other) | (other, serde_json::Value::String(s)) => {
250 s == &other.to_string()
251 }
252 _ => false,
253 }
254}
255
256fn display_json(v: &serde_json::Value) -> String {
257 match v {
258 serde_json::Value::String(s) => s.clone(),
259 other => other.to_string(),
260 }
261}
262
263pub fn validate_presets_strict(
268 commands: &BTreeMap<String, CommandSpec>,
269 schema: &Schema,
270) -> Result<(), String> {
271 for (preset_name, spec) in commands {
272 match spec.kind() {
273 Ok(CommandKind::Preset) => {}
274 _ => continue,
275 }
276 for key in spec.options.keys() {
277 if !schema.options.contains_key(key) {
278 return Err(format!(
279 "preset `{preset_name}` pins option `{key}` which is not declared in schema.options"
280 ));
281 }
282 }
283 }
284 Ok(())
285}
286
287pub fn merge_preset(root: &CommandConfig, preset: &CommandSpec) -> ResolvedConfig {
293 ResolvedConfig {
294 ddp: merge_ddp(&root.ddp, &preset.ddp),
295 training: merge_training(&root.training, &preset.training),
296 output: merge_output(&root.output, &preset.output),
297 options: preset.options.clone(),
298 }
299}
300
301pub fn defaults_only(root: &CommandConfig) -> ResolvedConfig {
303 ResolvedConfig {
304 ddp: root.ddp.clone().unwrap_or_default(),
305 training: root.training.clone().unwrap_or_default(),
306 output: root.output.clone().unwrap_or_default(),
307 options: BTreeMap::new(),
308 }
309}
310
311pub struct ResolvedConfig {
313 pub ddp: DdpConfig,
314 pub training: TrainingConfig,
315 pub output: OutputConfig,
316 pub options: BTreeMap<String, serde_json::Value>,
317}
318
319macro_rules! merge_field {
320 ($base:expr, $over:expr, $field:ident) => {
321 $over
322 .as_ref()
323 .and_then(|o| o.$field.clone())
324 .or_else(|| $base.as_ref().and_then(|b| b.$field.clone()))
325 };
326}
327
328fn merge_ddp(base: &Option<DdpConfig>, over: &Option<DdpConfig>) -> DdpConfig {
329 DdpConfig {
330 mode: merge_field!(base, over, mode),
331 policy: merge_field!(base, over, policy),
332 backend: merge_field!(base, over, backend),
333 anchor: merge_field!(base, over, anchor),
334 max_anchor: merge_field!(base, over, max_anchor),
335 overhead_target: merge_field!(base, over, overhead_target),
336 divergence_threshold: merge_field!(base, over, divergence_threshold),
337 max_batch_diff: merge_field!(base, over, max_batch_diff),
338 speed_hint: merge_field!(base, over, speed_hint),
339 partition_ratios: merge_field!(base, over, partition_ratios),
340 progressive: merge_field!(base, over, progressive),
341 max_grad_norm: merge_field!(base, over, max_grad_norm),
342 lr_scale_ratio: merge_field!(base, over, lr_scale_ratio),
343 snapshot_timeout: merge_field!(base, over, snapshot_timeout),
344 checkpoint_every: merge_field!(base, over, checkpoint_every),
345 timeline: merge_field!(base, over, timeline),
346 }
347}
348
349fn merge_training(base: &Option<TrainingConfig>, over: &Option<TrainingConfig>) -> TrainingConfig {
350 TrainingConfig {
351 epochs: merge_field!(base, over, epochs),
352 batch_size: merge_field!(base, over, batch_size),
353 batches_per_epoch: merge_field!(base, over, batches_per_epoch),
354 lr: merge_field!(base, over, lr),
355 seed: merge_field!(base, over, seed),
356 }
357}
358
359fn merge_output(base: &Option<OutputConfig>, over: &Option<OutputConfig>) -> OutputConfig {
360 OutputConfig {
361 dir: merge_field!(base, over, dir),
362 timeline: merge_field!(base, over, timeline),
363 monitor: merge_field!(base, over, monitor),
364 }
365}