use std::collections::BTreeMap;
use super::cluster::{DdpConfig, OutputConfig, TrainingConfig};
use super::schema::{
CommandConfig, CommandKind, CommandSpec, Schema,
};
const STRICT_UNIVERSAL_LONGS: &[(&str, Option<char>, bool)] = &[
("help", Some('h'), false),
("version", Some('V'), false),
("fdl-schema", None, false),
("refresh-schema", None, false),
];
pub fn schema_to_args_spec(schema: &Schema) -> crate::args::parser::ArgsSpec {
use crate::args::parser::{ArgsSpec, OptionDecl, PositionalDecl};
let mut options: Vec<OptionDecl> = schema
.options
.iter()
.map(|(long, spec)| OptionDecl {
long: long.clone(),
short: spec
.short
.as_deref()
.and_then(|s| s.chars().next()),
takes_value: spec.ty != "bool",
allows_bare: true,
repeatable: spec.ty.starts_with("list["),
choices: spec
.choices
.as_ref()
.map(|cs| strict_choices_to_strings(cs)),
})
.collect();
for (long, short, takes_value) in STRICT_UNIVERSAL_LONGS {
options.push(OptionDecl {
long: (*long).to_string(),
short: *short,
takes_value: *takes_value,
allows_bare: true,
repeatable: false,
choices: None,
});
}
let mut positionals: Vec<PositionalDecl> = schema
.args
.iter()
.map(|a| PositionalDecl {
name: a.name.clone(),
required: false,
variadic: a.variadic,
choices: a
.choices
.as_ref()
.map(|cs| strict_choices_to_strings(cs)),
})
.collect();
positionals.push(PositionalDecl {
name: "rest".to_string(),
required: false,
variadic: true,
choices: None,
});
ArgsSpec {
options,
positionals,
lenient_unknowns: !schema.strict,
}
}
fn strict_choices_to_strings(cs: &[serde_json::Value]) -> Vec<String> {
cs.iter()
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect()
}
pub fn validate_tail(tail: &[String], schema: &Schema) -> Result<(), String> {
if !schema.commands.is_empty() {
let Some(sub) = tail.first().filter(|t| !t.starts_with('-')) else {
return Ok(());
};
let Some(child) = schema.commands.get(sub) else {
let names: Vec<&str> = schema.commands.keys().map(String::as_str).collect();
return Err(match crate::args::parser::suggest(&names, sub) {
Some(s) => format!("unknown command `{sub}`, did you mean `{s}`?"),
None => format!(
"unknown command `{sub}`, expected one of: {}",
names.join(", ")
),
});
};
return validate_tail(&tail[1..], child);
}
let spec = schema_to_args_spec(schema);
let mut argv = Vec::with_capacity(tail.len() + 1);
argv.push("fdl".to_string());
argv.extend(tail.iter().cloned());
crate::args::parser::parse(&spec, &argv).map(|_| ())
}
pub fn validate_preset_for_exec(
preset_name: &str,
spec: &CommandSpec,
schema: &Schema,
) -> Result<(), String> {
for (key, value) in &spec.options {
let Some(opt) = schema.options.get(key) else {
if schema.strict {
return Err(format!(
"preset `{preset_name}` pins option `{key}` which is not declared in schema.options"
));
}
continue;
};
let Some(choices) = &opt.choices else {
continue;
};
if !choices.iter().any(|c| values_equal(c, value)) {
let allowed: Vec<String> = choices
.iter()
.map(|c| match c {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect();
return Err(format!(
"preset `{preset_name}` sets option `{key}` to `{}` -- allowed: {}",
display_json(value),
allowed.join(", "),
));
}
}
Ok(())
}
pub fn validate_preset_values(
commands: &BTreeMap<String, CommandSpec>,
schema: &Schema,
) -> Result<(), String> {
for (preset_name, spec) in commands {
match spec.kind() {
Ok(CommandKind::Preset) => {}
_ => continue,
}
for (key, value) in &spec.options {
let Some(opt) = schema.options.get(key) else {
continue; };
let Some(choices) = &opt.choices else {
continue; };
if !choices.iter().any(|c| values_equal(c, value)) {
let allowed: Vec<String> = choices
.iter()
.map(|c| match c {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect();
return Err(format!(
"preset `{preset_name}` sets option `{key}` to `{}` -- allowed: {}",
display_json(value),
allowed.join(", "),
));
}
}
}
Ok(())
}
fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
if a == b {
return true;
}
match (a, b) {
(serde_json::Value::String(s), other) | (other, serde_json::Value::String(s)) => {
s == &other.to_string()
}
_ => false,
}
}
fn display_json(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
pub fn validate_presets_strict(
commands: &BTreeMap<String, CommandSpec>,
schema: &Schema,
) -> Result<(), String> {
for (preset_name, spec) in commands {
match spec.kind() {
Ok(CommandKind::Preset) => {}
_ => continue,
}
for key in spec.options.keys() {
if !schema.options.contains_key(key) {
return Err(format!(
"preset `{preset_name}` pins option `{key}` which is not declared in schema.options"
));
}
}
}
Ok(())
}
pub fn merge_preset(root: &CommandConfig, preset: &CommandSpec) -> ResolvedConfig {
ResolvedConfig {
ddp: merge_ddp(&root.ddp, &preset.ddp),
training: merge_training(&root.training, &preset.training),
output: merge_output(&root.output, &preset.output),
options: preset.options.clone(),
}
}
pub fn defaults_only(root: &CommandConfig) -> ResolvedConfig {
ResolvedConfig {
ddp: root.ddp.clone().unwrap_or_default(),
training: root.training.clone().unwrap_or_default(),
output: root.output.clone().unwrap_or_default(),
options: BTreeMap::new(),
}
}
pub struct ResolvedConfig {
pub ddp: DdpConfig,
pub training: TrainingConfig,
pub output: OutputConfig,
pub options: BTreeMap<String, serde_json::Value>,
}
macro_rules! merge_field {
($base:expr, $over:expr, $field:ident) => {
$over
.as_ref()
.and_then(|o| o.$field.clone())
.or_else(|| $base.as_ref().and_then(|b| b.$field.clone()))
};
}
fn merge_ddp(base: &Option<DdpConfig>, over: &Option<DdpConfig>) -> DdpConfig {
DdpConfig {
mode: merge_field!(base, over, mode),
policy: merge_field!(base, over, policy),
backend: merge_field!(base, over, backend),
anchor: merge_field!(base, over, anchor),
max_anchor: merge_field!(base, over, max_anchor),
overhead_target: merge_field!(base, over, overhead_target),
divergence_threshold: merge_field!(base, over, divergence_threshold),
max_batch_diff: merge_field!(base, over, max_batch_diff),
speed_hint: merge_field!(base, over, speed_hint),
partition_ratios: merge_field!(base, over, partition_ratios),
progressive: merge_field!(base, over, progressive),
max_grad_norm: merge_field!(base, over, max_grad_norm),
lr_scale_ratio: merge_field!(base, over, lr_scale_ratio),
snapshot_timeout: merge_field!(base, over, snapshot_timeout),
checkpoint_every: merge_field!(base, over, checkpoint_every),
timeline: merge_field!(base, over, timeline),
}
}
fn merge_training(base: &Option<TrainingConfig>, over: &Option<TrainingConfig>) -> TrainingConfig {
TrainingConfig {
epochs: merge_field!(base, over, epochs),
batch_size: merge_field!(base, over, batch_size),
batches_per_epoch: merge_field!(base, over, batches_per_epoch),
lr: merge_field!(base, over, lr),
seed: merge_field!(base, over, seed),
}
}
fn merge_output(base: &Option<OutputConfig>, over: &Option<OutputConfig>) -> OutputConfig {
OutputConfig {
dir: merge_field!(base, over, dir),
timeline: merge_field!(base, over, timeline),
monitor: merge_field!(base, over, monitor),
}
}