Skip to main content

flodl_cli/config/
validation.rs

1//! Schema → ArgsSpec conversion, tail/preset validation, and config
2//! merging (ResolvedConfig + per-field merge helpers).
3
4use std::collections::BTreeMap;
5
6use super::cluster::{DdpConfig, OutputConfig, TrainingConfig};
7use super::schema::{CommandConfig, CommandKind, CommandSpec, Schema};
8
9/// Reserved flags that strict mode always tolerates in the user's tail.
10/// These are fdl-level universals (help/version) or opt-ins every
11/// FdlArgs-derived binary exposes (--fdl-schema) — keeping them out of
12/// the `schema.options` map means strict mode has to allowlist them
13/// separately or spuriously reject legal invocations.
14const STRICT_UNIVERSAL_LONGS: &[(&str, Option<char>, bool)] = &[
15    // (long, short, takes_value)
16    ("help", Some('h'), false),
17    ("version", Some('V'), false),
18    ("fdl-schema", None, false),
19    ("refresh-schema", None, false),
20];
21
22/// Convert a [`Schema`] into an [`ArgsSpec`](crate::args::parser::ArgsSpec) suitable for strict-mode
23/// tail validation. Positional `required` flags are intentionally
24/// dropped: the binary itself will enforce them after parsing, and
25/// treating them as required here would turn "missing positional" into
26/// a double-errored mess.
27pub 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            // Every value-taking option is allowed to appear bare in
38            // strict mode. fdl does not second-guess whether the binary
39            // would accept a bare `--foo`; that stays in the binary's
40            // court.
41            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    // Always-allowed universals — help/version/fdl-schema/refresh-schema
51    // are not in the user's schema but must not trigger "unknown flag".
52    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    // Positionals: drop the `required` bit. Strict mode is scoped to
64    // option names/values only; arity is the binary's concern.
65    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    // Catch-all so fdl-side validation never rejects positionals: like
76    // arity, positional binding is the binary's concern — its own parse
77    // errors loudly on excess. Keeps `-- <anything>` passthrough intact
78    // under strict schemas.
79    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        // Non-strict schemas accept user-forwarded flags the author
90        // didn't declare — the binary re-parses the tail anyway.
91        // Strict schemas reject anything not declared.
92        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
105/// Validate the user's extra argv tail against a schema. Always called
106/// before `run::exec_command` — the parser's lenient-unknowns mode is
107/// keyed off `schema.strict` so choice validation on declared flags
108/// fires regardless, while unknown-flag rejection stays opt-in.
109///
110/// The tokenizer from [`crate::args::parser`] is reused so "did you
111/// mean" suggestions, cluster, and equals handling come for free.
112pub fn validate_tail(tail: &[String], schema: &Schema) -> Result<(), String> {
113    // Variant-shaped CLI (tree schema): resolve to the invoked subcommand's
114    // leaf, then validate the rest against it. The subcommand is `tail[0]`:
115    // globals are peeled by the fdl wrapper and a branch node declares no
116    // options of its own (a node is leaf XOR branch — see `Schema.commands`),
117    // so any correct invocation has the subcommand first. If the tail is empty
118    // (nothing typed yet) OR starts with a flag (a misplaced leaf option, or
119    // its value), we can't confidently identify the subcommand here: a naive
120    // scan for the first bare token would mistake `42` in `--seed 42 train`
121    // for the subcommand and emit a bogus "unknown command `42`". Defer to the
122    // binary's authoritative re-parse instead.
123    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
147/// Validate a single preset that's about to be invoked. Combines the
148/// always-on `choices:` check and, if `schema.strict`, the unknown-key
149/// rejection — scoped to just this preset, not the whole `commands:`
150/// map. Called from the exec path so typos in a sibling preset don't
151/// block `--help` for a correct one.
152pub 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
187/// Always-on: validate preset YAML `options:` values against declared
188/// `choices:` in the schema. An option YAML value whose key matches a
189/// declared option with a `choices:` list must be one of those choices.
190/// Keys not declared in the schema are ignored here — those are the
191/// concern of [`validate_presets_strict`] (opt-in).
192///
193/// Used for whole-map validation (e.g. from a future `fdl config lint`
194/// subcommand). The dispatch path uses [`validate_preset_for_exec`] so
195/// sibling-preset typos don't block correct invocations.
196pub 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; // unknown key — strict's problem, not ours
208            };
209            let Some(choices) = &opt.choices else {
210                continue; // no choices declared — anything goes
211            };
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
231/// Compare two JSON values for equality, treating YAML's loose-typed
232/// representation (a preset might write `batch-size: 32` as an int
233/// while the schema's choices list contains `"32"` as a string).
234fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
235    if a == b {
236        return true;
237    }
238    // Cross-type string ↔ number comparison for YAML-friendly matching.
239    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
254/// At load time, reject preset `options:` keys that are not declared in
255/// the enclosing schema. Runs only when `schema.strict == true`, and
256/// only against entries resolved to [`CommandKind::Preset`] — `run:` and
257/// `path:` kinds don't share the parent schema.
258pub 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
278// ── Merge ───────────────────────────────────────────────────────────────
279
280/// Merge the enclosing `CommandConfig` defaults with a named preset's
281/// overrides. Preset values win. Used when dispatching an inline preset
282/// command (neither `run` nor `path`).
283pub 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
292/// Resolved config from root defaults only (no job).
293pub 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
302/// Fully resolved configuration ready for arg translation.
303pub 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}