use super::ServeArgs;
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};
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);
}
if let Some(gate) = &args.mtp_gate {
check_enum(&mut v, "--mtp-gate", gate, MTP_GATES);
}
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",
));
}
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);
}
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",
));
}
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",
));
}
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",
));
}
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).",
));
}
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);
}
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).",
));
}
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).",
));
}
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).",
));
}
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);
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.",
));
}
}
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);
let any_spec = args.speculative || args.self_speculative || args.ngram_speculative;
if let Some(num_drafts) = args.num_drafts
&& num_drafts > 1
&& !any_spec
{
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.",
));
}
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.",
));
}
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).",
));
}
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;