met 1.0.0-beta-preview

Metrale Engine: pure Rust LLM inference server (HTTP API, scheduler, TUI, CLI).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
// SPDX-License-Identifier: MIT OR Apache-2.0

//! 2026-09-26: Validation of `met serve` arguments that clap cannot do alone:
//! flag combinations that contradict each other, and the enumerated values.
//!
//! [`validate_serve_args`] reports every violation in one error, each with what
//! is wrong, why, and the fix. `serve` calls it before the model load, and a
//! model swap calls it before loading the next model.
//!
//! Owner: server CLI.
//! Invariants: `validate_serve_args` returns `Ok` only when no rule fired.

use super::ServeArgs;
// 2026-09-26: The value lists live in `flag_values`, which the dashboard's
// option picker also reads (`options_for_flag`).
use super::flag_values::{
    KvHighPrecisionLayers, LM_HEAD_DTYPES, MTP_GATES, MTP_QUANTS, SCHEDULER_CONFIGS, SCHEDULERS,
    SSM_H_DTYPES, TELEMETRY_LEVELS, TOOL_CALL_PARSERS, TRISTATES,
};

mod prompt_lookup;
mod tokenizer_trim;
pub(super) mod violation;
use violation::{Violation, check_enum, format_violations};

/// 2026-09-26: Validate a `met serve` command line.
///
/// # Errors
/// One formatted string listing every violation, so the operator can fix them
/// in one pass.
pub fn validate_serve_args(args: &ServeArgs) -> Result<(), String> {
    let mut v: Vec<Violation> = Vec::new();

    check_enum(
        &mut v,
        "--lm-head-dtype",
        &args.lm_head_dtype,
        LM_HEAD_DTYPES,
    );
    if let Some(dtype) = &args.ssm_h_dtype {
        check_enum(&mut v, "--ssm-h-dtype", dtype, SSM_H_DTYPES);
    }
    // 2026-09-26: Only when given: absent lets `METRALE_MTP_GATE_FORCE` decide.
    if let Some(gate) = &args.mtp_gate {
        check_enum(&mut v, "--mtp-gate", gate, MTP_GATES);
    }
    // 2026-09-28: The W4A4 lever belongs to the `nvfp4` tier.
    if let Err(e) = args.weight_quant_tier() {
        v.push(Violation::new(
            "--w4a4-downcast with --weight-quantization declared",
            e.to_string(),
            "add --weight-quantization nvfp4, or drop --w4a4-downcast",
        ));
    }
    // 2026-09-30: `--w4a4-downcast` picks activations by row count, so it is a lever of the
    // `adaptive` routing; a row-invariant family has its format from the flag itself.
    if args.w4a4_downcast {
        use metrale_config::{ActQuantFormat, ProjFamily};
        let fixed: Vec<&str> = [ProjFamily::Gdn, ProjFamily::Attn, ProjFamily::Ffn]
            .into_iter()
            .filter(|&f| {
                args.activation_quantization
                    .ladder(f)
                    .rungs()
                    .iter()
                    .any(|r| r.format != ActQuantFormat::Adaptive)
            })
            .map(ProjFamily::name)
            .collect();
        if !fixed.is_empty() {
            v.push(Violation::new(
                "--w4a4-downcast with a fixed --activation-quantization",
                format!(
                    "--w4a4-downcast chooses NVFP4 activations by row count, which is the \
                     `adaptive` routing; --activation-quantization {} fixes the format of {}",
                    args.activation_quantization,
                    fixed.join(", ")
                ),
                "add --activation-quantization adaptive, or drop --w4a4-downcast and name \
                 nvfp4 in the ladder (e.g. 1-32=nvfp4;33-=adaptive)",
            ));
        }
    }
    if args.no_canonical_tiers && !args.activation_quantization.is_adaptive() {
        v.push(Violation::new(
            "--no-canonical-tiers with a fixed --activation-quantization",
            format!(
                "--no-canonical-tiers picks W8A16 kernels by row count, which is the `adaptive` \
                 routing; --activation-quantization {} fixes a format",
                args.activation_quantization
            ),
            "add --activation-quantization adaptive, or drop --no-canonical-tiers",
        ));
    }
    for (flag, value) in [
        ("--ssm-batched-recurrent", &args.ssm_batched_recurrent),
        ("--content-loop-watchdog", &args.content_loop_watchdog),
        ("--tool-grammar", &args.tool_grammar),
    ] {
        check_enum(&mut v, flag, value, TRISTATES);
    }

    // 2026-09-26: `--hermetic` closes these channels, and the resolvers in
    // `cli::hermetic` would pick hermetic's answer without saying so. Refuse
    // the pair so the command line never names an override that is ignored.
    if args.hermetic && args.enable_prefix_caching {
        v.push(Violation::new(
            "--hermetic with --enable-prefix-caching",
            "--hermetic closes the radix KV prefix cache: it is keyed on token content \
             with no session component, so one request's KV blocks are reachable by any \
             later request sharing a prefix — exactly the cross-request channel a \
             known-answer test must not have",
            "drop --enable-prefix-caching (--hermetic already implies it is off), or drop \
             --hermetic if you meant to measure WITH the cache",
        ));
    }
    if args.hermetic && args.mtp_gate.as_deref() == Some("auto") {
        v.push(Violation::new(
            "--hermetic with --mtp-gate auto",
            "--hermetic pins the MTP gate to `force`, which disarms the arbiter. Under \
             `auto` the gate PROBES: its token counter is cumulative and is never reset \
             at a request boundary, so which request gets served by the serial arm — \
             which is not byte-equal to the batch arm even at temperature 0 — depends on \
             everything served before it",
            "drop --mtp-gate auto (--hermetic already forces the gate), or drop --hermetic",
        ));
    }
    // 2026-09-26: The FP16 h-state twins exist only on the fused-norm decode
    // arm; without it an FP32-only kernel reads the FP16 pool and produces
    // wrong output, not an error. An absent `--gdn-fused-norm` beside
    // `--ssm-h-dtype f16` publishes `fused_norm` off (`KernelFlagPlan`).
    // `h_f16` comes from `ssm_h_dtype_bits`, the decode the published cell
    // uses, so `f16-pool` binds every f16 rule below too.
    let h_f16 =
        metrale_model_layers::layers::qwen3_ssm::ssm_h_dtype_bits(args.ssm_h_dtype.as_deref()).0;
    if h_f16 && !args.gdn_fused_norm {
        v.push(Violation::new(
            "--ssm-h-dtype f16 without --gdn-fused-norm",
            "the FP16 h-state twins exist only on the fused-norm decode arm; the unfused \
             arms (gated_delta_rule_decode, ..._decode_f32_strided) are FP32-only and \
             would read the FP16 pool as FP32 — fluent garbage, not an error",
            "add --gdn-fused-norm, or use --ssm-h-dtype f32",
        ));
    }
    // 2026-09-26: The DFlash verify width is gamma + 1. Above
    // `MAX_F16_TWIN_DFLASH_GAMMA` it dispatches `gated_delta_rule_wy17`, which
    // has no FP16 h-state twin. Only an explicit `--dflash-gamma` is checked:
    // an unset gamma resolves through `default_dflash_gamma`, which clamps to
    // the same bound.
    if h_f16
        && args.dflash
        && args
            .dflash_gamma
            .is_some_and(|g| g > metrale_model_layers::layers::qwen3_ssm::MAX_F16_TWIN_DFLASH_GAMMA)
    {
        v.push(Violation::new(
            "--dflash with a verify width above the FP16 twin range, together with \
             --ssm-h-dtype f16",
            "DFlash verify widths above 16 dispatch gated_delta_rule_wy17, which has \
             no FP16 h-state twin — an FP32 kernel over an FP16 h-state emits fluent \
             garbage. Widths 5..16 are covered by the wyN _f16 twins",
            "use --dflash-gamma <= 15 (verify width 16), drop --dflash, or use \
             --ssm-h-dtype f32",
        ));
    }

    // 2026-09-26: The same `FromStr` that `publish_kernel_flags` publishes
    // through.
    if let Err(why) = args
        .ssm_rollback_mode
        .parse::<metrale_model_layers::ssm_reserve::SsmRollbackMode>()
    {
        v.push(Violation::new(
            format!(
                "--ssm-rollback-mode '{}' is not a valid value.",
                args.ssm_rollback_mode
            ),
            why,
            "use snapshot (default, wired) or replay (experimental scaffold).",
        ));
    }
    // 2026-09-26: The same parse that `publish_kernel_flags` publishes through.
    if let Err(why) =
        metrale_model_layers::ssm_reserve::parse_decode_ring_slots(&args.ssm_decode_ring_slots)
    {
        v.push(Violation::new(
            format!(
                "--ssm-decode-ring-slots '{}' is not a valid value.",
                args.ssm_decode_ring_slots
            ),
            why,
            "use auto (default: preflight sizes the ring from free memory) or 0..=8.",
        ));
    }
    check_enum(
        &mut v,
        "--mtp-quantization",
        &args.mtp_quantization,
        MTP_QUANTS,
    );
    check_enum(&mut v, "--scheduler", &args.scheduler, SCHEDULERS);
    check_enum(
        &mut v,
        "--scheduler-config",
        &args.scheduler_config,
        SCHEDULER_CONFIGS,
    );
    check_enum(&mut v, "--telemetry", &args.telemetry, TELEMETRY_LEVELS);
    if let Some(parser) = args.tool_call_parser.as_deref() {
        check_enum(&mut v, "--tool-call-parser", parser, TOOL_CALL_PARSERS);
    }
    // 2026-09-26: Parsed with `KvCacheDtype`'s own `FromStr`. Only an explicit
    // value can be checked here: an omitted flag resolves later against
    // MODEL.toml (`resolve_kv_dtype_str`). The build embeds MODEL.toml's
    // `default_kv_dtype` unchecked (`build_parse_behavior.rs`), so a bad value
    // there fails only at the same parse in `serve_phases/kv_cache.rs`, during
    // the model load.
    if let Some(kv_dtype) = args.kv_cache_dtype.as_deref()
        && kv_dtype
            .parse::<metrale_cache::kv_cache::KvCacheDtype>()
            .is_err()
    {
        v.push(Violation::new(
            format!("--kv-cache-dtype '{kv_dtype}' is not a known KV cache dtype."),
            "the value does not parse to any supported KV cache format.",
            "use one of: fp8, bf16, nvfp4 (or a turbo* TurboQuant-Plus variant).",
        ));
    }

    // 2026-09-26: The frozen FP8 KV scale is amax × headroom / 448
    // (`fp8_calibration.rs`), so a headroom below 1.0 clips the values it was
    // measured from.
    if args.fp8_kv_headroom < 1.0 {
        v.push(Violation::new(
            format!("--fp8-kv-headroom {} is below 1.0.", args.fp8_kv_headroom),
            "the frozen FP8 KV scale covers headroom× the calibration-window absmax; \
             a multiplier under 1.0 clips the very values it was measured from.",
            "use a value ≥ 1.0 (default 2.0).",
        ));
    }

    // 2026-09-26: Only `fp8` runs online FP8 KV calibration. Both flags must be
    // explicit: an omitted --kv-cache-dtype resolves against MODEL.toml later.
    if let (Some(calib), Some(kv_dtype)) = (
        args.fp8_kv_calibration_tokens,
        args.kv_cache_dtype.as_deref(),
    ) && calib > 0
        && kv_dtype != "fp8"
    {
        v.push(Violation::new(
            format!(
                "--fp8-kv-calibration-tokens {calib} has no effect with --kv-cache-dtype {kv_dtype}.",
            ),
            "online FP8 KV-scale calibration only feeds an FP8 KV cache; with a \
             bf16/nvfp4 cache the calibrated scales are never read.",
            "set --kv-cache-dtype fp8, or drop --fp8-kv-calibration-tokens (0 = off).",
        ));
    }
    // 2026-09-26: --kv-high-precision-layers over a bf16 cache is a no-op, and
    // is accepted: recipes pass `kv_cache_dtype: bf16` with
    // `kv_high_precision_layers: auto` (e.g. the fixture
    // `qwen3.6-35b-a3b-fp8-mtp.yaml`).

    if args.require_auth && args.auth_tokens_file.is_none() && args.auth_token.is_none() {
        v.push(Violation::new(
            "--require-auth is set but no bearer tokens were provided.",
            "with auth enforced and no tokens loaded, EVERY request is rejected 401.",
            "pass --auth-tokens-file <path> (preferred, 0600) or --auth-token <token>.",
        ));
    }

    prompt_lookup::check(args, &mut v);
    tokenizer_trim::check(args, &mut v);

    // 2026-10-02: The confidence stop shapes MTP draft chains, so it needs MTP;
    // DFlash drafts a whole block in one pass and has no chain to stop.
    if let Some(spec) = args.mtp_draft.draft_confidence_stop {
        let tau = spec.tau;
        if !(tau > 0.0 && tau < 1.0) {
            v.push(Violation::new(
                format!("--draft-confidence-stop {tau} is outside (0, 1)."),
                "TAU is a probability threshold: 0 would never stop a chain and 1 would \
                 stop every chain after its first draft.",
                "pass a value strictly between 0 and 1, or drop the flag.",
            ));
        }
        if !args.speculative || args.dflash {
            v.push(Violation::new(
                "--draft-confidence-stop needs --speculative (MTP) and is not used with --dflash.",
                "the stop ends MTP draft chains early; without MTP there is no chain, and a \
                 DFlash drafter proposes its whole block in one pass.",
                "add --speculative, or drop --draft-confidence-stop.",
            ));
        }
    }

    // 2026-10-03: A ratio outside 0..=1 would snap to an end bucket silently.
    if let Some(r) = args.mtp_shape.mtp_dcut_ratio
        && !(0.0..=1.0).contains(&r)
    {
        v.push(Violation::new(
            format!("--mtp-dcut-ratio {r} is outside 0..=1."),
            "the ratio is the fraction of prunable draft positions kept; values snap to \
             0.25, 0.5, 0.75 or 1.0.",
            "pass a value from 0 to 1 (1.0 keeps every draft), or drop the flag.",
        ));
    }

    crate::cli::validate_spec_cost::check(args, &mut v); // 2026-10-04: measured's checks

    // 2026-09-26: Only an explicit --num-drafts is checked: an omitted one
    // resolves against MODEL.toml later, and a model default without a
    // speculative method is not a user error.
    let any_spec = args.speculative || args.self_speculative || args.ngram_speculative;
    if let Some(num_drafts) = args.num_drafts
        && num_drafts > 1
        && !any_spec
    {
        // 2026-09-26: Under --dflash the draft count is the drafter's gamma
        // minus 1 (`serve_load`), so the flag is ignored there too; only the
        // remedy differs.
        if args.dflash {
            v.push(Violation::new(
                format!("--num-drafts {num_drafts} is ignored under --dflash."),
                "a DFlash serve drafts at the drafter checkpoint's trained block size \
                 (γ); the scheduler overrides --num-drafts with γ - 1.",
                "drop --num-drafts, or use --dflash-gamma to override the drafter's γ \
                 (block-diffusion drafters are trained at ONE block size — expect \
                 acceptance collapse away from it).",
            ));
        } else {
            v.push(Violation::new(
                format!("--num-drafts {num_drafts} is set but no speculative method is enabled.",),
                "the draft count only applies when speculative decoding proposes drafts; \
                 without it the flag is ignored.",
                "add --speculative (MTP), --self-speculative, or --ngram-speculative — or \
                 drop --num-drafts.",
            ));
        }
    }

    if args.disable_thinking && args.max_thinking_budget.is_some() {
        v.push(Violation::new(
            "--max-thinking-budget is set together with --disable-thinking.",
            "--disable-thinking strips reasoning entirely, so there is nothing for the \
             budget to cap.",
            "drop one: keep --disable-thinking for no reasoning, or drop it and keep the \
             budget to bound reasoning length.",
        ));
    }

    if args.rank >= args.world_size {
        v.push(Violation::new(
            format!(
                "--rank {} is out of range for --world-size {}.",
                args.rank, args.world_size
            ),
            "ranks are 0-indexed, so a valid rank is in 0..world_size.",
            format!(
                "set --rank in 0..={} (or raise --world-size).",
                args.world_size.saturating_sub(1)
            ),
        ));
    }
    if args.ep_size > args.world_size {
        v.push(Violation::new(
            format!(
                "--ep-size {} exceeds --world-size {}.",
                args.ep_size, args.world_size
            ),
            "expert parallelism cannot span more ranks than exist.",
            "raise --world-size to at least --ep-size, or lower --ep-size.",
        ));
    }
    // 2026-10-09: The expert layout's topology rule, before any weight loads.
    if let Err(e) = args
        .moe_expert_layout
        .0
        .check_topology(args.tp_size, args.ep_size)
    {
        v.push(Violation::new(
            format!(
                "--moe-expert-layout {} does not fit --tp-size {} --ep-size {}.",
                args.moe_expert_layout.0.name(),
                args.tp_size,
                args.ep_size
            ),
            format!("{e:#}"),
            "use --moe-expert-layout tp with --ep-size 1 and --tp-size equal to the rank \
             count, or --moe-expert-layout ep.",
        ));
    }
    if args.tp_size > args.world_size {
        v.push(Violation::new(
            format!(
                "--tp-size {} exceeds --world-size {}.",
                args.tp_size, args.world_size
            ),
            "tensor parallelism cannot span more ranks than exist.",
            "raise --world-size to at least --tp-size, or lower --tp-size.",
        ));
    }

    if !args.high_speed_swap {
        let mut orphaned: Vec<&str> = Vec::new();
        if args.high_speed_swap_dir.is_some() {
            orphaned.push("--high-speed-swap-dir");
        }
        if args.high_speed_swap_gb.is_some() {
            orphaned.push("--high-speed-swap-gb");
        }
        if args.high_speed_swap_resident_blocks.is_some() {
            orphaned.push("--high-speed-swap-resident-blocks");
        }
        if args.no_high_speed_swap_graph {
            orphaned.push("--no-high-speed-swap-graph");
        }
        if !orphaned.is_empty() {
            v.push(Violation::new(
                format!("{} set without --high-speed-swap.", orphaned.join(", ")),
                "high-speed-swap tuning options are ignored unless the feature is on.",
                "add --high-speed-swap, or drop the tuning option(s).",
            ));
        }
    }

    if !(args.gpu_memory_utilization > 0.0 && args.gpu_memory_utilization <= 1.0) {
        v.push(Violation::new(
            format!(
                "--gpu-memory-utilization {} is outside (0.0, 1.0].",
                args.gpu_memory_utilization
            ),
            "the value is the fraction of total GPU memory Metrale Engine may claim.",
            "use a fraction in (0.0, 1.0], e.g. 0.90.",
        ));
    }

    // 2026-09-26: The same `KvHighPrecisionLayers` parse the resolve site in
    // `serve_phases/kv_cache.rs` uses, run here before the weight load.
    if let Err(why) = args
        .kv_high_precision_layers
        .parse::<KvHighPrecisionLayers>()
    {
        v.push(Violation::new(
            format!(
                "--kv-high-precision-layers '{}' is not a valid value.",
                args.kv_high_precision_layers
            ),
            why,
            "use a layer count (0 = let the KV dtype decide), auto (2, recommended), \
             or max/all (every attention layer).",
        ));
    }

    // 2026-09-26: Nothing else reads `args.warmup_prompt`. The flag stays on the
    // CLI so that a copied command line gets this explanation rather than
    // clap's "unexpected argument".
    if args.warmup_prompt.is_some() {
        v.push(Violation::new(
            "--warmup-prompt is not implemented.",
            "nothing reads it: no prompt is tokenized, no prefill runs and nothing enters \
             the prefix cache, so the cold-start TTFT its help text promises to remove is \
             still paid on the first real request.",
            "drop --warmup-prompt, and warm the server yourself by sending one throwaway \
             request after startup — that populates the prefix cache through the same path \
             a real request does.",
        ));
    }

    if v.is_empty() {
        return Ok(());
    }
    Err(format_violations(&v))
}

#[cfg(test)]
#[path = "validate_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "validate_tests_b.rs"]
mod validate_tests_b;