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
6
7use super::cluster::{DdpConfig, OutputConfig, TrainingConfig};
8use super::schema::{
9    CommandConfig, CommandKind, CommandSpec, Schema,
10};
11
12/// Reserved flags that strict mode always tolerates in the user's tail.
13/// These are fdl-level universals (help/version) or opt-ins every
14/// FdlArgs-derived binary exposes (--fdl-schema) — keeping them out of
15/// the `schema.options` map means strict mode has to allowlist them
16/// separately or spuriously reject legal invocations.
17const STRICT_UNIVERSAL_LONGS: &[(&str, Option<char>, bool)] = &[
18    // (long, short, takes_value)
19    ("help", Some('h'), false),
20    ("version", Some('V'), false),
21    ("fdl-schema", None, false),
22    ("refresh-schema", None, false),
23];
24
25/// Convert a [`Schema`] into an [`ArgsSpec`](crate::args::parser::ArgsSpec) suitable for strict-mode
26/// tail validation. Positional `required` flags are intentionally
27/// dropped: the binary itself will enforce them after parsing, and
28/// treating them as required here would turn "missing positional" into
29/// a double-errored mess.
30pub 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            // Every value-taking option is allowed to appear bare in
44            // strict mode. fdl does not second-guess whether the binary
45            // would accept a bare `--foo`; that stays in the binary's
46            // court.
47            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    // Always-allowed universals — help/version/fdl-schema/refresh-schema
57    // are not in the user's schema but must not trigger "unknown flag".
58    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    // Positionals: drop the `required` bit. Strict mode is scoped to
70    // option names/values only; arity is the binary's concern.
71    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    // Catch-all so fdl-side validation never rejects positionals: like
85    // arity, positional binding is the binary's concern — its own parse
86    // errors loudly on excess. Keeps `-- <anything>` passthrough intact
87    // under strict schemas.
88    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        // Non-strict schemas accept user-forwarded flags the author
99        // didn't declare — the binary re-parses the tail anyway.
100        // Strict schemas reject anything not declared.
101        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
114/// Validate the user's extra argv tail against a schema. Always called
115/// before `run::exec_command` — the parser's lenient-unknowns mode is
116/// keyed off `schema.strict` so choice validation on declared flags
117/// fires regardless, while unknown-flag rejection stays opt-in.
118///
119/// The tokenizer from [`crate::args::parser`] is reused so "did you
120/// mean" suggestions, cluster, and equals handling come for free.
121pub fn validate_tail(tail: &[String], schema: &Schema) -> Result<(), String> {
122    // Variant-shaped CLI (tree schema): resolve to the invoked subcommand's
123    // leaf, then validate the rest against it. The subcommand is `tail[0]`:
124    // globals are peeled by the fdl wrapper and a branch node declares no
125    // options of its own (a node is leaf XOR branch — see `Schema.commands`),
126    // so any correct invocation has the subcommand first. If the tail is empty
127    // (nothing typed yet) OR starts with a flag (a misplaced leaf option, or
128    // its value), we can't confidently identify the subcommand here: a naive
129    // scan for the first bare token would mistake `42` in `--seed 42 train`
130    // for the subcommand and emit a bogus "unknown command `42`". Defer to the
131    // binary's authoritative re-parse instead.
132    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
156/// Validate a single preset that's about to be invoked. Combines the
157/// always-on `choices:` check and, if `schema.strict`, the unknown-key
158/// rejection — scoped to just this preset, not the whole `commands:`
159/// map. Called from the exec path so typos in a sibling preset don't
160/// block `--help` for a correct one.
161pub 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
196/// Always-on: validate preset YAML `options:` values against declared
197/// `choices:` in the schema. An option YAML value whose key matches a
198/// declared option with a `choices:` list must be one of those choices.
199/// Keys not declared in the schema are ignored here — those are the
200/// concern of [`validate_presets_strict`] (opt-in).
201///
202/// Used for whole-map validation (e.g. from a future `fdl config lint`
203/// subcommand). The dispatch path uses [`validate_preset_for_exec`] so
204/// sibling-preset typos don't block correct invocations.
205pub 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; // unknown key — strict's problem, not ours
217            };
218            let Some(choices) = &opt.choices else {
219                continue; // no choices declared — anything goes
220            };
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
240/// Compare two JSON values for equality, treating YAML's loose-typed
241/// representation (a preset might write `batch-size: 32` as an int
242/// while the schema's choices list contains `"32"` as a string).
243fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
244    if a == b {
245        return true;
246    }
247    // Cross-type string ↔ number comparison for YAML-friendly matching.
248    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
263/// At load time, reject preset `options:` keys that are not declared in
264/// the enclosing schema. Runs only when `schema.strict == true`, and
265/// only against entries resolved to [`CommandKind::Preset`] — `run:` and
266/// `path:` kinds don't share the parent schema.
267pub 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
287// ── Merge ───────────────────────────────────────────────────────────────
288
289/// Merge the enclosing `CommandConfig` defaults with a named preset's
290/// overrides. Preset values win. Used when dispatching an inline preset
291/// command (neither `run` nor `path`).
292pub 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
301/// Resolved config from root defaults only (no job).
302pub 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
311/// Fully resolved configuration ready for arg translation.
312pub 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}