From f0f89b8d5e67a024ffc98418532303d794141211 Mon Sep 17 00:00:00 2001 From: Kaden Schutt <151092359+Kaden-Schutt@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:55:09 +0000 Subject: [PATCH 01/34] fix(bench): record prompt evidence and warn on short-prompt prefill The default ~24-token prompt reports prefill_tok_s ~= 363 tok/s while a 4.4k-token prompt on the same binary reports 886: the short-prompt number is launch overhead, not prefill, and the JSON gave no way to tell. - Add --prompt-file : prompt read verbatim (raw bytes, no trim), mutually exclusive with positional PROMPT words. - Standard-bench JSON gains top-level prompt_tokens (u64, as the daemon reports it in done.prompt_tokens), prompt_md5 (hex md5 of the exact prompt bytes), prompt_chars, and warnings[]; warn when prompt_tokens < 256 that prefill_tok_s measures launch overhead. - Same three values printed on the stderr banner next to model:/arch:. - No existing field renamed, nulled, or removed; default prompt bytes unchanged (audit 2026-09-02 fix slice: bench-prompt-evidence). --- AGENTS.md | 2 +- Cargo.lock | 1 + crates/hipfire-cli/Cargo.toml | 1 + crates/hipfire-cli/src/main.rs | 194 +++++++++++++++++++++++++++++++-- docs/CLI.md | 2 +- 5 files changed, 188 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e0288765..d71a4f00d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,8 +355,8 @@ hipfire bench --runs 5 --warmups 3 --max-tokens 128 --json | `--spec` | `off`/`dflash`/`mtp`/`ngram`/`dspark`/`auto` | | `--backend` | `noslots` (sequential daemon) / `slots` / `batch` / `both` | | `--workload` | `stateless` / `multiturn` / `both` | +| `--prompt-file PATH` | verbatim prompt bytes for the run; JSON records `prompt_tokens`/`prompt_md5`/`prompt_chars` plus a `warnings` caveat below 256 tokens | | `--kv-mode`, `--kv-backend` | KV format and allocator | -| `--reasoning-on` | off by default: a reasoning model cannot close `` inside the token budget, and the daemon fails that turn closed | Pin `--backend` and `--workload` explicitly for any A/B. The default is `both`, which measures two things at once and is not a comparison. diff --git a/Cargo.lock b/Cargo.lock index 4ed89d3d8..53e88308f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1313,6 +1313,7 @@ dependencies = [ "hyper", "hyper-util", "libc", + "md5", "saddle-core", "serde", "serde_json", diff --git a/crates/hipfire-cli/Cargo.toml b/crates/hipfire-cli/Cargo.toml index bbe77622f..b82cf94d3 100644 --- a/crates/hipfire-cli/Cargo.toml +++ b/crates/hipfire-cli/Cargo.toml @@ -19,6 +19,7 @@ http-body-util = "0.1" hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["tokio"] } libc = "0.2" +md5 = "0.8" hipfire-config = { path = "../hipfire-config" } hipfire-client = { path = "../hipfire-client" } hipfire-registry = { path = "../hipfire-registry" } diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index 86764ac22..249baa6b3 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -489,6 +489,10 @@ pub(crate) struct BenchArgs { /// Prompt words for the standard benchmark. #[arg(num_args = 0..)] prompt: Vec, + /// Read the standard-benchmark prompt verbatim from a file (raw bytes, + /// no trimming). Mutually exclusive with positional PROMPT words. + #[arg(long, conflicts_with = "prompt")] + prompt_file: Option, } #[derive(Args, Debug)] @@ -3615,6 +3619,53 @@ fn sample_stats(values: &[f64]) -> Option { }) } +/// Default standard-bench prompt. Historical numbers depend on its exact +/// bytes; do not change it. +const BENCH_DEFAULT_PROMPT: &str = "Explain the theory of general relativity in simple terms."; + +/// Below this tokenized prompt length the measured `prefill_tok_s` is launch +/// overhead, not prefill throughput (363 tok/s at ~24 tokens vs 886 at 4.4k +/// on a 7900 XTX with the same binary). +const BENCH_PREFILL_EVIDENCE_TOKENS: u64 = 256; + +/// Resolve the standard-benchmark prompt: `--prompt-file` reads the file +/// verbatim (raw bytes, no trimming — one newline can move τ by 17%), else +/// the positional words joined with spaces, else the historical default. +/// The clap `conflicts_with` on `--prompt-file` covers CLI parsing; the +/// explicit check here covers programmatically built args. +fn resolve_bench_prompt(args: &BenchArgs) -> Result { + if let Some(path) = args.prompt_file.as_deref() { + if !args.prompt.is_empty() { + bail!("--prompt-file cannot be combined with a positional prompt"); + } + let bytes = fs::read(path) + .with_context(|| format!("failed to read --prompt-file {}", path.display()))?; + return String::from_utf8(bytes) + .with_context(|| format!("--prompt-file {} is not valid UTF-8", path.display())); + } + Ok(if args.prompt.is_empty() { + BENCH_DEFAULT_PROMPT.to_owned() + } else { + args.prompt.join(" ") + }) +} + +/// Hex md5 of the exact prompt bytes sent, so two bench numbers are only +/// compared when their prompts are byte-identical. +fn bench_prompt_md5(prompt: &str) -> String { + format!("{:x}", md5::compute(prompt.as_bytes())) +} + +/// Short-prompt caveat: below 256 prompt tokens `prefill_tok_s` measures +/// launch overhead, not prefill throughput. +fn bench_prompt_warning(prompt_tokens: u64) -> Option { + (prompt_tokens < BENCH_PREFILL_EVIDENCE_TOKENS).then(|| { + format!( + "prompt is {prompt_tokens} tokens; prefill_tok_s at this length measures launch overhead, not prefill throughput — use --prompt-file with ≥256 tokens for a prefill number" + ) + }) +} + fn bench_command(paths: &Paths, args: BenchArgs) -> Result<()> { if args.runs == 0 { bail!("--runs must be positive"); @@ -3665,11 +3716,9 @@ fn bench_command(paths: &Paths, args: BenchArgs) -> Result<()> { return bench_experimental(paths, &args); } let (mut engine, loaded, pre_diag, post_diag) = open_bench_engine(paths, &args, None)?; - let prompt = if args.prompt.is_empty() { - "Explain the theory of general relativity in simple terms.".to_owned() - } else { - args.prompt.join(" ") - }; + let prompt = resolve_bench_prompt(&args)?; + let prompt_md5 = bench_prompt_md5(&prompt); + let prompt_chars = prompt.chars().count() as u64; eprintln!("hipfire bench"); eprintln!(" model: {}", args.model); eprintln!( @@ -3688,6 +3737,8 @@ fn bench_command(paths: &Paths, args: BenchArgs) -> Result<()> { ); eprintln!(" runs: {}", args.runs); eprintln!(" max_tokens: {}", args.max_tokens); + eprintln!(" prompt_md5: {prompt_md5}"); + eprintln!(" prompt_chars: {prompt_chars}"); if args.matrix || args.redline { bench_matrix(&mut engine, &args, &loaded, &post_diag) } else { @@ -3700,6 +3751,7 @@ fn bench_command(paths: &Paths, args: BenchArgs) -> Result<()> { let mut prefill = Vec::new(); let mut wall = Vec::new(); let mut ttft = Vec::new(); + let mut prompt_tokens: Option = None; for _ in 0..args.runs { let done = bench_generate_with_reasoning( &mut engine, @@ -3707,6 +3759,11 @@ fn bench_command(paths: &Paths, args: BenchArgs) -> Result<()> { args.max_tokens as u64, args.reasoning_on, )?; + // Every run uses the same prompt, so the daemon's tokenized + // prompt length is run-invariant; keep the first report. + if prompt_tokens.is_none() { + prompt_tokens = done.get("prompt_tokens").and_then(serde_json::Value::as_u64); + } if let Some(value) = done.get("decode_tok_s").and_then(serde_json::Value::as_f64) { decode.push(value); } @@ -3726,6 +3783,19 @@ fn bench_command(paths: &Paths, args: BenchArgs) -> Result<()> { std::io::stderr().flush()?; } eprintln!(); + // Below 256 prompt tokens prefill_tok_s is launch overhead, not + // prefill throughput — say so in the report instead of leaving a + // bare number that invites a wrong comparison. + let warnings: Vec = prompt_tokens + .and_then(bench_prompt_warning) + .into_iter() + .collect(); + if let Some(tokens) = prompt_tokens { + eprintln!(" prompt_tokens: {tokens}"); + } + for warning in &warnings { + eprintln!(" warning: {warning}"); + } let report = serde_json::json!({ "protocol": "native-generate-v1", "model": args.model, @@ -3735,6 +3805,10 @@ fn bench_command(paths: &Paths, args: BenchArgs) -> Result<()> { "max_tokens": args.max_tokens, "runs": args.runs, "batch": 1, + "prompt_tokens": prompt_tokens, + "prompt_md5": prompt_md5, + "prompt_chars": prompt_chars, + "warnings": warnings, "decode_tok_s": sample_stats(&decode), "prefill_tok_s": sample_stats(&prefill), "wall_tok_s": sample_stats(&wall), @@ -4225,11 +4299,7 @@ fn bench_experimental(paths: &Paths, args: &BenchArgs) -> Result<()> { bail!("--exp requires RDNA2 (gfx1030/gfx1031), detected {arch}"); } let _ = bench_generate(&mut engine, "Hello", 16)?; - let prompt = if args.prompt.is_empty() { - "Explain the theory of general relativity in simple terms.".to_owned() - } else { - args.prompt.join(" ") - }; + let prompt = resolve_bench_prompt(args)?; let mut samples = Vec::new(); for _ in 0..args.runs { let done = bench_generate(&mut engine, &prompt, 128)?; @@ -4285,6 +4355,7 @@ fn profile_command(paths: &Paths, args: ProfileArgs) -> Result<()> { backend: "both".to_owned(), workload: "both".to_owned(), prompt: Vec::new(), + prompt_file: None, }; let (mut engine, _, _, _) = open_bench_engine(paths, &bench, None)?; let _ = bench_generate(&mut engine, "Hello", 1)?; @@ -9044,6 +9115,109 @@ mod tests { assert_eq!(req.get("max_tokens").and_then(|v| v.as_u64()), Some(128)); } + fn bench_args_for_test(prompt: Vec, prompt_file: Option) -> BenchArgs { + BenchArgs { + model: "qwen:test".to_owned(), + runs: 1, + json: true, + exp: false, + matrix: false, + pp: vec![128], + ctx: vec![128], + tg: 128, + max_tokens: 128, + sustained_tg: None, + sustained_ctx: vec![128], + warmups: 1, + kv_mode: None, + kv_backend: None, + redline: false, + speculation: None, + reasoning_on: false, + concurrency: None, + backend: "both".to_owned(), + workload: "both".to_owned(), + prompt, + prompt_file, + } + } + + #[test] + fn bench_prompt_file_conflicts_with_positional_prompt() { + let err = Cli::try_parse_from([ + "hipfire", + "bench", + "qwen:test", + "--prompt-file", + "prompt.txt", + "hello", + ]) + .unwrap_err(); + assert!( + err.to_string().contains("--prompt-file"), + "conflict error should name the flag: {err}" + ); + } + + #[test] + fn bench_resolve_prompt_keeps_historical_default() { + let args = bench_args_for_test(Vec::new(), None); + assert_eq!( + resolve_bench_prompt(&args).unwrap(), + "Explain the theory of general relativity in simple terms." + ); + } + + #[test] + fn bench_resolve_prompt_joins_positional_words() { + let args = bench_args_for_test(vec!["hello".to_owned(), "world".to_owned()], None); + assert_eq!(resolve_bench_prompt(&args).unwrap(), "hello world"); + } + + #[test] + fn bench_resolve_prompt_file_is_verbatim() { + let path = std::env::temp_dir().join("hipfire-bench-prompt-verbatim.txt"); + // Trailing newline included: the file is read as raw bytes, never trimmed. + std::fs::write(&path, "repeat after me\n").unwrap(); + let args = bench_args_for_test(Vec::new(), Some(path.clone())); + let prompt = resolve_bench_prompt(&args).unwrap(); + std::fs::remove_file(&path).ok(); + assert_eq!(prompt, "repeat after me\n"); + assert_eq!( + bench_prompt_md5(&prompt), + format!("{:x}", md5::compute(b"repeat after me\n")) + ); + } + + #[test] + fn bench_resolve_prompt_rejects_file_and_positional() { + let args = bench_args_for_test( + vec!["hello".to_owned()], + Some(PathBuf::from("prompt.txt")), + ); + assert!(resolve_bench_prompt(&args).is_err()); + } + + #[test] + fn bench_prompt_warning_threshold() { + // The default short prompt must warn; 256+ tokens must not. + let short = bench_prompt_warning(24).expect("24 tokens must warn"); + assert!(short.contains("launch overhead"), "unexpected text: {short}"); + assert!(short.contains("24"), "warning should name the count: {short}"); + assert!(bench_prompt_warning(255).is_some()); + assert!(bench_prompt_warning(256).is_none()); + assert!(bench_prompt_warning(4400).is_none()); + } + + #[test] + fn bench_prompt_md5_is_hex_of_prompt_bytes() { + // md5("abc") is a fixed vector; guards against swapping in sha256. + assert_eq!( + bench_prompt_md5("abc"), + "900150983cd24fb0d6963f7d28e17f72" + ); + } + #[test] fn http_reasoning_nested_max_tokens_alias_resolves_cap_source() { let resolved = resolve(Vec::::new()).unwrap(); diff --git a/docs/CLI.md b/docs/CLI.md index 471fa9e1c..276351e57 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -121,7 +121,7 @@ Supported CLI formats include `mq4`, `mq6`, `q8`/`q8f16`, `hf4`/`hf6` and hfq al | Command | Purpose | |---|---| -| `hipfire bench [opts] [prompt]` | Prefill/decode timing. `--runs N` (default 5), `--json`, `--exp` (RDNA2 variant sweep). | +| `hipfire bench [opts] [prompt]` | Prefill/decode timing. `--runs N` (default 5), `--json`, `--exp` (RDNA2 variant sweep). `--prompt-file PATH` reads the prompt verbatim; JSON records `prompt_tokens`/`prompt_md5`/`prompt_chars`/`warnings` (short prompts warn that `prefill_tok_s` is launch overhead). | | `hipfire bench --matrix ...` | Synthetic PP/context/TG matrix (`--pp`, `--ctx`, `--tg`, `--sustained-tg`, `--sustained-ctx`, `--warmups`, `--kv-mode`, `--redline`). | | `hipfire profile [model] [--kernel substr] [--json]` | Live daemon roofline and compiled-kernel VGPR/SGPR/LDS/occupancy report. Use `hipfire-atlas` for measured ISA-fit and workload analysis. | | `hipfire diag` | Static device/runtime checks plus a live HIP arch, version, and VRAM probe when the daemon is available. | From 0f90851bac5e87494e1a45e42d70b5d957df34b5 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 00:58:40 +0000 Subject: [PATCH 02/34] style: rustfmt changed files --- crates/hipfire-cli/src/main.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index 249baa6b3..51d1b0294 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -3762,7 +3762,9 @@ fn bench_command(paths: &Paths, args: BenchArgs) -> Result<()> { // Every run uses the same prompt, so the daemon's tokenized // prompt length is run-invariant; keep the first report. if prompt_tokens.is_none() { - prompt_tokens = done.get("prompt_tokens").and_then(serde_json::Value::as_u64); + prompt_tokens = done + .get("prompt_tokens") + .and_then(serde_json::Value::as_u64); } if let Some(value) = done.get("decode_tok_s").and_then(serde_json::Value::as_f64) { decode.push(value); @@ -9191,10 +9193,7 @@ mod tests { #[test] fn bench_resolve_prompt_rejects_file_and_positional() { - let args = bench_args_for_test( - vec!["hello".to_owned()], - Some(PathBuf::from("prompt.txt")), - ); + let args = bench_args_for_test(vec!["hello".to_owned()], Some(PathBuf::from("prompt.txt"))); assert!(resolve_bench_prompt(&args).is_err()); } @@ -9202,8 +9201,14 @@ mod tests { fn bench_prompt_warning_threshold() { // The default short prompt must warn; 256+ tokens must not. let short = bench_prompt_warning(24).expect("24 tokens must warn"); - assert!(short.contains("launch overhead"), "unexpected text: {short}"); - assert!(short.contains("24"), "warning should name the count: {short}"); + assert!( + short.contains("launch overhead"), + "unexpected text: {short}" + ); + assert!( + short.contains("24"), + "warning should name the count: {short}" + ); assert!(bench_prompt_warning(255).is_some()); assert!(bench_prompt_warning(256).is_none()); assert!(bench_prompt_warning(4400).is_none()); @@ -9212,10 +9217,7 @@ mod tests { #[test] fn bench_prompt_md5_is_hex_of_prompt_bytes() { // md5("abc") is a fixed vector; guards against swapping in sha256. - assert_eq!( - bench_prompt_md5("abc"), - "900150983cd24fb0d6963f7d28e17f72" - ); + assert_eq!(bench_prompt_md5("abc"), "900150983cd24fb0d6963f7d28e17f72"); } #[test] From e68cf78bb9fe2922747b56dc26e8f43b5e26d37b Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:47:28 +0000 Subject: [PATCH 03/34] fix(bench): read prompt length from prefill_tokens + cached_tokens The daemon's done event has no prompt_tokens key; the prompt is prefill_tokens (rows prefilled) plus cached_tokens (prompt-cache prefix). Measured on a 7900 XTX the JSON reported prompt_tokens: null and never warned on the 24-token default prompt. --- crates/hipfire-cli/src/main.rs | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index 51d1b0294..38546e692 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -3666,6 +3666,18 @@ fn bench_prompt_warning(prompt_tokens: u64) -> Option { }) } +/// Prompt length as the daemon reports it on the `done` event: +/// `prefill_tokens` (rows actually prefilled) plus `cached_tokens` (prefix +/// served from the prompt cache). `None` when the event carries neither. +fn bench_prompt_tokens_from_done(done: &serde_json::Value) -> Option { + let prefill = done.get("prefill_tokens").and_then(serde_json::Value::as_u64); + let cached = done.get("cached_tokens").and_then(serde_json::Value::as_u64); + match (prefill, cached) { + (None, None) => None, + (p, c) => Some(p.unwrap_or(0) + c.unwrap_or(0)), + } +} + fn bench_command(paths: &Paths, args: BenchArgs) -> Result<()> { if args.runs == 0 { bail!("--runs must be positive"); @@ -3760,11 +3772,12 @@ fn bench_command(paths: &Paths, args: BenchArgs) -> Result<()> { args.reasoning_on, )?; // Every run uses the same prompt, so the daemon's tokenized - // prompt length is run-invariant; keep the first report. + // prompt length is run-invariant; keep the first report. The + // done event reports the prompt as `prefill_tokens` (rows the + // engine actually prefilled) plus `cached_tokens` (prefix served + // from the prompt cache); the prompt is their sum. if prompt_tokens.is_none() { - prompt_tokens = done - .get("prompt_tokens") - .and_then(serde_json::Value::as_u64); + prompt_tokens = bench_prompt_tokens_from_done(&done); } if let Some(value) = done.get("decode_tok_s").and_then(serde_json::Value::as_f64) { decode.push(value); @@ -9214,6 +9227,18 @@ mod tests { assert!(bench_prompt_warning(4400).is_none()); } + #[test] + fn bench_prompt_tokens_come_from_prefill_plus_cached() { + // The daemon's done event names the prompt as prefill_tokens (+ any + // prompt-cache hit in cached_tokens); there is no prompt_tokens key. + let done = serde_json::json!({"prefill_tokens": 4400, "cached_tokens": 8}); + assert_eq!(bench_prompt_tokens_from_done(&done), Some(4408)); + let no_cache = serde_json::json!({"prefill_tokens": 24}); + assert_eq!(bench_prompt_tokens_from_done(&no_cache), Some(24)); + let neither = serde_json::json!({"tokens": 128, "prompt_tokens": 99}); + assert_eq!(bench_prompt_tokens_from_done(&neither), None); + } + #[test] fn bench_prompt_md5_is_hex_of_prompt_bytes() { // md5("abc") is a fixed vector; guards against swapping in sha256. From f8ef51edff96377dfbb5cdaba311228a8eaaa86a Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:47:39 +0000 Subject: [PATCH 04/34] style: rustfmt changed files --- crates/hipfire-cli/src/main.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index 38546e692..0c9e0fc96 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -3670,8 +3670,12 @@ fn bench_prompt_warning(prompt_tokens: u64) -> Option { /// `prefill_tokens` (rows actually prefilled) plus `cached_tokens` (prefix /// served from the prompt cache). `None` when the event carries neither. fn bench_prompt_tokens_from_done(done: &serde_json::Value) -> Option { - let prefill = done.get("prefill_tokens").and_then(serde_json::Value::as_u64); - let cached = done.get("cached_tokens").and_then(serde_json::Value::as_u64); + let prefill = done + .get("prefill_tokens") + .and_then(serde_json::Value::as_u64); + let cached = done + .get("cached_tokens") + .and_then(serde_json::Value::as_u64); match (prefill, cached) { (None, None) => None, (p, c) => Some(p.unwrap_or(0) + c.unwrap_or(0)), From 4dc2234e789d58d73372c59c9d4062b8713b8573 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:56:28 +0000 Subject: [PATCH 05/34] chore: refresh crate maps (check-crate-maps --write) --- crates/hipfire-cli/map.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/hipfire-cli/map.md b/crates/hipfire-cli/map.md index d87967dec..1b7a0de3a 100644 --- a/crates/hipfire-cli/map.md +++ b/crates/hipfire-cli/map.md @@ -23,7 +23,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | File | Lines | Public items | Tests | |---|---:|---:|---:| | [`src/bench_concurrency.rs`](src/bench_concurrency.rs) | 720 | 21 | 9 | -| [`src/main.rs`](src/main.rs) | 9,692 | 0 | 65 | +| [`src/main.rs`](src/main.rs) | 9,897 | 0 | 73 | | [`src/serve/complete.rs`](src/serve/complete.rs) | 6,754 | 0 | 89 | | [`src/serve/http.rs`](src/serve/http.rs) | 1,089 | 0 | 6 | | [`src/serve/metrics.rs`](src/serve/metrics.rs) | 328 | 0 | 5 | @@ -43,7 +43,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Dependencies (from `Cargo.toml`) - path: `hipfire-client`, `hipfire-config`, `hipfire-registry`, `hipfire-runtime`, `saddle-core` -- external: `anyhow`, `bytes`, `clap`, `ctrlc`, `http-body-util`, `hyper`, `hyper-util`, `libc`, `serde`, `serde_json`, `sha2`, `tokio`, `tokio-util`, `ureq` +- external: `anyhow`, `bytes`, `clap`, `ctrlc`, `http-body-util`, `hyper`, `hyper-util`, `libc`, `md5`, `serde`, `serde_json`, `sha2`, `tokio`, `tokio-util`, `ureq` - dev: — - build: — @@ -53,6 +53,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 7 modules · 22,228 lines · 23 public items · 206 tests · 0 examples +- 7 modules · 22,433 lines · 23 public items · 214 tests · 0 examples From 8f3a9b6c58ef384d781ba20bdb2680aa2b338f0e Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Fri, 4 Sep 2026 10:11:41 +0000 Subject: [PATCH 06/34] docs(agents): restore the --reasoning-on row the --prompt-file hunk dropped hw-gate Fable seat on #689: the flag table lost its `--reasoning-on` row (base AGENTS.md:359) while the flag still exists in `hipfire bench --help`. Additive row only, as the PR body says. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index d71a4f00d..db50903f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -357,6 +357,7 @@ hipfire bench --runs 5 --warmups 3 --max-tokens 128 --json | `--workload` | `stateless` / `multiturn` / `both` | | `--prompt-file PATH` | verbatim prompt bytes for the run; JSON records `prompt_tokens`/`prompt_md5`/`prompt_chars` plus a `warnings` caveat below 256 tokens | | `--kv-mode`, `--kv-backend` | KV format and allocator | +| `--reasoning-on` | off by default: a reasoning model cannot close `` inside the token budget, and the daemon fails that turn closed | Pin `--backend` and `--workload` explicitly for any A/B. The default is `both`, which measures two things at once and is not a comparison. From 8bd5e343594ef380db9c431006f3e8880ad3f033 Mon Sep 17 00:00:00 2001 From: Kaden Schutt <151092359+Kaden-Schutt@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:56:17 +0000 Subject: [PATCH 07/34] fix(prefill): share MQ-V2 admit rule between llama and qwen35 gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llama::is_batchable_la admitted MQ4G256V2/MQ6/5/3/2G256V2 for WMMA prefill only on gfx1200/gfx1201 while qwen35::is_batchable_la admitted them on gfx11+gfx12 behind HIPFIRE_MQV2_GFX11_WMMA, so plain Llama/Qwen3 dense qt=44 models prefetched per-token on gfx1100/1151 while Qwen3.5/3.8 took WMMA — despite both doc-comments claiming an exact match (audit 2026-09-02 Broken 1). Move the dtype set + arch set + kill-switch helper into llama::mqv2_wmma_batchable / llama::mqv2_gfx11_wmma_enabled_from_env in hipfire-runtime and delegate from both callers, so the lockstep is structural. MQ4CG256 (qt=45) stays gfx12-only in both by intent. Tests: rename the two gfx12-only llama admit tests to gfx11+gfx12 expectations, repoint qwen35 env-escape test at the shared helper, and add mqv2_admit_llama_qwen35_lockstep asserting both gates agree over the MQ-V2 dtypes x {gfx1100, gfx1151, gfx1201, gfx1030, gfx1010}. --- .../hipfire-arch-qwen35/src/qwen35/prefill.rs | 87 +++++++------ crates/hipfire-runtime/src/llama.rs | 114 +++++++++++++----- 2 files changed, 135 insertions(+), 66 deletions(-) diff --git a/crates/hipfire-arch-qwen35/src/qwen35/prefill.rs b/crates/hipfire-arch-qwen35/src/qwen35/prefill.rs index 418e23b44..01489eac9 100644 --- a/crates/hipfire-arch-qwen35/src/qwen35/prefill.rs +++ b/crates/hipfire-arch-qwen35/src/qwen35/prefill.rs @@ -1543,17 +1543,14 @@ pub(crate) fn is_batchable_la(dt: DType, arch: &str) -> bool { // (gfx1100/1101/1102/1150/1151 + gfx1200/1201) but gate the gfx11 half // behind HIPFIRE_MQV2_GFX11_WMMA != "0" — setting // HIPFIRE_MQV2_GFX11_WMMA=0 restores the per-token fallback ONLY on - // gfx11, leaving gfx12 untouched. Lockstep with the HasWmma predicate - // on GemmMq*G256V2* keys and with gemm_mq*g256v2's has_wmma() guard. + // gfx11, leaving gfx12 untouched. Delegates to the shared + // `hipfire_runtime::llama::mqv2_wmma_batchable` rule so this stays in + // lockstep with `llama::is_batchable_la` structurally. Lockstep with + // the HasWmma predicate on GemmMq*G256V2* keys and with + // gemm_mq*g256v2's has_wmma() guard. // MQ4CG256 (qt45) remains gfx12-only until its gfx11 sibling lands. - let mqv2_with_wmma = matches!( + let mqv2_with_wmma = llama::mqv2_wmma_batchable( dt, - DType::MQ4G256V2 - | DType::MQ6G256V2 - | DType::MQ5G256V2 - | DType::MQ3G256V2 - | DType::MQ2G256V2 - ) && mqv2_gfx11_wmma_enabled_from_env( hipfire_config::developer_var("HIPFIRE_MQV2_GFX11_WMMA") .ok() .as_deref(), @@ -1582,24 +1579,6 @@ pub(crate) fn is_batchable_la(dt: DType, arch: &str) -> bool { || bf16_with_gfx942 } -/// Helper for MQ2/3/4/5/6G256V2 (qt44,47-50) batched prefill admit: gfx12 always, gfx11 -/// gated by HIPFIRE_MQV2_GFX11_WMMA != "0". Public for testability, mirrors -/// `mq6_batched_admit_enabled_from_env` / `q8_prefill_wmma_enabled_from_env`. -/// `value` is the raw env var (None = unset → default ON); only Some("0") -/// disables the gfx11 path. Gfx12 is unaffected by the env var. -pub(crate) fn mqv2_gfx11_wmma_enabled_from_env(value: Option<&str>, arch: &str) -> bool { - let gfx11_enabled = value != Some("0"); - if matches!(arch, "gfx1200" | "gfx1201") { - true - } else if matches!( - arch, - "gfx1100" | "gfx1101" | "gfx1102" | "gfx1150" | "gfx1151" - ) { - gfx11_enabled - } else { - false - } -} /// Single source of truth for per-layer batchability and checked geometry. /// Called by `validate_ep_batch_compatibility`, `prefill_batch_pbs_eligible`, /// `fa_batched_ok` guard, and later EP state preflight. Validates every @@ -8152,44 +8131,44 @@ mod tests { #[test] fn qwen35_is_batchable_la_mq4_v2_env_escape() { // HIPFIRE_MQV2_GFX11_WMMA=0 restores fallback ONLY on gfx11; gfx12 - // remains admitted. Use the helper directly to avoid global env + // remains admitted. Use the shared helper directly to avoid global env // mutation flakiness in parallel tests — is_batchable_la delegates - // to this helper verbatim. + // to `llama::mqv2_wmma_batchable`, which calls this helper verbatim. for arch in ["gfx1100", "gfx1101", "gfx1102", "gfx1150", "gfx1151"] { assert!( - !mqv2_gfx11_wmma_enabled_from_env(Some("0"), arch), + !llama::mqv2_gfx11_wmma_enabled_from_env(Some("0"), arch), "env=0 should disable {arch}" ); assert!( - mqv2_gfx11_wmma_enabled_from_env(None, arch), + llama::mqv2_gfx11_wmma_enabled_from_env(None, arch), "unset should enable {arch}" ); assert!( - mqv2_gfx11_wmma_enabled_from_env(Some("1"), arch), + llama::mqv2_gfx11_wmma_enabled_from_env(Some("1"), arch), "env=1 should enable {arch}" ); } for arch in ["gfx1200", "gfx1201"] { assert!( - mqv2_gfx11_wmma_enabled_from_env(Some("0"), arch), + llama::mqv2_gfx11_wmma_enabled_from_env(Some("0"), arch), "gfx12 unaffected by env=0 on {arch}" ); assert!( - mqv2_gfx11_wmma_enabled_from_env(None, arch), + llama::mqv2_gfx11_wmma_enabled_from_env(None, arch), "gfx12 enabled without env on {arch}" ); } for arch in ["gfx1010", "gfx942", "gfx1030", "gfx1103", "gfx1152"] { assert!( - !mqv2_gfx11_wmma_enabled_from_env(None, arch), + !llama::mqv2_gfx11_wmma_enabled_from_env(None, arch), "non-WMMA {arch} must never admit" ); assert!( - !mqv2_gfx11_wmma_enabled_from_env(Some("0"), arch), + !llama::mqv2_gfx11_wmma_enabled_from_env(Some("0"), arch), "non-WMMA {arch} with env=0" ); assert!( - !mqv2_gfx11_wmma_enabled_from_env(Some("1"), arch), + !llama::mqv2_gfx11_wmma_enabled_from_env(Some("1"), arch), "non-WMMA {arch} with env=1" ); } @@ -8241,6 +8220,40 @@ mod tests { assert_eq!(rdna_compute::MQ4V2_GROUP_BYTES, 136); } + #[test] + fn mqv2_admit_llama_qwen35_lockstep() { + // Audit 2026-09-02 Broken 1: `llama::is_batchable_la` admitted MQ-V2 + // only on gfx12 while this module admitted gfx11+gfx12, despite both + // doc-comments claiming an exact match. Both now delegate to the + // shared `llama::mqv2_wmma_batchable` rule; this test iterates the + // MQ-V2 dtypes over gfx11, gfx12, and pre-WMMA arches and asserts the + // two gates agree. Both read `HIPFIRE_MQV2_GFX11_WMMA` from the + // environment identically, so equality holds in any env state + // without mutating globals. + let dts = [ + DType::MQ4G256V2, + DType::MQ6G256V2, + DType::MQ5G256V2, + DType::MQ3G256V2, + DType::MQ2G256V2, + DType::MQ4CG256, + ]; + for dt in dts { + for arch in ["gfx1100", "gfx1151", "gfx1201", "gfx1030", "gfx1010"] { + assert_eq!( + llama::is_batchable_la(dt, arch), + is_batchable_la(dt, arch), + "lockstep drift for {dt:?} on {arch}" + ); + } + } + // Absolute pins so the test also fails if the shared rule itself + // regresses, not just on caller drift. + assert!(is_batchable_la(DType::MQ4G256V2, "gfx1201")); + assert!(!is_batchable_la(DType::MQ4G256V2, "gfx1030")); + assert!(!is_batchable_la(DType::MQ4CG256, "gfx1100")); + } + #[test] fn qwen35_v2_dense_keys_are_exact_no_hfq4_default() { // Contract: every admitted V2 dtype maps 1:1 to its exact V2 kernel diff --git a/crates/hipfire-runtime/src/llama.rs b/crates/hipfire-runtime/src/llama.rs index e9e909107..1251a95a8 100644 --- a/crates/hipfire-runtime/src/llama.rs +++ b/crates/hipfire-runtime/src/llama.rs @@ -1838,10 +1838,49 @@ pub fn prefill_forward( /// largest physical_cap any consumer sets up. pub const PREFILL_MAX_BATCH: usize = 256; +/// Kill-switch for the MQ-V2 (qt44 + neutral qt47-50) gfx11 WMMA prefill path: +/// gfx12 (`gfx1200`/`gfx1201`) is always admitted, gfx11 +/// (`gfx1100`/`gfx1101`/`gfx1102`/`gfx1150`/`gfx1151`) is admitted unless +/// `HIPFIRE_MQV2_GFX11_WMMA=0`, anything else is rejected. Single definition +/// shared by `llama::is_batchable_la` and `qwen35::is_batchable_la` so the two +/// stay in lockstep structurally instead of by matching comments. +/// `value` is the raw env var (None = unset → default ON); only `Some("0")` +/// disables the gfx11 path. Gfx12 is unaffected by the env var. +pub fn mqv2_gfx11_wmma_enabled_from_env(value: Option<&str>, arch: &str) -> bool { + let gfx11_enabled = value != Some("0"); + if matches!(arch, "gfx1200" | "gfx1201") { + true + } else if matches!( + arch, + "gfx1100" | "gfx1101" | "gfx1102" | "gfx1150" | "gfx1151" + ) { + gfx11_enabled + } else { + false + } +} + +/// Admit rule for the MQ-V2 family (`MQ4G256V2` + neutral `MQ6/5/3/2G256V2`) +/// in batched WMMA prefill: dtype set × arch set × the +/// `HIPFIRE_MQV2_GFX11_WMMA` kill-switch in one function. Both +/// `llama::is_batchable_la` and `qwen35::is_batchable_la` delegate here; +/// `MQ4CG256` (qt45) stays gfx12-only in both callers and is intentionally +/// NOT part of this rule. +pub fn mqv2_wmma_batchable(dt: DType, mqv2_gfx11_wmma: Option<&str>, arch: &str) -> bool { + matches!( + dt, + DType::MQ4G256V2 + | DType::MQ6G256V2 + | DType::MQ5G256V2 + | DType::MQ3G256V2 + | DType::MQ2G256V2 + ) && mqv2_gfx11_wmma_enabled_from_env(mqv2_gfx11_wmma, arch) +} + /// Is this dtype/arch combination eligible for the batched WMMA prefill -/// kernels? Matches `qwen35::is_batchable_la` exactly so plain Qwen3 and -/// hybrid Qwen3.5 share one rule and stay in lockstep when new dtypes or -/// arches gain WMMA support. +/// kernels? Shares the MQ-V2 admit rule with `qwen35::is_batchable_la` via +/// `mqv2_wmma_batchable`, so plain Qwen3 and hybrid Qwen3.5 stay in lockstep +/// structurally when new dtypes or arches gain WMMA support. pub fn is_batchable_la(dt: DType, arch: &str) -> bool { let always_ok = matches!( dt, @@ -1874,21 +1913,25 @@ pub fn is_batchable_la(dt: DType, arch: &str) -> bool { arch, "gfx1010" | "gfx1011" | "gfx1012" | "gfx1013" | "gfx1030" | "gfx1031" | "gfx1032" ); - // MQ4G256V2 / MQ4CG256 batched prefill + batched lm_head GEMM exist only - // on gfx12 (gfx1200/gfx1201). Outside gfx12, fall back to per-token decode - // rather than dispatching a gfx12 WMMA kernel. Lockstep with - // qwen35::is_batchable_la (qt44/qt45). - // Extended to neutral V2 family qt47-50. - let mq4_v2_gfx12 = matches!( + // MQ-V2 family (qt44 + neutral qt47-50) batched prefill + batched lm_head + // GEMM: gfx11 + gfx12 via the shared rule, with the gfx11 half behind the + // `HIPFIRE_MQV2_GFX11_WMMA=0` kill-switch. Delegates to + // `mqv2_wmma_batchable` so this stays in lockstep with + // `qwen35::is_batchable_la` structurally, not by matching comments. + // Outside the admitted arches, fall back to per-token decode rather than + // dispatching a foreign-arch WMMA kernel. + let mq4_v2 = mqv2_wmma_batchable( dt, - DType::MQ4G256V2 - | DType::MQ4CG256 - | DType::MQ6G256V2 - | DType::MQ5G256V2 - | DType::MQ3G256V2 - | DType::MQ2G256V2 - ) && matches!(arch, "gfx1200" | "gfx1201"); - wmma_only || mq3_gfx10_scalar || mq4_v2_gfx12 + hipfire_config::developer_var("HIPFIRE_MQV2_GFX11_WMMA") + .ok() + .as_deref(), + arch, + ); + // MQ4CG256 (qt45) remains gfx12-only until its gfx11 sibling lands — + // intentionally not part of the shared rule, in both callers. + let mq4cg256_gfx12 = + matches!(dt, DType::MQ4CG256) && matches!(arch, "gfx1200" | "gfx1201"); + wmma_only || mq3_gfx10_scalar || mq4_v2 || mq4cg256_gfx12 } /// Per-call scratch for `forward_prefill_batch`. Holds [N × ...] working @@ -8247,24 +8290,32 @@ mod tests { } #[test] - fn is_batchable_la_mq4_v2_gfx12_only() { - // MQ4G256V2 / MQ4CG256 batched prefill is gfx12-only; other arches - // fall back to per-token decode. - for arch in ["gfx1200", "gfx1201"] { + fn is_batchable_la_mq4_v2_gfx11_and_gfx12() { + // MQ4G256V2 batched prefill admits gfx11 + gfx12 through the shared + // `mqv2_wmma_batchable` rule (gfx11 behind HIPFIRE_MQV2_GFX11_WMMA); + // MQ4CG256 stays gfx12-only. Lockstep with + // `qwen35::is_batchable_la` by construction. + for arch in [ + "gfx1100", "gfx1101", "gfx1102", "gfx1150", "gfx1151", "gfx1200", "gfx1201", + ] { assert!( is_batchable_la(DType::MQ4G256V2, arch), "MQ4G256V2 should batch on {arch}" ); + } + for arch in ["gfx1200", "gfx1201"] { assert!( is_batchable_la(DType::MQ4CG256, arch), "MQ4CG256 should batch on {arch}" ); } - for arch in ["gfx1010", "gfx1100", "gfx942"] { + for arch in ["gfx1010", "gfx1030", "gfx942"] { assert!( !is_batchable_la(DType::MQ4G256V2, arch), "MQ4G256V2 must fall back on {arch}" ); + } + for arch in ["gfx1010", "gfx1100", "gfx1151", "gfx942"] { assert!( !is_batchable_la(DType::MQ4CG256, arch), "MQ4CG256 must fall back on {arch}" @@ -8273,14 +8324,19 @@ mod tests { } #[test] - fn is_batchable_la_v2_family_gfx12_only() { - for arch in ["gfx1200", "gfx1201"] { - assert!(is_batchable_la(DType::MQ6G256V2, arch), "MQ6V2 gfx12"); - assert!(is_batchable_la(DType::MQ5G256V2, arch), "MQ5V2 gfx12"); - assert!(is_batchable_la(DType::MQ3G256V2, arch), "MQ3V2 gfx12"); - assert!(is_batchable_la(DType::MQ2G256V2, arch), "MQ2V2 gfx12"); + fn is_batchable_la_v2_family_gfx11_and_gfx12() { + // Neutral V2 family (qt47-50) admits gfx11 + gfx12 through the shared + // `mqv2_wmma_batchable` rule, mirroring + // `qwen35_is_batchable_la_v2_family_gfx11_and_gfx12`. + for arch in [ + "gfx1100", "gfx1101", "gfx1102", "gfx1150", "gfx1151", "gfx1200", "gfx1201", + ] { + assert!(is_batchable_la(DType::MQ6G256V2, arch), "MQ6V2 on {arch}"); + assert!(is_batchable_la(DType::MQ5G256V2, arch), "MQ5V2 on {arch}"); + assert!(is_batchable_la(DType::MQ3G256V2, arch), "MQ3V2 on {arch}"); + assert!(is_batchable_la(DType::MQ2G256V2, arch), "MQ2V2 on {arch}"); } - for arch in ["gfx1010", "gfx1100", "gfx942"] { + for arch in ["gfx1010", "gfx1030", "gfx942"] { assert!(!is_batchable_la(DType::MQ6G256V2, arch), "MQ6V2 fallback"); assert!(!is_batchable_la(DType::MQ5G256V2, arch), "MQ5V2 fallback"); assert!(!is_batchable_la(DType::MQ3G256V2, arch), "MQ3V2 fallback"); From db9c1b2497fb0966fc05e1cfa6e9f2231b387dcf Mon Sep 17 00:00:00 2001 From: Kaden Schutt <151092359+Kaden-Schutt@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:01:27 +0000 Subject: [PATCH 08/34] fix(mq4v2): discriminating disjoint-halves GEMM parity arm + doc corrections mq4v2_gemm_parity's Gaussian weights give both halves near-identical headers, so a wrong half-select hides in quantization noise despite the header comment promising a systematic blow-up (audit 2026-09-02 Missing 1). Add arm 2 using the disjoint-halves construction from mq4v2_residual_parity (half0 [-1,1], half1 [96,160]) over the same batch-size sweep: v2 output must match the f32 reference within 5% rel-RMS, and the swapped-headers negative control (as in mq4v2_moe_parity) must DISAGREE. Keep the Gaussian v1-vs-v2 arm and fix the header comment to state what each arm can and cannot detect. Add a host-side test proving the fixture discriminates with no GPU. Docs (audit Would-change 1-2): spec section 9 now records MoE as production-wired for qt=44, the XBATCH single-row path as ported, and the gfx11 kt+=2 / residual kt++ stepping; residual_mmq.hip loses its stale Experimental tag; the gfx12 QKV kernel loses its HYPOTHESIS/scaffold wording for the validated C-map statement. --- .../examples/mq4v2_gemm_parity.rs | 181 ++++++++++++++++-- docs/quant-formats/mq4-v2.md | 11 +- kernels/src/gemm_mq4g256v2_residual_mmq.hip | 7 +- kernels/src/gemm_qkv_mq4g256v2_wmma.gfx12.hip | 38 ++-- 4 files changed, 193 insertions(+), 44 deletions(-) diff --git a/crates/hipfire-runtime/examples/mq4v2_gemm_parity.rs b/crates/hipfire-runtime/examples/mq4v2_gemm_parity.rs index 4a10c0461..3fbf3318e 100644 --- a/crates/hipfire-runtime/examples/mq4v2_gemm_parity.rs +++ b/crates/hipfire-runtime/examples/mq4v2_gemm_parity.rs @@ -1,4 +1,5 @@ -//! v1-vs-v2 cross-check for the **WMMA GEMM** path (qt=13 vs qt=44). +//! v1-vs-v2 cross-check for the **WMMA GEMM** path (qt=13 vs qt=44), +//! plus a discriminating disjoint-halves arm for the v2 residual path. //! //! `mq4v2_parity` verifies the decode GEMV against a host oracle. It does NOT //! cover the WMMA prefill GEMMs, which are what `--scoring-mode prefill` actually @@ -6,7 +7,27 @@ //! executing (8 v2 modules compiled), WT2 KLD came back 16.705139 against a //! 0.043776 baseline. //! -//! ## Why cross-check instead of a host reference +//! ## Arm 1: Gaussian v1-vs-v2 agreement — CANNOT catch a half-select bug +//! +//! Realistic post-FWHT weights (Gaussian, sigma ~0.011) give the two halves of +//! every group near-identical `(scale, zero)` headers, so a wrong half-select +//! predicate lands inside 4-bit quantization noise. This arm detects gross v2 +//! decode errors (agreement far above the quantization floor) but a wrong +//! predicate passes it silently. See arm 2 for the discriminating fixture. +//! +//! ## Arm 2: disjoint halves + negative control — CATCHES a half-select bug +//! +//! Same construction as `mq4v2_residual_parity.rs`: half 0 in `[-1, 1]`, half 1 +//! in `[96, 160]`, packed through the same fp16 round-trip. A kernel that +//! decodes half 1 with half 0's header reconstructs `~0` instead of `~128`, +//! so the v2 output is asserted against an exact-dequant f32 reference within +//! a tight tolerance (rel-RMS below 5%). The negative control — a reference +//! computed with the halves' headers swapped — must DISAGREE by an order of +//! magnitude more; if it ever agrees, the fixture has stopped separating the +//! halves and the arm is vacuous (same control as +//! `rdna-compute/examples/mq4v2_moe_parity.rs`). +//! +//! ## Why cross-check instead of a host reference (arm 1) //! //! Replicating a WMMA kernel on the host means reproducing fp16 activation //! conversion, 16x16 tiling, and accumulation order — a reference that is itself @@ -16,10 +37,11 @@ //! Both paths then share every stage except the 8 header bytes and their decode. //! v1 quantizes with one affine grid per 256 weights; v2 with one per 128. v2 is //! therefore slightly MORE accurate, so agreement should sit at the scale of -//! 4-bit quantization noise. A systematic blow-up isolates the v2 header decode — -//! in practice the half-select predicate, which the spec calls out as "the single -//! highest-risk detail in the port" because a wrong one "compiles, runs, and -//! silently applies the wrong scale to half of every tensor." +//! 4-bit quantization noise. A systematic blow-up isolates a gross v2 header +//! decode error — but NOT the half-select predicate, which the spec calls out as +//! "the single highest-risk detail in the port" because a wrong one "compiles, +//! runs, and silently applies the wrong scale to half of every tensor." Only +//! arm 2's disjoint fixture can see that failure. //! //! ## Why sweep batch size //! @@ -27,7 +49,7 @@ //! body by batch size and flags. Scoring compiled `_bt8` and `_bt12`, so the BT //! bodies are live — and BT is b-transposed, which changes the nibble addressing //! the half-select must be derived from. Sweeping batch size tells us WHICH body -//! is wrong rather than just that something is. +//! is wrong rather than just that something is. Both arms sweep. //! //! Run: `cargo run --release -p hipfire-runtime --example mq4v2_gemm_parity` @@ -51,6 +73,7 @@ fn prng(i: usize, salt: u32) -> f32 { /// Realistic post-FWHT weights: roughly Gaussian, sigma ~0.011 as measured on the /// Qwen3.8-27B parent. Deliberately NOT the disjoint-halves fixture -- here both /// containers must be individually reasonable so their outputs are comparable. +/// (The discriminating fixture is `build_disjoint_halves` below, used by arm 2.) fn build_weights(m: usize, k: usize) -> Vec { let mut w = vec![0.0f32; m * k]; for (i, v) in w.iter_mut().enumerate() { @@ -62,6 +85,43 @@ fn build_weights(m: usize, k: usize) -> Vec { w } +/// Discriminating fixture (same construction as `mq4v2_residual_parity.rs`): +/// half 0 in `[-1, 1]`, half 1 in `[96, 160]`. The two halves occupy disjoint +/// ranges, so a kernel that decodes half 1 with half 0's header reconstructs +/// `~0` instead of `~128` and fails by >100% relative error instead of hiding +/// inside quantization noise. +fn build_disjoint_halves(m: usize, k: usize) -> Vec { + let mut w = vec![0.0f32; m * k]; + for r in 0..m { + for c in 0..k { + let gi = c % GROUP; + let idx = r * k + c; + if gi < HALF { + // [-1, 1] + w[idx] = prng(idx, 0xA5A5_0001) * 2.0 - 1.0; + } else { + // [96, 160] — disjoint from half0 by two orders of magnitude + w[idx] = 96.0 + prng(idx, 0x5A5A_0002) * 64.0; + } + } + } + w +} + +/// Swap the two 4-byte half-headers of every group in a packed v2 blob. The +/// result decodes each half with the OTHER half's grid — the negative control: +/// a reference built from this blob must DISAGREE with the correct reference. +fn swap_v2_half_headers(blob: &[u8]) -> Vec { + let mut out = blob.to_vec(); + for chunk in out.chunks_exact_mut(GROUP_BYTES) { + let mut tmp = [0u8; 8]; + tmp.copy_from_slice(&chunk[0..8]); + chunk[0..4].copy_from_slice(&tmp[4..8]); + chunk[4..8].copy_from_slice(&tmp[0..4]); + } + out +} + /// qt=13 / HFQ4 container: `[0..4) f32 scale, [4..8) f32 zero` over all 256. fn pack_v1(w: &[f32], m: usize, k: usize) -> Vec { let gpr = k / GROUP; @@ -250,6 +310,57 @@ fn main() { } } + // ── Arm 2: disjoint halves + negative control (residual path) ────────── + // + // Arm 1's Gaussian weights cannot discriminate a wrong half-select; this + // arm can. Same batch-size sweep, same v2 residual WMMA kernel, but the + // weights put half 0 in [-1, 1] and half 1 in [96, 160], so decoding half + // 1 with half 0's header is a ~100x scale error. The v2 output must match + // the exact-dequant f32 reference within a tight tolerance, AND the + // swapped-headers reference must DISAGREE — otherwise the fixture is + // vacuous and the arm proves nothing. + { + let wd = build_disjoint_halves(m, k); + let bd = pack_v2(&wd, m, k); + let bd_swapped = swap_v2_half_headers(&bd); + for &batch in &[1usize, 8, 12, 16, 32] { + let x: Vec = (0..batch * k) + .map(|i| prng(i, 0xC0FF_EE00) * 2.0 - 1.0) + .collect(); + let want = ref_gemm(&bd, &x, m, k, batch, true); + let want_bug = ref_gemm(&bd_swapped, &x, m, k, batch, true); + let bug_rel = { + let bug_f32: Vec = want_bug.iter().map(|&v| v as f32).collect(); + rel_rms(&bug_f32, &want) + }; + // Host-side negative control: the swapped grid must be badly wrong + // before any GPU result is scored against it. + assert!( + bug_rel > 0.5, + "disjoint fixture not discriminating at batch {batch}: bug_rel {bug_rel:.3e} — halves overlap" + ); + let d_a = gpu.upload_raw(&bd, &[bd.len()]).unwrap(); + let d_x = gpu.upload_f32(&x, &[batch * k]).unwrap(); + let d_y = gpu.zeros(&[batch * m], rdna_compute::DType::F32).unwrap(); + gpu.gemm_hfq4g256_residual_wmma_gfx12_mq4v2(&d_a, &d_x, &d_y, m, k, batch) + .expect("v2 disjoint residual wmma launch"); + gpu.hip.device_synchronize().unwrap(); + let got = gpu.download_f32(&d_y).unwrap(); + let e = rel_rms(&got, &want); + let verdict = if e < 0.05 && e < bug_rel * 0.1 { + "ok" + } else { + "FAIL" + }; + eprintln!( + "disjoint batch {batch:>3}: v2 rel-rms {e:.4e} bug {bug_rel:.3e} {verdict}" + ); + if verdict == "FAIL" { + failures.push((3000 + batch, e, bug_rel)); + } + } + } + // ── The fused multi-output GEMMs ──────────────────────────────────────── // // These are the rest of the live v2 set. `gemm_qkvza` carries NINETEEN header @@ -356,19 +467,63 @@ fn main() { if failures.is_empty() { eprintln!( - "\nmq4v2_gemm_parity: PASS — every live v2 WMMA GEMM matches its own exact dequant" + "\nmq4v2_gemm_parity: PASS — every live v2 WMMA GEMM matches its own exact dequant, and the disjoint-halves residual arm is half-select correct" ); } else { eprintln!( "\nmq4v2_gemm_parity: FAIL — codes {:?}", failures.iter().map(|f| f.0).collect::>() ); - eprintln!("(1000 = gate_up, 2000 = qkvza, otherwise the residual batch size)"); + eprintln!("(1000 = gate_up, 2000 = qkvza, 3000+batch = disjoint-halves residual, otherwise the Gaussian residual batch size)"); eprintln!("The v1 row is the WMMA fp16 error floor; a v2 row far above it means that"); - eprintln!("kernel mis-decodes its own header. Each body has its OWN nibble addressing,"); - eprintln!( - "so its half-select predicate must be derived from that addressing, never copied." - ); + eprintln!("kernel mis-decodes its own header. Only the disjoint arm (3000+batch) can"); + eprintln!("see a half-select bug — the Gaussian arm hides one inside quantization noise."); + eprintln!("Each body has its OWN nibble addressing, so its half-select predicate must"); + eprintln!("be derived from that addressing, never copied."); std::process::exit(1); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disjoint_fixture_discriminates_half_select() { + // Host-side proof that arm 2's negative control is load-bearing: the + // halves occupy disjoint ranges, header-swapping is an involution, + // and a swapped-headers reference DISAGREES with the correct one. + // Runs with no GPU. + let (m, k, batch) = (16usize, 256usize, 4usize); + let w = build_disjoint_halves(m, k); + for r in 0..m { + for c in 0..k { + let v = w[r * k + c]; + if (c % GROUP) < HALF { + assert!((-1.0..=1.0).contains(&v), "half0 out of range: {v}"); + } else { + assert!((96.0..=160.0).contains(&v), "half1 out of range: {v}"); + } + } + } + let blob = pack_v2(&w, m, k); + let swapped = swap_v2_half_headers(&blob); + assert_ne!(swapped, blob, "swapping identical headers would be vacuous"); + assert_eq!( + swap_v2_half_headers(&swapped), + blob, + "header swap must be an involution" + ); + let x: Vec = (0..batch * k) + .map(|i| prng(i, 0xC0FF_EE00) * 2.0 - 1.0) + .collect(); + let want = ref_gemm(&blob, &x, m, k, batch, true); + let want_bug = ref_gemm(&swapped, &x, m, k, batch, true); + let bug_f32: Vec = want_bug.iter().map(|&v| v as f32).collect(); + let bug_rel = rel_rms(&bug_f32, &want); + assert!( + bug_rel > 0.5, + "swapped-headers reference must DISAGREE: bug_rel {bug_rel:.3e}" + ); + } +} diff --git a/docs/quant-formats/mq4-v2.md b/docs/quant-formats/mq4-v2.md index 7c5d25fbb..9374d3e0b 100644 --- a/docs/quant-formats/mq4-v2.md +++ b/docs/quant-formats/mq4-v2.md @@ -241,8 +241,11 @@ Measured across the 11 ported kernels, three distinct forms occur: | `kt < 8` | 8 | WMMA GEMM main + BT bodies | nibbles at `gp + 8 + kt*8 + k_grp*4` with `k_grp = tid>>4 ∈ {0,1}`; `kt*8 + 4 < 64` ⇔ `kt < 8` for both `k_grp` | | `quarter_in_group < 2` | 2 | WMMA `ldsstage` bodies | nibbles at `gp + 8 + quarter*32 + {0,8,16,24} + k_grp*4`; max offset at `quarter=1` is 60 < 64 | -In the WMMA main path the loop steps `kt += 4` over tiles `kt..kt+3`, so all four tiles in one -body share a half (kt=0,4 → half 0; kt=8,12 → half 1) and one select per body suffices. +The step varies by variant — gfx11 main bodies step `kt += 2` (K2 unroll; both tiles in a +pair share a half), the gfx11 residual body steps `kt++` (per-tile select), gfx12 bodies +step `kt += 4` over tiles `kt..kt+3` (all four tiles in one body share a half: kt=0,4 → +half 0; kt=8,12 → half 1) — but every body selects with `kt < 8`, so one select per +body suffices in all three forms. A wrong predicate here **compiles, runs, and silently applies the wrong scale to half of every tensor**. It is the single highest-risk detail in the port. For any kernel not in the 11, redo @@ -429,10 +432,10 @@ relatively less of the damage. "Codebooks are for the sub-4-bit tier" is defensi ### Not claimed / out of scope - **wave64** half-split (§ 4) — not verified; wave64 remains unsupported for these formats. -- **MoE** paths (`gemv_hfq4g256_moe_*`, `gemm_*_moe_grouped_*`) — out of scope / fail-closed for V2 product tiers. +- **MoE** paths (`gemv_mq4g256v2_moe_*`, `gemm_mq4g256v2_moe_grouped_*`) — production-wired for qt=44 (decode + prefill, gfx11 + gfx12; loader → `MoeResolution.routed_indexable_mq4v2` at `families/moe.rs:244` → `pipeline/mod.rs:1244-1262`). - **qt=45 on gfx11** — no gfx11 WMMA sibling; do not promote. - **gfx1030 default-R decision** (§ 6) — still open if/when gfx1030 ships these dtypes; not a dense-WMMA blocker. -- Research-only surfaces: `muse_*`, dp4a / cpol / `ldscoop` / `ldsx` / `.v1`–`.v5` / `XBATCH` single-row path. (gfx11 base/BT WMMA for **qt=44** is production, not research-only.) +- Research-only surfaces: `muse_*`, dp4a / cpol / `ldscoop` / `ldsx` / `.v1`–`.v5`. (The `XBATCH` single-row path IS ported for qt=44 — `gemv_mq4g256v2.hip:295-361` — and gfx11 base/BT WMMA for **qt=44** is production, not research-only.) ### Port surface that landed for dense HasWmma (qt=44) / dense gfx12 (qt=45) diff --git a/kernels/src/gemm_mq4g256v2_residual_mmq.hip b/kernels/src/gemm_mq4g256v2_residual_mmq.hip index 595eee5ff..43b3fcee4 100644 --- a/kernels/src/gemm_mq4g256v2_residual_mmq.hip +++ b/kernels/src/gemm_mq4g256v2_residual_mmq.hip @@ -7,7 +7,12 @@ #include #include -// Experimental HFQ4-G256 MMQ residual GEMM for RDNA3/RDNA3.5. +// Production MQ4G256V2 (qt=44) MMQ residual GEMM for gfx1100/gfx1151. +// +// Selected as the fast path when `batch >= 128 && batch % 128 == 0` +// (see `crates/rdna-compute/src/gemm.rs:17860-17909` and the qkv/qkvza/ +// gate_up/residual call sites); otherwise the WMMA residual path runs. +// Applies the scale per 128-K half (`kt < 8` select), not a per-256 V1-ism. // // This mirrors the important parts of llama.cpp's AMD MMQ path: // - pre-quantize the activation matrix into block_q8_1_mmq DS4 layout diff --git a/kernels/src/gemm_qkv_mq4g256v2_wmma.gfx12.hip b/kernels/src/gemm_qkv_mq4g256v2_wmma.gfx12.hip index 5959ae87f..8ec6bfa8d 100644 --- a/kernels/src/gemm_qkv_mq4g256v2_wmma.gfx12.hip +++ b/kernels/src/gemm_qkv_mq4g256v2_wmma.gfx12.hip @@ -12,17 +12,10 @@ // (which targets gfx11 / RDNA3). Compile this file with // hipcc --offload-arch=gfx1200 (or gfx1201, ...). // -// SCAFFOLD STATUS (2026-04-26): -// This file is NOT yet wired into dispatch.rs. It compiles for gfx12 -// targets and is intended as the canonical pattern reference for the -// five remaining gfx12 WMMA ports (qkvza-hfq4, gate_up-hfq4, plus -// the three hfq6 variants). Runtime correctness must be validated on -// real RDNA4 hardware (9070 XT / R9700) via test_kernels before any -// dispatch.rs site is allowed to route here. The C-output mapping -// below is a HYPOTHESIS derived from CK trait math and has NOT been -// confirmed against silicon — see the channel-test note at the -// write-back loop. See `.skills/hipfire-arch-port/` for the port -// workflow. +// STATUS: production gfx12 WMMA path, wired into dispatch. The C-output +// mapping below was validated 2026-04-27 in this file's channel-test on +// R9700 — the same map its `gemm_gate_up_mq4g256v2_wmma.gfx12.hip` sibling +// documents. // // Differences from the gfx11 kernel: // 1. WMMA builtin: __builtin_amdgcn_wmma_f32_16x16x16_f16_w32_gfx12 @@ -34,11 +27,12 @@ // lane (tid >> 4) = 0 -> carries K = [0..7] of the 16-K tile // lane (tid >> 4) = 1 -> carries K = [8..15] of the 16-K tile // Each lane therefore loads HALF as much per WMMA tile as gfx11. -// 4. C-output mapping (HYPOTHESIS — see below): -// gfx11 (validated, commit b7ac66a): -// acc[j] = C[2*j + (tid>>4)][tid & 15] -// gfx12 (UNVERIFIED, derived from CK kCM0/kCM1PerLane swap): -// acc[j] = C[8*(tid>>4) + j][tid & 15] +// 4. C-output mapping (validated 2026-04-27 in the QKV scaffold's +// channel-test on R9700): +// gfx11: acc[j] = C[2*j + (tid>>4)][tid & 15] (rows interleaved) +// gfx12: acc[j] = C[8*(tid>>4) + j][tid & 15] (rows contiguous — +// group 0: rows 0..7, +// group 1: rows 8..15) // // Reference (ROCm 7.x): // /opt/rocm/include/ck_tile/ops/gemm/warp/warp_gemm_attribute_wmma_impl_base_traits.hpp @@ -165,10 +159,8 @@ extern "C" __global__ void gemm_qkv_mq4g256v2_wmma_gfx12( } // --- Output --- - // - // gfx12 wave32 WMMA C-mapping HYPOTHESIS — DO NOT TRUST WITHOUT - // CHANNEL-TEST ON REAL HARDWARE. Derived from the CK trait swap - // (warp_gemm_attribute_wmma_impl_base_traits.hpp): + // gfx12 wave32 WMMA C-mapping (validated 2026-04-27 in this file's + // channel-test on R9700; same map the gate_up gfx12 sibling documents): // // gfx11: kCMLane=2, kCM0PerLane=8, kCM1PerLane=1 // -> acc[j] = C[2*j + (tid>>4)][tid & 15] (rows interleaved) @@ -176,12 +168,6 @@ extern "C" __global__ void gemm_qkv_mq4g256v2_wmma_gfx12( // -> acc[j] = C[8*(tid>>4) + j][tid & 15] (rows contiguous) // // i.e. lane group 0 holds output rows 0..7, lane group 1 rows 8..15. - // The gfx11 mapping was silently wrong for ~6 weeks before being fixed - // in commit b7ac66a — assume the same risk here. Validation recipe - // (from that fix): add `if (blockIdx.x == 0 && blockIdx.y == 0) - // printf("tid=%d j=%d row=%d col=%d acc=%f\\n", tid, j, out_row, - // out_col, acc[j]);` and compare against a CPU reference for a small - // golden case. Adjust the (j, k_grp) -> out_row formula until it matches. const int out_col = batch_start + m_lane; // batch index if (out_col < N) { #pragma unroll From 077aac54523a23b338abe8ca5836b77c81074399 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:02:22 +0000 Subject: [PATCH 09/34] style: rustfmt changed files --- crates/hipfire-runtime/src/llama.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/hipfire-runtime/src/llama.rs b/crates/hipfire-runtime/src/llama.rs index 1251a95a8..b16edb8e9 100644 --- a/crates/hipfire-runtime/src/llama.rs +++ b/crates/hipfire-runtime/src/llama.rs @@ -1929,8 +1929,7 @@ pub fn is_batchable_la(dt: DType, arch: &str) -> bool { ); // MQ4CG256 (qt45) remains gfx12-only until its gfx11 sibling lands — // intentionally not part of the shared rule, in both callers. - let mq4cg256_gfx12 = - matches!(dt, DType::MQ4CG256) && matches!(arch, "gfx1200" | "gfx1201"); + let mq4cg256_gfx12 = matches!(dt, DType::MQ4CG256) && matches!(arch, "gfx1200" | "gfx1201"); wmma_only || mq3_gfx10_scalar || mq4_v2 || mq4cg256_gfx12 } From 8d4450b18610a7c44a28f7974c0be47f60c11f37 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:56:24 +0000 Subject: [PATCH 10/34] chore: refresh crate maps (check-crate-maps --write) --- crates/hipfire-arch-qwen35/map.md | 4 ++-- crates/hipfire-runtime/map.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/hipfire-arch-qwen35/map.md b/crates/hipfire-arch-qwen35/map.md index 94a8f3efd..f27912845 100644 --- a/crates/hipfire-arch-qwen35/map.md +++ b/crates/hipfire-arch-qwen35/map.md @@ -42,7 +42,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | [`src/qwen35/ep_batch.rs`](src/qwen35/ep_batch.rs) | 4,800 | 20 | 7 | | [`src/qwen35/forward.rs`](src/qwen35/forward.rs) | 6,255 | 31 | 12 | | [`src/qwen35/load.rs`](src/qwen35/load.rs) | 4,906 | 10 | 0 | -| [`src/qwen35/prefill.rs`](src/qwen35/prefill.rs) | 9,312 | 11 | 48 | +| [`src/qwen35/prefill.rs`](src/qwen35/prefill.rs) | 9,325 | 11 | 49 | | [`src/qwen35/weights.rs`](src/qwen35/weights.rs) | 1,971 | 43 | 10 | | [`src/qwen35.rs`](src/qwen35.rs) | 63 | 7 | 0 | | [`src/scheduler.rs`](src/scheduler.rs) | 142 | 3 | 4 | @@ -97,6 +97,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 29 modules · 57,281 lines · 436 public items · 189 tests · 4 examples +- 29 modules · 57,294 lines · 436 public items · 190 tests · 4 examples diff --git a/crates/hipfire-runtime/map.md b/crates/hipfire-runtime/map.md index 1995fd991..1c8b741fe 100644 --- a/crates/hipfire-runtime/map.md +++ b/crates/hipfire-runtime/map.md @@ -53,7 +53,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | [`src/kv_backend.rs`](src/kv_backend.rs) | 129 | 1 | 7 | | [`src/kv_mode.rs`](src/kv_mode.rs) | 298 | 10 | 7 | | [`src/lib.rs`](src/lib.rs) | 80 | 55 | 0 | -| [`src/llama.rs`](src/llama.rs) | 8,738 | 83 | 42 | +| [`src/llama.rs`](src/llama.rs) | 8,793 | 85 | 42 | | [`src/llama_spec.rs`](src/llama_spec.rs) | 617 | 6 | 1 | | [`src/loader_api.rs`](src/loader_api.rs) | 256 | 10 | 4 | | [`src/loop_guard.rs`](src/loop_guard.rs) | 194 | 8 | 4 | @@ -114,7 +114,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside - [`src/kv_backend.rs`](src/kv_backend.rs): `saddle_core` - [`src/kv_mode.rs`](src/kv_mode.rs): `saddle_core`, `KvModePolicy`, `ResolveResult`, `QWEN35_HFQ_POLICY`, `QWEN35_PARO_POLICY`, `DIR_SAFETENSORS_POLICY`, `LLAMA_HFQ_POLICY`, `HFQ_Q8_ONLY_POLICY`, `QWEN35_PP_POLICY`, `resolve` - [`src/lib.rs`](src/lib.rs): `admission`, `arch`, `arch_mapping`, `arch_model`, `arch_spec`, `augmentor`, `bf16_loader`, `cache_plan`, `cask`, `config`, `cpu_router`, `ddtree`, +43 more -- [`src/llama.rs`](src/llama.rs): `ModelArch`, `LlamaConfig`, `from_gguf`, `dequantize_q4_0`, `dequantize_q8_0`, `f16_to_f32`, `f32_to_f16`, `dequantize_q4_k`, `convert_q4k_to_q4f16_g64`, `convert_q4k_to_q4f16_g32`, `dequantize_q6_k`, `ParoRotation`, +71 more +- [`src/llama.rs`](src/llama.rs): `ModelArch`, `LlamaConfig`, `from_gguf`, `dequantize_q4_0`, `dequantize_q8_0`, `f16_to_f32`, `f32_to_f16`, `dequantize_q4_k`, `convert_q4k_to_q4f16_g64`, `convert_q4k_to_q4f16_g32`, `dequantize_q6_k`, `ParoRotation`, +73 more - [`src/llama_spec.rs`](src/llama_spec.rs): `verify_block_argmax`, `verify_block_logits`, `verify_block_argmax_capture_gpu`, `verify_block_sampled_capture_gpu`, `verify_tree_logits`, `lm_head_logits_n_rows` - [`src/loader_api.rs`](src/loader_api.rs): `ModelSource`, `from_path`, `arch_id`, `is_dir`, `describe`, `LoadCtx`, `SpecLoadCfg`, `CaskConfig`, `physical_cap`, `physical_cap_with_override` - [`src/loop_guard.rs`](src/loop_guard.rs): `StopReason`, `LoopGuard`, `from_config`, `new`, `off`, `enabled`, `check`, `window_len` @@ -156,6 +156,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 58 modules · 53,079 lines · 893 public items · 617 tests · 132 examples +- 58 modules · 53,134 lines · 895 public items · 617 tests · 132 examples From d0961475f24c654d27b2862d5d507181cafe63a5 Mon Sep 17 00:00:00 2001 From: Kaden Schutt <151092359+Kaden-Schutt@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:07:50 +0000 Subject: [PATCH 11/34] fix(dflash): make draft ctors transactional on partial failure DflashScratch::new_with_mq, new_windowed, DflashWeights::load, and build_generic_dflash_speculator could '?' out mid-construction, leaking earlier alloc_tensor results (GpuTensor/DeviceBuffer have no Drop). Record each allocation in a slot vec (gt!/wt!/at!) taken once into the final owner; the error arm frees completed layers plus staged slots. new_windowed frees the base scratch via alloc_or_free!; the generic builder frees weights (and scratch) on later failures. Success path is byte-identical. Mirrors load_dflash_state's or_free! (audit-Dflash Broken 3). --- crates/hipfire-runtime/src/dflash.rs | 571 +++++++++++++------ crates/hipfire-runtime/src/dflash_generic.rs | 25 +- 2 files changed, 402 insertions(+), 194 deletions(-) diff --git a/crates/hipfire-runtime/src/dflash.rs b/crates/hipfire-runtime/src/dflash.rs index 1f7ce9ad3..ec3ee164c 100644 --- a/crates/hipfire-runtime/src/dflash.rs +++ b/crates/hipfire-runtime/src/dflash.rs @@ -617,6 +617,39 @@ fn hfq_weight( Ok(wt) } +impl DflashLayerWeights { + /// Consume one layer's weights, releasing every GPU tensor including the + /// AWQ/paro sidecars (`free_all`, not `.buf` — an AWQ-trunk drafter + /// carries one scale tensor per weight per layer). Shared by the + /// success-path `DflashWeights::free_gpu` and the `load` error path so a + /// failed load frees completed layers without duplicating this list. + fn free_gpu(self, gpu: &mut Gpu) { + let _ = gpu.free_tensor(self.attn_norm); + self.wq.free_all(gpu); + self.wk.free_all(gpu); + self.wv.free_all(gpu); + self.wo.free_all(gpu); + let _ = gpu.free_tensor(self.q_norm); + let _ = gpu.free_tensor(self.k_norm); + let _ = gpu.free_tensor(self.ffn_norm); + self.w_gate.free_all(gpu); + self.w_up.free_all(gpu); + self.w_down.free_all(gpu); + if let Some(t) = self.attn_conv_base { + let _ = gpu.free_tensor(t); + } + if let Some(w) = self.attn_conv_proj { + w.free_all(gpu); + } + if let Some(t) = self.mlp_conv_base { + let _ = gpu.free_tensor(t); + } + if let Some(w) = self.mlp_conv_proj { + w.free_all(gpu); + } + } +} + impl DflashWeights { /// True when the selector (candidate proposal) path is available. pub fn has_candidate_selector(&self) -> bool { @@ -625,22 +658,97 @@ impl DflashWeights { && self.successor_codebook.is_some() } pub fn load(gpu: &mut Gpu, hfq: &HfqFile, cfg: &DflashConfig) -> HipResult { - let fc = hfq_weight( + // Transactional construction. Every GPU tensor allocated below is + // recorded in `live_t` (plain F32) / `live_w` (weight + sidecars) and + // taken exactly once into its final owner; completed layers accumulate + // in `layers`. Any failure frees the completed layers plus every + // recorded-but-unplaced tensor before returning Err — a bare `?` + // would leak them (`GpuTensor`/`DeviceBuffer` have no `Drop`). The + // leaf loaders (`hfq_weight`, `hfq_tensor_f32`) are single-alloc and + // need no cover. Mirrors the `or_free!` style in + // `hipfire-arch-qwen35`'s `load_dflash_state`. + let mut live_t: Vec> = Vec::new(); + let mut live_w: Vec> = Vec::new(); + let mut layers: Vec = Vec::with_capacity(cfg.n_layers); + macro_rules! gt { + ($e:expr) => {{ + match $e { + Ok(t) => { + live_t.push(Some(t)); + live_t.len() - 1 + } + Err(e) => { + for l in layers.drain(..) { + l.free_gpu(gpu); + } + for slot in live_w.iter_mut() { + if let Some(w) = slot.take() { + w.free_all(gpu); + } + } + for slot in live_t.iter_mut() { + if let Some(t) = slot.take() { + let _ = gpu.free_tensor(t); + } + } + return Err(e); + } + } + }}; + } + macro_rules! wt { + ($e:expr) => {{ + match $e { + Ok(w) => { + live_w.push(Some(w)); + live_w.len() - 1 + } + Err(e) => { + for l in layers.drain(..) { + l.free_gpu(gpu); + } + for slot in live_w.iter_mut() { + if let Some(w) = slot.take() { + w.free_all(gpu); + } + } + for slot in live_t.iter_mut() { + if let Some(t) = slot.take() { + let _ = gpu.free_tensor(t); + } + } + return Err(e); + } + } + }}; + } + macro_rules! take_t { + ($i:expr) => { + live_t[$i].take().expect("dflash load: F32 slot taken twice") + }; + } + macro_rules! take_w { + ($i:expr) => { + live_w[$i].take().expect("dflash load: weight slot taken twice") + }; + } + let i_fc = wt!(hfq_weight( hfq, gpu, "fc.weight", cfg.hidden, cfg.num_extract() * cfg.hidden, - )?; - let hidden_norm = hfq_tensor_f32(hfq, gpu, "hidden_norm.weight", vec![cfg.hidden])?; - let norm = hfq_tensor_f32(hfq, gpu, "norm.weight", vec![cfg.hidden])?; + )); + let i_hidden_norm = + gt!(hfq_tensor_f32(hfq, gpu, "hidden_norm.weight", vec![cfg.hidden])); + let i_norm = gt!(hfq_tensor_f32(hfq, gpu, "norm.weight", vec![cfg.hidden])); let conv_k = cfg.conv_kernel_size.unwrap_or(2); let conv_g = cfg.conv_group_size.unwrap_or(16); let conv_groups = cfg.hidden / conv_g; let proj_m = 2 * conv_k * conv_groups; - let mut layers = Vec::with_capacity(cfg.n_layers); + // (`layers` is declared above so the `gt!`/`wt!` error arms can free it.) for i in 0..cfg.n_layers { let p = format!("layers.{i}"); // Attempt DFlash2 conv weights; absent on legacy drafts. @@ -656,12 +764,12 @@ impl DflashWeights { }; if hfq.tensor_data(&key).is_some() { // shape 2*K*H - Some(hfq_tensor_f32( + Some(gt!(hfq_tensor_f32( hfq, gpu, &key, vec![2 * conv_k * cfg.hidden], - )?) + ))) } else { None } @@ -677,7 +785,7 @@ impl DflashWeights { alt }; if hfq.tensor_data(&key).is_some() { - Some(hfq_weight(hfq, gpu, &key, proj_m, cfg.hidden)?) + Some(wt!(hfq_weight(hfq, gpu, &key, proj_m, cfg.hidden))) } else { None } @@ -693,12 +801,12 @@ impl DflashWeights { alt }; if hfq.tensor_data(&key).is_some() { - Some(hfq_tensor_f32( + Some(gt!(hfq_tensor_f32( hfq, gpu, &key, vec![2 * conv_k * cfg.hidden], - )?) + ))) } else { None } @@ -714,110 +822,125 @@ impl DflashWeights { alt }; if hfq.tensor_data(&key).is_some() { - Some(hfq_weight(hfq, gpu, &key, proj_m, cfg.hidden)?) + Some(wt!(hfq_weight(hfq, gpu, &key, proj_m, cfg.hidden))) } else { None } } else { None }; - let layer = DflashLayerWeights { - attn_norm: hfq_tensor_f32( - hfq, - gpu, - &format!("{p}.input_layernorm.weight"), - vec![cfg.hidden], - )?, - wq: hfq_weight( - hfq, - gpu, - &format!("{p}.self_attn.q_proj.weight"), - cfg.q_dim(), - cfg.hidden, - )?, - wk: hfq_weight( - hfq, - gpu, - &format!("{p}.self_attn.k_proj.weight"), - cfg.kv_dim(), - cfg.hidden, - )?, - wv: hfq_weight( - hfq, - gpu, - &format!("{p}.self_attn.v_proj.weight"), - cfg.kv_dim(), - cfg.hidden, - )?, - wo: hfq_weight( - hfq, - gpu, - &format!("{p}.self_attn.o_proj.weight"), - cfg.hidden, - cfg.q_dim(), - )?, - q_norm: hfq_tensor_f32( - hfq, - gpu, - &format!("{p}.self_attn.q_norm.weight"), - vec![cfg.head_dim], - )?, - k_norm: hfq_tensor_f32( - hfq, - gpu, - &format!("{p}.self_attn.k_norm.weight"), - vec![cfg.head_dim], - )?, - ffn_norm: hfq_tensor_f32( - hfq, - gpu, - &format!("{p}.post_attention_layernorm.weight"), - vec![cfg.hidden], - )?, - w_gate: hfq_weight( - hfq, - gpu, - &format!("{p}.mlp.gate_proj.weight"), - cfg.intermediate, - cfg.hidden, - )?, - w_up: hfq_weight( - hfq, - gpu, - &format!("{p}.mlp.up_proj.weight"), - cfg.intermediate, - cfg.hidden, - )?, - w_down: hfq_weight( - hfq, - gpu, - &format!("{p}.mlp.down_proj.weight"), - cfg.hidden, - cfg.intermediate, - )?, - attn_conv_base, - attn_conv_proj, - mlp_conv_base, - mlp_conv_proj, - }; - layers.push(layer); + // Stage every field as a slot index first: a failure below frees + // the staged slots (plus completed layers) via `gt!`/`wt!`, and + // the `take_*!` push itself is infallible. + let i_attn_norm = gt!(hfq_tensor_f32( + hfq, + gpu, + &format!("{p}.input_layernorm.weight"), + vec![cfg.hidden], + )); + let i_wq = wt!(hfq_weight( + hfq, + gpu, + &format!("{p}.self_attn.q_proj.weight"), + cfg.q_dim(), + cfg.hidden, + )); + let i_wk = wt!(hfq_weight( + hfq, + gpu, + &format!("{p}.self_attn.k_proj.weight"), + cfg.kv_dim(), + cfg.hidden, + )); + let i_wv = wt!(hfq_weight( + hfq, + gpu, + &format!("{p}.self_attn.v_proj.weight"), + cfg.kv_dim(), + cfg.hidden, + )); + let i_wo = wt!(hfq_weight( + hfq, + gpu, + &format!("{p}.self_attn.o_proj.weight"), + cfg.hidden, + cfg.q_dim(), + )); + let i_q_norm = gt!(hfq_tensor_f32( + hfq, + gpu, + &format!("{p}.self_attn.q_norm.weight"), + vec![cfg.head_dim], + )); + let i_k_norm = gt!(hfq_tensor_f32( + hfq, + gpu, + &format!("{p}.self_attn.k_norm.weight"), + vec![cfg.head_dim], + )); + let i_ffn_norm = gt!(hfq_tensor_f32( + hfq, + gpu, + &format!("{p}.post_attention_layernorm.weight"), + vec![cfg.hidden], + )); + let i_w_gate = wt!(hfq_weight( + hfq, + gpu, + &format!("{p}.mlp.gate_proj.weight"), + cfg.intermediate, + cfg.hidden, + )); + let i_w_up = wt!(hfq_weight( + hfq, + gpu, + &format!("{p}.mlp.up_proj.weight"), + cfg.intermediate, + cfg.hidden, + )); + let i_w_down = wt!(hfq_weight( + hfq, + gpu, + &format!("{p}.mlp.down_proj.weight"), + cfg.hidden, + cfg.intermediate, + )); + layers.push(DflashLayerWeights { + attn_norm: take_t!(i_attn_norm), + wq: take_w!(i_wq), + wk: take_w!(i_wk), + wv: take_w!(i_wv), + wo: take_w!(i_wo), + q_norm: take_t!(i_q_norm), + k_norm: take_t!(i_k_norm), + ffn_norm: take_t!(i_ffn_norm), + w_gate: take_w!(i_w_gate), + w_up: take_w!(i_w_up), + w_down: take_w!(i_w_down), + attn_conv_base: attn_conv_base.map(|j| take_t!(j)), + attn_conv_proj: attn_conv_proj.map(|j| take_w!(j)), + mlp_conv_base: mlp_conv_base.map(|j| take_t!(j)), + mlp_conv_proj: mlp_conv_proj.map(|j| take_w!(j)), + }); } // Selector: hidden_projection [rank, hidden] + two codebooks [vocab, rank] host-side // Exact HFQ names are `candidate_selector.hidden_projection.weight`, // `candidate_selector.predecessor_codebook`, `candidate_selector.successor_codebook`. // Optional fallback `.weight` suffix is tolerated but not required. - let selector_hidden_proj = if cfg.selector_rank.is_some() { + // Slot index (`take_w!`n below); the codebooks between here and the + // take are host-side only and cannot fail with GPU memory held. + let i_selector_hidden_proj = if cfg.selector_rank.is_some() { let rank = cfg.selector_rank.unwrap(); let candidates = [ "candidate_selector.hidden_projection.weight", "selector.hidden_projection.weight", "selector.hidden_proj.weight", ]; - let mut found = None; + let mut found: Option = None; for n in candidates { if hfq.tensor_data(n).is_some() { - found = Some(hfq_weight(hfq, gpu, n, rank, cfg.hidden)?); + found = Some(wt!(hfq_weight(hfq, gpu, n, rank, cfg.hidden))); break; } } @@ -896,6 +1019,12 @@ impl DflashWeights { (None, None, None, None) }; + let fc = take_w!(i_fc); + let hidden_norm = take_t!(i_hidden_norm); + let norm = take_t!(i_norm); + let selector_hidden_proj = i_selector_hidden_proj.map(|j| take_w!(j)); + debug_assert!(live_t.iter().all(|s| s.is_none())); + debug_assert!(live_w.iter().all(|s| s.is_none())); let has_mq = std::iter::once(&fc) .chain(layers.iter().flat_map(|l| { let mut v: Vec<&WeightTensor> = @@ -924,8 +1053,20 @@ impl DflashWeights { }); if has_mq { // MQ dispatch needs the engine's FWHT sign tables uploaded - // (matches `gemv_mq4g256_with_rotate`'s setup). - gpu.ensure_mq_signs()?; + // (matches `gemv_mq4g256_with_rotate`'s setup). A failure here + // must still release the weights above before returning Err. + if let Err(e) = gpu.ensure_mq_signs() { + fc.free_all(gpu); + let _ = gpu.free_tensor(hidden_norm); + let _ = gpu.free_tensor(norm); + for l in layers { + l.free_gpu(gpu); + } + if let Some(w) = selector_hidden_proj { + w.free_all(gpu); + } + return Err(e); + } } Ok(DflashWeights { @@ -951,29 +1092,7 @@ impl DflashWeights { let _ = gpu.free_tensor(self.hidden_norm); let _ = gpu.free_tensor(self.norm); for l in self.layers { - let _ = gpu.free_tensor(l.attn_norm); - l.wq.free_all(gpu); - l.wk.free_all(gpu); - l.wv.free_all(gpu); - l.wo.free_all(gpu); - let _ = gpu.free_tensor(l.q_norm); - let _ = gpu.free_tensor(l.k_norm); - let _ = gpu.free_tensor(l.ffn_norm); - l.w_gate.free_all(gpu); - l.w_up.free_all(gpu); - l.w_down.free_all(gpu); - if let Some(t) = l.attn_conv_base { - let _ = gpu.free_tensor(t); - } - if let Some(w) = l.attn_conv_proj { - w.free_all(gpu); - } - if let Some(t) = l.mlp_conv_base { - let _ = gpu.free_tensor(t); - } - if let Some(w) = l.mlp_conv_proj { - w.free_all(gpu); - } + l.free_gpu(gpu); } if let Some(w) = self.selector_hidden_proj { w.free_all(gpu); @@ -1346,13 +1465,34 @@ impl DflashScratch { if let Some(v) = s.v_ctx_cached.pop() { let _ = gpu.free_tensor(v); } - s.k_full_cached = Some(gpu.alloc_tensor(&[w_full * kvd], DType::F32)?); - s.v_full_cached = Some(gpu.alloc_tensor(&[w_full * kvd], DType::F32)?); - s.k_cat_full = Some(gpu.alloc_tensor(&[(w_full + b) * kvd], DType::F32)?); - s.v_cat_full = Some(gpu.alloc_tensor(&[(w_full + b) * kvd], DType::F32)?); + // The base scratch `s` is fully owned here: any failure below frees + // it before returning Err — a bare `?` would leak it (no `Drop` on + // the GPU-owning types), including when the failure follows the pop + // above. Same class as the `or_free!` sites in `load_dflash_state`. + macro_rules! alloc_or_free { + ($e:expr) => { + match $e { + Ok(t) => t, + Err(e) => { + s.free_gpu(gpu); + return Err(e); + } + } + }; + } + let k_full = alloc_or_free!(gpu.alloc_tensor(&[w_full * kvd], DType::F32)); + let v_full = alloc_or_free!(gpu.alloc_tensor(&[w_full * kvd], DType::F32)); + let k_cat = alloc_or_free!(gpu.alloc_tensor(&[(w_full + b) * kvd], DType::F32)); + let v_cat = alloc_or_free!(gpu.alloc_tensor(&[(w_full + b) * kvd], DType::F32)); // positions_k holds the last w_full context rows + the B noise rows // (the forward uploads only that suffix; every layer's span is one). - let new_positions_k = gpu.alloc_tensor(&[w_full + b], DType::F32)?; + // Allocate before freeing the old buffer so a failure still leaves + // `s` intact for the error arm above. + let new_positions_k = alloc_or_free!(gpu.alloc_tensor(&[w_full + b], DType::F32)); + s.k_full_cached = Some(k_full); + s.v_full_cached = Some(v_full); + s.k_cat_full = Some(k_cat); + s.v_cat_full = Some(v_cat); let _ = gpu.free_tensor(std::mem::replace(&mut s.positions_k, new_positions_k)); // The ctx bound is the target's physical capacity, not the window — // l may cross w_full (the last layer's span just slides). @@ -1380,7 +1520,38 @@ impl DflashScratch { let qd = cfg.q_dim(); let kvd = cfg.kv_dim(); - let mq_x_rot = if with_mq { + // Transactional construction: every `alloc_tensor` below goes through + // `at!`, which records the tensor in `live`; each index is taken + // exactly once when the struct is built. On failure the error arm + // frees everything recorded so far and returns — a bare `?` would + // leak (`GpuTensor`/`DeviceBuffer` have no `Drop`). Same style as the + // `gt!`/`wt!` slots in `DflashWeights::load` above. + let mut live: Vec> = Vec::new(); + macro_rules! at { + ($shape:expr) => {{ + match gpu.alloc_tensor($shape, DType::F32) { + Ok(t) => { + live.push(Some(t)); + live.len() - 1 + } + Err(e) => { + for slot in live.iter_mut() { + if let Some(t) = slot.take() { + let _ = gpu.free_tensor(t); + } + } + return Err(e); + } + } + }}; + } + macro_rules! take { + ($i:expr) => { + live[$i].take().expect("dflash scratch slot taken twice") + }; + } + + let i_mq_x_rot = if with_mq { // Sized for a CHUNK of the worst-case MQ rotation, not the whole // first-call prefix. The rotations called through `gemm_dispatch` // are: @@ -1401,93 +1572,113 @@ impl DflashScratch { // `ceil(batch / chunk_rows)` smaller GEMMs — adds ~1-2 launches per // 1K prefix tokens (negligible vs seconds-scale prefill). let widest = MQ_X_ROT_CHUNK_ROWS * std::cmp::max(inter, std::cmp::max(qd, ne * h)); - Some(gpu.alloc_tensor(&[widest], DType::F32)?) + Some(at!(&[widest])) } else { None }; // DFlash2 optional buffers: allocated only when the config declares them. - let (conv_temp, conv_dynamic, selector_proj, topk_ids, topk_vals) = { - let need_conv = cfg.conv_kernel_size.is_some() && cfg.conv_group_size.is_some(); - let need_selector = cfg.selector_rank.is_some() && cfg.selector_top_k.is_some(); - let ct = if need_conv { - Some(gpu.alloc_tensor(&[b * h], DType::F32)?) - } else { - None - }; - let cd = if need_conv { - let k = cfg.conv_kernel_size.unwrap(); - let g = cfg.conv_group_size.unwrap(); - let groups = h / g; - let stride = 2 * k * groups; - Some(gpu.alloc_tensor(&[b * stride], DType::F32)?) - } else { - None - }; - let sp = if need_selector { - let rank = cfg.selector_rank.unwrap(); - Some(gpu.alloc_tensor(&[b * rank], DType::F32)?) - } else { - None - }; - let (ti, tv) = if need_selector { - let kk = cfg.selector_top_k.unwrap(); - // ids as i32 stored in F32 buffer (reinterprets), vals as f32 - ( - Some(gpu.alloc_tensor(&[b * kk], DType::F32)?), - Some(gpu.alloc_tensor(&[b * kk], DType::F32)?), - ) - } else { - (None, None) - }; - (ct, cd, sp, ti, tv) + // Slot indices (`take!`n at the build below). + let need_conv = cfg.conv_kernel_size.is_some() && cfg.conv_group_size.is_some(); + let need_selector = cfg.selector_rank.is_some() && cfg.selector_top_k.is_some(); + let i_conv_temp = if need_conv { + Some(at!(&[b * h])) + } else { + None + }; + let i_conv_dynamic = if need_conv { + let k = cfg.conv_kernel_size.unwrap(); + let g = cfg.conv_group_size.unwrap(); + let groups = h / g; + let stride = 2 * k * groups; + Some(at!(&[b * stride])) + } else { + None + }; + let i_selector_proj = if need_selector { + let rank = cfg.selector_rank.unwrap(); + Some(at!(&[b * rank])) + } else { + None + }; + let (i_topk_ids, i_topk_vals) = if need_selector { + let kk = cfg.selector_top_k.unwrap(); + // ids as i32 stored in F32 buffer (reinterprets), vals as f32 + (Some(at!(&[b * kk])), Some(at!(&[b * kk]))) + } else { + (None, None) }; // Per-layer cache buffers for k_ctx/v_ctx (post-norm-for-K, pre-rope). // Size each at [max_ctx × kv_dim] f32 = l × kvd × 4 bytes. Memory // cost for 16-layer / 4096-ctx / 256-kv_dim draft ≈ 2 × 16 × 4 MB // = 128 MB. Trivial vs 24 GB VRAM. - let mut k_ctx_cached = Vec::with_capacity(cfg.n_layers); - let mut v_ctx_cached = Vec::with_capacity(cfg.n_layers); + let mut kv_idx: Vec<(usize, usize)> = Vec::with_capacity(cfg.n_layers); let mut draft_ffn_graphs = Vec::with_capacity(cfg.n_layers); let mut draft_ffn_warmed_up = Vec::with_capacity(cfg.n_layers); for _ in 0..cfg.n_layers { - k_ctx_cached.push(gpu.alloc_tensor(&[l * kvd], DType::F32)?); - v_ctx_cached.push(gpu.alloc_tensor(&[l * kvd], DType::F32)?); + kv_idx.push((at!(&[l * kvd]), at!(&[l * kvd]))); draft_ffn_graphs.push(HashMap::new()); draft_ffn_warmed_up.push(HashSet::new()); } + let i_x = at!(&[b * h]); + let i_x_norm = at!(&[b * h]); + let i_q = at!(&[b * qd]); + let i_k_noise = at!(&[b * kvd]); + let i_v_noise = at!(&[b * kvd]); + let i_gate = at!(&[b * inter]); + let i_up = at!(&[b * inter]); + let i_gate_up = at!(&[b * inter]); + let i_attn_out = at!(&[b * qd]); + let i_residual = at!(&[b * h]); + + let i_target_hidden = at!(&[l * ne * h]); + let i_target_hidden_proj = at!(&[l * h]); + + let i_k_cat = at!(&[tot * kvd]); + let i_v_cat = at!(&[tot * kvd]); + + let i_positions_q = at!(&[b]); + let i_positions_k = at!(&[tot]); + + let mut k_ctx_cached = Vec::with_capacity(cfg.n_layers); + let mut v_ctx_cached = Vec::with_capacity(cfg.n_layers); + for (ik, iv) in kv_idx { + k_ctx_cached.push(take!(ik)); + v_ctx_cached.push(take!(iv)); + } + debug_assert!(live.iter().all(|s| s.is_none())); Ok(DflashScratch { max_block_size: b, max_ctx_len: l, - x: gpu.alloc_tensor(&[b * h], DType::F32)?, - x_norm: gpu.alloc_tensor(&[b * h], DType::F32)?, - q: gpu.alloc_tensor(&[b * qd], DType::F32)?, - k_noise: gpu.alloc_tensor(&[b * kvd], DType::F32)?, - v_noise: gpu.alloc_tensor(&[b * kvd], DType::F32)?, - gate: gpu.alloc_tensor(&[b * inter], DType::F32)?, - up: gpu.alloc_tensor(&[b * inter], DType::F32)?, - gate_up: gpu.alloc_tensor(&[b * inter], DType::F32)?, - attn_out: gpu.alloc_tensor(&[b * qd], DType::F32)?, - residual: gpu.alloc_tensor(&[b * h], DType::F32)?, - - target_hidden: gpu.alloc_tensor(&[l * ne * h], DType::F32)?, - target_hidden_proj: gpu.alloc_tensor(&[l * h], DType::F32)?, - - k_cat: gpu.alloc_tensor(&[tot * kvd], DType::F32)?, - v_cat: gpu.alloc_tensor(&[tot * kvd], DType::F32)?, - - positions_q: gpu.alloc_tensor(&[b], DType::F32)?, - positions_k: gpu.alloc_tensor(&[tot], DType::F32)?, - - mq_x_rot, - conv_temp, - conv_dynamic, - selector_proj, - topk_ids, - topk_vals, + x: take!(i_x), + x_norm: take!(i_x_norm), + q: take!(i_q), + k_noise: take!(i_k_noise), + v_noise: take!(i_v_noise), + gate: take!(i_gate), + up: take!(i_up), + gate_up: take!(i_gate_up), + attn_out: take!(i_attn_out), + residual: take!(i_residual), + + target_hidden: take!(i_target_hidden), + target_hidden_proj: take!(i_target_hidden_proj), + + k_cat: take!(i_k_cat), + v_cat: take!(i_v_cat), + + positions_q: take!(i_positions_q), + positions_k: take!(i_positions_k), + + mq_x_rot: i_mq_x_rot.map(|j| take!(j)), + conv_temp: i_conv_temp.map(|j| take!(j)), + conv_dynamic: i_conv_dynamic.map(|j| take!(j)), + selector_proj: i_selector_proj.map(|j| take!(j)), + topk_ids: i_topk_ids.map(|j| take!(j)), + topk_vals: i_topk_vals.map(|j| take!(j)), thlog: TargetHiddenLog::new(), k_ctx_cached, v_ctx_cached, diff --git a/crates/hipfire-runtime/src/dflash_generic.rs b/crates/hipfire-runtime/src/dflash_generic.rs index 019542907..f4c5526c0 100644 --- a/crates/hipfire-runtime/src/dflash_generic.rs +++ b/crates/hipfire-runtime/src/dflash_generic.rs @@ -1048,18 +1048,35 @@ pub fn build_generic_dflash_speculator( let block_size = config.block_size; // L3: F16 drafts (dflash_convert) → has_mq=false → DflashScratch::new. // new_with_mq only for an MQ-quantized draft. - let scratch = if weights.has_mq { + // Transactional: `weights` and `scratch` own GPU memory with no `Drop`, + // so each fallible step below frees what is already owned before + // returning Err (same class as the `or_free!` chain in + // `hipfire-arch-qwen35`'s `load_dflash_state`). + let scratch = match if weights.has_mq { DflashScratch::new_with_mq(gpu, &config, block_size, ctx_capacity, true) - .map_err(|e| format!("{e}"))? + .map_err(|e| format!("{e}")) } else { - DflashScratch::new(gpu, &config, block_size, ctx_capacity).map_err(|e| format!("{e}"))? + DflashScratch::new(gpu, &config, block_size, ctx_capacity).map_err(|e| format!("{e}")) + } { + Ok(s) => s, + Err(e) => { + weights.free_gpu(gpu); + return Err(e); + } }; let _ = draft_hfq; // Tell the target which residual-hidden layers to capture (the drafter's // target_layer_ids), and mint the per-target verify scratch. target.set_dflash_extract_layers(config.target_layer_ids.clone()); - let verify_scratch = target.new_spec_scratch(gpu, block_size)?; + let verify_scratch = match target.new_spec_scratch(gpu, block_size) { + Ok(v) => v, + Err(e) => { + weights.free_gpu(gpu); + scratch.free_gpu(gpu); + return Err(e); + } + }; Ok(Box::new(GenericDflashSpeculator { weights, From 624d0cd836510bc961be0878becfaa88c38a56dc Mon Sep 17 00:00:00 2001 From: Kaden Schutt <151092359+Kaden-Schutt@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:08:40 +0000 Subject: [PATCH 12/34] fix(generate): roll back live state on make_spec_emitter error The make_spec_emitter Err exit ran after a successful spec.prefill without production_fail_closed_rollback_live, unlike every other post-prefill error exit. The target KV/DeltaNet/drafter hidden had advanced and host seq_pos/conversation_tokens were cleared, so the next turn could LCP against a dirty GPU. Route it through the same rollback + fail-closed error (audit-Dflash Broken 4). --- crates/hipfire-generate/src/qwen.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/hipfire-generate/src/qwen.rs b/crates/hipfire-generate/src/qwen.rs index 5bea5b5ba..153f4ed74 100644 --- a/crates/hipfire-generate/src/qwen.rs +++ b/crates/hipfire-generate/src/qwen.rs @@ -3110,15 +3110,25 @@ pub fn generate_spec( let mut emit: Box = match carrier.make_spec_emitter(emit_ctx) { Ok(e) => e, Err(e) => { - emit_active_attempt_error( - stdout, - Some(id), - &format!("{}", e), - "validation", - false, - false, + // Post-prefill failure: the target's KV/DeltaNet/drafter hidden + // already advanced and host seq_pos/conversation_tokens were + // cleared on a cold start. Fail closed like every other + // post-prefill error exit (prefill/step Err, realign, forced + // terminal): live rollback first, then one correlated error — + // otherwise the next turn LCPs against a dirty GPU + // (audit-Dflash Broken 4). + let msg = format!("make_spec_emitter: {e}"); + let ep = production_fail_closed_rollback_live( + &mut m.seq_pos, + &mut m.conversation_tokens, + &mut m.prefill_checkpoints, + &mut m.dflash_checkpoints, + &mut m.asst_turn_cache, + gpu, + slot, + spec.as_mut(), ); - let _ = stdout.flush(); + emit_fail_closed_error(stdout, Some(id), &msg, "validation", false, &ep); return None; } }; From ace8407d3b5c1a0f7477ada5e72534c35175abbd Mon Sep 17 00:00:00 2001 From: Kaden Schutt <151092359+Kaden-Schutt@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:12:19 +0000 Subject: [PATCH 13/34] fix(generate): unify spec ctx-cap guards and report ctx-exhausted as length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate_dflash fell back to AR only when prompt + max_tokens > ctx_capacity, but generate_spec hard-errors when prompt + max_tokens + block_size > ctx — requests in that band got gen_start followed by an error instead of the promised AR fallback. Both sites now share spec_ctx_request_fits (prompt + max + block <= cap). The mid-loop position + block_size >= ctx_capacity break now sets SpecRun::ctx_exhausted, which the qwen (v2 + legacy) and dense epilogues OR into the length decision: finish_reason=length with no cache store instead of a silent early stop (audit-Dflash Broken 5). --- crates/hipfire-generate/src/common.rs | 47 +++++++++++++++- crates/hipfire-generate/src/dense.rs | 15 +++--- crates/hipfire-generate/src/qwen.rs | 50 ++++++++++++----- .../qwen_dflash_semantic_terminal_tests.rs | 53 +++++++++++++++++++ 4 files changed, 144 insertions(+), 21 deletions(-) diff --git a/crates/hipfire-generate/src/common.rs b/crates/hipfire-generate/src/common.rs index 724abff7e..e3df35c07 100644 --- a/crates/hipfire-generate/src/common.rs +++ b/crates/hipfire-generate/src/common.rs @@ -572,6 +572,26 @@ pub fn qwen_dflash_hit_length_cap( generated >= max_tokens && !decoded_eot && !semantic_stop } +/// Shared ctx-capacity margin for the spec entry guard (`generate_dflash` +/// AR fallback) and the in-loop guard (`generate_spec` hard error). A +/// request fits only when prompt + budget + one full draft block fits the +/// draft's context-indexed structures; the `+ block_size` margin is what the +/// mid-loop `position + block_size >= ctx_capacity` break enforces per +/// cycle. Both sites must use this so any request `generate_spec` would +/// refuse falls back to AR at entry instead of erroring after `gen_start` +/// (audit-DFlash Broken 5). +pub fn spec_ctx_request_fits( + prompt_len: usize, + max_tokens: usize, + block_size: usize, + ctx_capacity: usize, +) -> bool { + prompt_len + .saturating_add(max_tokens) + .saturating_add(block_size) + <= ctx_capacity +} + /// Extract held ToolCalls from a FinishSummary (generate_spec holds them). pub fn finish_summary_held_tool_calls( finish: &FinishSummary, @@ -1289,6 +1309,11 @@ pub struct SpecRun { /// `finish.decoded_eot` — wrappers OR both so stop-at-max_tokens wins over /// length. GrammarViolation is fail-closed, not carried here. pub semantic_stop: Option, + /// The mid-loop `position + block_size >= ctx_capacity` break fired: the + /// draft cannot host another full block. Wrappers OR this into the + /// length-cap decision so the turn reports `finish_reason=length` with + /// no cache store instead of a natural `stop`. + pub ctx_exhausted: bool, /// Truthful rollback attestation when this turn ended fail-closed /// (grammar / open-think / malformed). `None` on safe Done paths. pub fail_closed_rollback: Option, @@ -1537,7 +1562,7 @@ pub fn token_logprob_fields( #[cfg(test)] mod tests { - use super::latch_request_think_cap; + use super::{latch_request_think_cap, spec_ctx_request_fits}; #[test] fn numeric_think_cap_latches_once_and_keeps_first_position() { @@ -1566,4 +1591,24 @@ mod tests { assert!(latched); assert_eq!(mark, Some(4096)); } + + #[test] + fn spec_ctx_request_fits_holds_one_block_margin() { + // Sum is prompt + max_tokens + block_size vs ctx_capacity: exactly + // at cap fits, cap+1 refuses, and a bare prompt+max_tokens == cap + // still refuses once the block margin is added (the band the entry + // guard used to admit and the loop guard then rejected). + assert!(spec_ctx_request_fits(100, 900, 24, 1024)); + assert!(spec_ctx_request_fits(100, 899, 24, 1023)); + assert!(!spec_ctx_request_fits(100, 900, 24, 1023)); + assert!(!spec_ctx_request_fits(1000, 24, 24, 1024)); + assert!(!spec_ctx_request_fits(900, 100, 24, 1023)); + // Zero block degrades to the legacy prompt+max_tokens check. + assert!(spec_ctx_request_fits(100, 900, 0, 1000)); + assert!(!spec_ctx_request_fits(100, 901, 0, 1000)); + // Saturating arithmetic: huge budgets clamp instead of panicking + // (debug) or wrapping (release) into a false fit. + assert!(spec_ctx_request_fits(usize::MAX, 1, 1, usize::MAX)); + assert!(!spec_ctx_request_fits(usize::MAX - 10, 20, 0, usize::MAX - 1)); + } } diff --git a/crates/hipfire-generate/src/dense.rs b/crates/hipfire-generate/src/dense.rs index aac549982..6b5aaac55 100644 --- a/crates/hipfire-generate/src/dense.rs +++ b/crates/hipfire-generate/src/dense.rs @@ -559,12 +559,15 @@ pub fn generate_deepseek4_spec( } // Semantic stop (StopSequence/EOS/ThinkCap) or decoded_eot at cap is not // length — preserves stop/tool_calls when generated == max_tokens. - let hit_length_cap = qwen_dflash_hit_length_cap( - run.generated, - max_tokens, - run.finish.decoded_eot, - run.semantic_stop.is_some(), - ); + // A ctx-exhausted mid-loop break is a length stop even when the token + // budget is unspent: same `length` + no-store path as the cap below. + let hit_length_cap = run.ctx_exhausted + || qwen_dflash_hit_length_cap( + run.generated, + max_tokens, + run.finish.decoded_eot, + run.semantic_stop.is_some(), + ); match ds4_spec_wire_terminal( run.finish.finish_reason, run.finish.tool_calls, diff --git a/crates/hipfire-generate/src/qwen.rs b/crates/hipfire-generate/src/qwen.rs index 153f4ed74..fefa0b2fc 100644 --- a/crates/hipfire-generate/src/qwen.rs +++ b/crates/hipfire-generate/src/qwen.rs @@ -2075,7 +2075,20 @@ pub fn generate_dflash( .as_ref() .map(|s| s.ctx_capacity()) .unwrap_or(usize::MAX); - if prompt_tokens.len().saturating_add(max_tokens) > spec_ctx_capacity { + let spec_block_size = m + .speculator + .as_ref() + .map(|s| s.block_size()) + .unwrap_or(0); + // Shared margin with the `generate_spec` hard guard below: prompt + + // budget + one draft block must fit, so any request the loop would refuse + // falls back to AR here instead of erroring after `gen_start`. + if !spec_ctx_request_fits( + prompt_tokens.len(), + max_tokens, + spec_block_size, + spec_ctx_capacity, + ) { emit_qwen_ar_info( stdout, id, @@ -2430,8 +2443,10 @@ pub fn generate_dflash( im_end_token, ); let semantic_stop = run.semantic_stop.is_some(); - let hit_length_cap = - qwen_dflash_hit_length_cap(run.generated, max_tokens, decoded_eot, semantic_stop); + // A ctx-exhausted mid-loop break is a length stop even when the token + // budget is unspent: same `length` + no-store path as the cap below. + let hit_length_cap = run.ctx_exhausted + || qwen_dflash_hit_length_cap(run.generated, max_tokens, decoded_eot, semantic_stop); // Prefer producer-visible channel; fall back to finish Token events. let visible = if !run.finish.visible_text.is_empty() { run.finish.visible_text.clone() @@ -2637,12 +2652,13 @@ pub fn generate_dflash( let emit_tool_calls = extract_tool_calls_from_text(&decoded_full); // Semantic stop / decoded_eot at the budget boundary is stop/tool_calls, // not length — same rule as the qwen_semantic_v2 path. - let hit_length_cap = qwen_dflash_hit_length_cap( - run.generated, - max_tokens, - run.finish.decoded_eot, - run.semantic_stop.is_some(), - ); + let hit_length_cap = run.ctx_exhausted + || qwen_dflash_hit_length_cap( + run.generated, + max_tokens, + run.finish.decoded_eot, + run.semantic_stop.is_some(), + ); let finish_reason = if hit_length_cap { "length" } else if !emit_tool_calls.is_empty() { @@ -2977,12 +2993,11 @@ pub fn generate_spec( let _ = stdout.flush(); return None; } + // Shared margin with the `generate_dflash` entry fallback above (same + // predicate): without eviction the entry already diverted these to AR, + // so this is belt-and-suspenders for direct `generate_spec` callers. if m.eviction.is_none() - && prompt_tokens - .len() - .saturating_add(max_tokens) - .saturating_add(block_size) - > ctx_capacity + && !spec_ctx_request_fits(prompt_tokens.len(), max_tokens, block_size, ctx_capacity) { emit_active_attempt_error( stdout, @@ -3151,6 +3166,11 @@ pub fn generate_spec( let mut spec_cycles = 0usize; let mut spec_accepted = 0usize; let mut generated = 0usize; + // Set when the mid-loop `position + block_size >= ctx_capacity` break + // fires: the draft's context-indexed structures cannot host another + // full block, so the epilogue must report a length stop + // (`finish_reason=length`, no cache store) rather than a natural stop. + let mut ctx_exhausted = false; // Post-prefill compaction (FlashCASK pattern from dflash_spec_demo). // If the prompt already filled past budget+beta, compact once before @@ -3366,6 +3386,7 @@ pub fn generate_spec( return None; } if position.saturating_add(block_size) >= ctx_capacity { + ctx_exhausted = true; break; } @@ -3958,6 +3979,7 @@ pub fn generate_spec( finish, grammar_violated, semantic_stop, + ctx_exhausted, fail_closed_rollback, prefill_s: t_prefill.duration_since(t0).as_secs_f64(), total_s: t_end.duration_since(t0).as_secs_f64(), diff --git a/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs b/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs index 38a8e59d8..79f889799 100644 --- a/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs +++ b/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs @@ -323,6 +323,59 @@ use hipfire_runtime::emit_text::extract_tool_calls_from_text; .is_none()); } + #[test] + fn ctx_exhausted_maps_to_length_with_budget_unspent() { + // Mid-loop `position + block_size >= ctx_capacity` break with + // generated < max_tokens: the epilogue ORs `run.ctx_exhausted` into + // the length decision, so the turn reports `length` with no tool + // release and no cache store instead of a natural `stop`. + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( + 10, 16, false, false + )); + // Wrapper-level mapping (`generate_dflash` / dense spec epilogue). + let ctx_exhausted = true; + let hit_length_cap = ctx_exhausted + || hipfire_generate::common::qwen_dflash_hit_length_cap(10, 16, false, false); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( + &fin, + hit_length_cap, + false, + "partial", + false, + ); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + fingerprint_text, + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*release_tool_calls); + assert!(!*store_cache); + assert!(wire_tool_calls.is_empty()); + assert!(fingerprint_text.is_empty()); + } + other => panic!("expected length Done, got {other:?}"), + } + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(!action.store); + assert!(hipfire_generate::qwen::qwen_dflash_apply_cache_action( + |_, _| panic!("must not insert"), + &action, + vec![1, 2] + ) + .is_none()); + } + #[test] fn final_token_eot_beats_length() { assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, true, false)); From 06e75a92062bfc60a11b020501eea7bffbbcdd7c Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:12:51 +0000 Subject: [PATCH 14/34] style: rustfmt changed files --- crates/hipfire-generate/src/common.rs | 7 +- crates/hipfire-generate/src/qwen.rs | 6 +- .../qwen_dflash_semantic_terminal_tests.rs | 6363 +++++++++-------- crates/hipfire-runtime/src/dflash.rs | 22 +- 4 files changed, 3305 insertions(+), 3093 deletions(-) diff --git a/crates/hipfire-generate/src/common.rs b/crates/hipfire-generate/src/common.rs index e3df35c07..77d49f25b 100644 --- a/crates/hipfire-generate/src/common.rs +++ b/crates/hipfire-generate/src/common.rs @@ -1609,6 +1609,11 @@ mod tests { // Saturating arithmetic: huge budgets clamp instead of panicking // (debug) or wrapping (release) into a false fit. assert!(spec_ctx_request_fits(usize::MAX, 1, 1, usize::MAX)); - assert!(!spec_ctx_request_fits(usize::MAX - 10, 20, 0, usize::MAX - 1)); + assert!(!spec_ctx_request_fits( + usize::MAX - 10, + 20, + 0, + usize::MAX - 1 + )); } } diff --git a/crates/hipfire-generate/src/qwen.rs b/crates/hipfire-generate/src/qwen.rs index fefa0b2fc..566b4d5d8 100644 --- a/crates/hipfire-generate/src/qwen.rs +++ b/crates/hipfire-generate/src/qwen.rs @@ -2075,11 +2075,7 @@ pub fn generate_dflash( .as_ref() .map(|s| s.ctx_capacity()) .unwrap_or(usize::MAX); - let spec_block_size = m - .speculator - .as_ref() - .map(|s| s.block_size()) - .unwrap_or(0); + let spec_block_size = m.speculator.as_ref().map(|s| s.block_size()).unwrap_or(0); // Shared margin with the `generate_spec` hard guard below: prompt + // budget + one draft block must fit, so any request the loop would refuse // falls back to AR here instead of erroring after `gen_start`. diff --git a/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs b/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs index 79f889799..78858bd11 100644 --- a/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs +++ b/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs @@ -17,106 +17,106 @@ use hipfire_generate::ar::*; use hipfire_generate::common::*; use hipfire_runtime::emit_text::extract_tool_calls_from_text; - use hipfire_runtime::prompt_frame::{AssistantPrefix, ToolCall}; - use hipfire_runtime::spec::{ - ClientEvent, FinishSummary, SpecEmit, SpecEmitCtx, SpecStep, StopReason, - }; - use hipfire_runtime::tokenizer::Tokenizer; - use std::collections::HashSet; - - fn summary_tool_calls(calls: Vec) -> FinishSummary { - let n = calls.len(); - FinishSummary { - events: vec![ClientEvent::ToolCalls(calls)], - finish_reason: "tool_calls", - tool_calls: n, - visible_text: "Sure.".into(), - decoded_eot: false, - open_think: false, - } - } - - fn summary_stop(visible: &str) -> FinishSummary { - FinishSummary { - events: vec![ClientEvent::Token(visible.into())], - finish_reason: "stop", - tool_calls: 0, - visible_text: visible.into(), - decoded_eot: false, - open_think: false, - } - } - - fn summary_malformed() -> FinishSummary { - FinishSummary { - events: Vec::new(), - finish_reason: "malformed_protocol", - tool_calls: 0, - visible_text: String::new(), - decoded_eot: false, - open_think: false, +use hipfire_runtime::prompt_frame::{AssistantPrefix, ToolCall}; +use hipfire_runtime::spec::{ + ClientEvent, FinishSummary, SpecEmit, SpecEmitCtx, SpecStep, StopReason, +}; +use hipfire_runtime::tokenizer::Tokenizer; +use std::collections::HashSet; + +fn summary_tool_calls(calls: Vec) -> FinishSummary { + let n = calls.len(); + FinishSummary { + events: vec![ClientEvent::ToolCalls(calls)], + finish_reason: "tool_calls", + tool_calls: n, + visible_text: "Sure.".into(), + decoded_eot: false, + open_think: false, + } +} + +fn summary_stop(visible: &str) -> FinishSummary { + FinishSummary { + events: vec![ClientEvent::Token(visible.into())], + finish_reason: "stop", + tool_calls: 0, + visible_text: visible.into(), + decoded_eot: false, + open_think: false, + } +} + +fn summary_malformed() -> FinishSummary { + FinishSummary { + events: Vec::new(), + finish_reason: "malformed_protocol", + tool_calls: 0, + visible_text: String::new(), + decoded_eot: false, + open_think: false, + } +} + +fn json_escape(s: &str) -> String { + let mut out = String::new(); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), } } - - fn json_escape(s: &str) -> String { - let mut out = String::new(); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } + out +} + +fn byte_to_gpt2_char_test(b: u8) -> char { + let mut bs: Vec = Vec::new(); + bs.extend((b'!' as u32)..=(b'~' as u32)); + bs.extend((0xA1u32)..=(0xACu32)); + bs.extend((0xAEu32)..=(0xFFu32)); + let mut cs: Vec = bs.clone(); + let mut n: u32 = 0; + for byte in 0u32..=255u32 { + if !bs.contains(&byte) { + bs.push(byte); + cs.push(256 + n); + n += 1; } - out } - - fn byte_to_gpt2_char_test(b: u8) -> char { - let mut bs: Vec = Vec::new(); - bs.extend((b'!' as u32)..=(b'~' as u32)); - bs.extend((0xA1u32)..=(0xACu32)); - bs.extend((0xAEu32)..=(0xFFu32)); - let mut cs: Vec = bs.clone(); - let mut n: u32 = 0; - for byte in 0u32..=255u32 { - if !bs.contains(&byte) { - bs.push(byte); - cs.push(256 + n); - n += 1; - } + for (bb, cc) in bs.into_iter().zip(cs.into_iter()) { + if bb == b as u32 { + return char::from_u32(cc).unwrap(); } - for (bb, cc) in bs.into_iter().zip(cs.into_iter()) { - if bb == b as u32 { - return char::from_u32(cc).unwrap(); - } - } - char::from_u32(b as u32).unwrap() } - - /// Same minimal tokenizer family as qwen35 `spec_emit` CPU tests. - fn test_tokenizer() -> Tokenizer { - let mut entries: Vec = Vec::new(); - entries.push(r#""<|im_start|>": 0"#.to_string()); - entries.push(r#""<|im_end|>": 1"#.to_string()); - entries.push(r#""": 2"#.to_string()); - entries.push(r#""": 3"#.to_string()); - entries.push(r#""system": 4"#.to_string()); - entries.push(r#""user": 5"#.to_string()); - entries.push(r#""assistant": 6"#.to_string()); - entries.push(r#""\n": 7"#.to_string()); - entries.push(r#""Ġ": 8"#.to_string()); - entries.push(r#""<|endoftext|>": 9"#.to_string()); - for b in 0u32..=255u32 { - let ch = byte_to_gpt2_char_test(b as u8); - let escaped = json_escape(&ch.to_string()); - entries.push(format!(r#""{}": {}"#, escaped, 100 + b)); - } - let vocab_block = entries.join(", "); - let json = format!( - r#"{{ + char::from_u32(b as u32).unwrap() +} + +/// Same minimal tokenizer family as qwen35 `spec_emit` CPU tests. +fn test_tokenizer() -> Tokenizer { + let mut entries: Vec = Vec::new(); + entries.push(r#""<|im_start|>": 0"#.to_string()); + entries.push(r#""<|im_end|>": 1"#.to_string()); + entries.push(r#""": 2"#.to_string()); + entries.push(r#""": 3"#.to_string()); + entries.push(r#""system": 4"#.to_string()); + entries.push(r#""user": 5"#.to_string()); + entries.push(r#""assistant": 6"#.to_string()); + entries.push(r#""\n": 7"#.to_string()); + entries.push(r#""Ġ": 8"#.to_string()); + entries.push(r#""<|endoftext|>": 9"#.to_string()); + for b in 0u32..=255u32 { + let ch = byte_to_gpt2_char_test(b as u8); + let escaped = json_escape(&ch.to_string()); + entries.push(format!(r#""{}": {}"#, escaped, 100 + b)); + } + let vocab_block = entries.join(", "); + let json = format!( + r#"{{ "model": {{"type": "BPE", "vocab": {{ {vocab} }}, "merges": []}}, "added_tokens": [ {{"id": 0, "content": "<|im_start|>", "special": true}}, @@ -126,340 +126,418 @@ use hipfire_runtime::emit_text::extract_tool_calls_from_text; {{"id": 9, "content": "<|endoftext|>", "special": true}} ] }}"#, - vocab = vocab_block, - ); - Tokenizer::from_hf_json(&json).expect("test tokenizer") - } - - fn make_qwen_emit<'a>( - tok: &'a Tokenizer, - assistant_prefix: AssistantPrefix, - ) -> Box { - hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { - tokenizer: tok, - eos: 9, - im_end: Some(1), - tools: Some(&[]), - stop: Vec::new(), - max_think: 0, - max_tokens: 256, - assistant_prefix, - think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, - decoded_vocab: None, - }) - } - - /// Drive production Qwen35Emit with whole-string encodes. - fn drive_qwen_emit( - text: &str, - assistant_prefix: AssistantPrefix, - ) -> (Vec, FinishSummary, Vec) { - let tok = test_tokenizer(); - let ids = tok.encode(text); - assert!(!ids.is_empty(), "encode produced no tokens for {text:?}"); - let mut emit = make_qwen_emit(&tok, assistant_prefix); - let mut stream = Vec::new(); - let mut first = true; - for id in &ids { - let outcome = if first { - first = false; - emit.begin(*id) - } else { - emit.observe(*id) - }; - stream.extend(outcome.events); - if outcome.stop.is_some() { - break; - } - } - let streamed = emit.streamed_tokens().to_vec(); - let finish = emit.finish(); - (stream, finish, streamed) - } - - /// Drive production emitter token-by-token (for split-marker cases). - fn drive_qwen_ids( - ids: &[u32], - assistant_prefix: AssistantPrefix, - ) -> (Vec, FinishSummary, Vec) { - let tok = test_tokenizer(); - let mut emit = make_qwen_emit(&tok, assistant_prefix); - let mut stream = Vec::new(); - let mut first = true; - for id in ids { - let outcome = if first { - first = false; - emit.begin(*id) - } else { - emit.observe(*id) - }; - stream.extend(outcome.events); - if outcome.stop.is_some() { - break; - } + vocab = vocab_block, + ); + Tokenizer::from_hf_json(&json).expect("test tokenizer") +} + +fn make_qwen_emit<'a>( + tok: &'a Tokenizer, + assistant_prefix: AssistantPrefix, +) -> Box { + hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { + tokenizer: tok, + eos: 9, + im_end: Some(1), + tools: Some(&[]), + stop: Vec::new(), + max_think: 0, + max_tokens: 256, + assistant_prefix, + think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, + decoded_vocab: None, + }) +} + +/// Drive production Qwen35Emit with whole-string encodes. +fn drive_qwen_emit( + text: &str, + assistant_prefix: AssistantPrefix, +) -> (Vec, FinishSummary, Vec) { + let tok = test_tokenizer(); + let ids = tok.encode(text); + assert!(!ids.is_empty(), "encode produced no tokens for {text:?}"); + let mut emit = make_qwen_emit(&tok, assistant_prefix); + let mut stream = Vec::new(); + let mut first = true; + for id in &ids { + let outcome = if first { + first = false; + emit.begin(*id) + } else { + emit.observe(*id) + }; + stream.extend(outcome.events); + if outcome.stop.is_some() { + break; } - let streamed = emit.streamed_tokens().to_vec(); - let finish = emit.finish(); - (stream, finish, streamed) - } - - fn parse_jsonl(out: &str) -> Vec { - out.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("bad jsonl {l}: {e}"))) - .collect() } - - /// GPU-less attested epilogue for unit tests (no real device sync). - fn attest_epilogue(rolled_back: bool) -> hipfire_generate::common::RollbackEpilogue { - hipfire_generate::common::RollbackEpilogue { - rolled_back, - context: None, + let streamed = emit.streamed_tokens().to_vec(); + let finish = emit.finish(); + (stream, finish, streamed) +} + +/// Drive production emitter token-by-token (for split-marker cases). +fn drive_qwen_ids( + ids: &[u32], + assistant_prefix: AssistantPrefix, +) -> (Vec, FinishSummary, Vec) { + let tok = test_tokenizer(); + let mut emit = make_qwen_emit(&tok, assistant_prefix); + let mut stream = Vec::new(); + let mut first = true; + for id in ids { + let outcome = if first { + first = false; + emit.begin(*id) + } else { + emit.observe(*id) + }; + stream.extend(outcome.events); + if outcome.stop.is_some() { + break; } } - - /// Attested epilogue with sync-failure context (rolled_back=false). - fn attest_epilogue_with_context(context: &str) -> hipfire_generate::common::RollbackEpilogue { - hipfire_generate::common::RollbackEpilogue { - rolled_back: false, - context: Some(context.to_string()), + let streamed = emit.streamed_tokens().to_vec(); + let finish = emit.finish(); + (stream, finish, streamed) +} + +fn parse_jsonl(out: &str) -> Vec { + out.lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("bad jsonl {l}: {e}"))) + .collect() +} + +/// GPU-less attested epilogue for unit tests (no real device sync). +fn attest_epilogue(rolled_back: bool) -> hipfire_generate::common::RollbackEpilogue { + hipfire_generate::common::RollbackEpilogue { + rolled_back, + context: None, + } +} + +/// Attested epilogue with sync-failure context (rolled_back=false). +fn attest_epilogue_with_context(context: &str) -> hipfire_generate::common::RollbackEpilogue { + hipfire_generate::common::RollbackEpilogue { + rolled_back: false, + context: Some(context.to_string()), + } +} + +#[test] +fn safe_stop_stores_cache_no_calls() { + let fin = summary_stop("hello"); + let term = + hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "hello", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + fingerprint_text, + wire_tool_calls, + } => { + assert_eq!(*finish_reason, "stop"); + assert!(!*release_tool_calls); + assert!(*store_cache); + assert!(wire_tool_calls.is_empty()); + assert_eq!( + fingerprint_text.as_str(), + hipfire_generate::common::normalize_asst_turn_for_fingerprint("hello") + ); } - } - - #[test] - fn safe_stop_stores_cache_no_calls() { - let fin = summary_stop("hello"); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "hello", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - fingerprint_text, - wire_tool_calls, - } => { - assert_eq!(*finish_reason, "stop"); - assert!(!*release_tool_calls); - assert!(*store_cache); - assert!(wire_tool_calls.is_empty()); - assert_eq!( - fingerprint_text.as_str(), - hipfire_generate::common::normalize_asst_turn_for_fingerprint("hello") - ); - } - other => panic!("expected Done, got {other:?}"), + other => panic!("expected Done, got {other:?}"), + } + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(action.store); + assert!(action.tool_calls.is_empty()); +} + +#[test] +fn tool_safe_releases_calls_and_stores() { + let calls = vec![ToolCall { + id: None, + name: "get_weather".into(), + arguments: serde_json::json!({"city": "SF"}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls.clone()); + let term = + hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "Sure.", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "tool_calls"); + assert!(*release_tool_calls); + assert!(*store_cache); + assert_eq!(wire_tool_calls.len(), 1); + assert_eq!(wire_tool_calls[0].name, "get_weather"); } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(action.store); - assert!(action.tool_calls.is_empty()); - } - - #[test] - fn tool_safe_releases_calls_and_stores() { - let calls = vec![ToolCall { - id: None, - name: "get_weather".into(), - arguments: serde_json::json!({"city": "SF"}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls.clone()); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "Sure.", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - wire_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "tool_calls"); - assert!(*release_tool_calls); - assert!(*store_cache); - assert_eq!(wire_tool_calls.len(), 1); - assert_eq!(wire_tool_calls[0].name, "get_weather"); - } - other => panic!("expected Done, got {other:?}"), + other => panic!("expected Done, got {other:?}"), + } + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(action.store); + assert_eq!(action.tool_calls.len(), 1); +} + +#[test] +fn pure_length_suppresses_calls_and_cache() { + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + assert!(hipfire_generate::common::qwen_dflash_hit_length_cap( + 16, 16, false, false + )); + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( + 16, 16, false, true + )); + let term = + hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "partial", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + fingerprint_text, + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*release_tool_calls); + assert!(!*store_cache); + assert!(wire_tool_calls.is_empty()); + assert!(fingerprint_text.is_empty()); } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(action.store); - assert_eq!(action.tool_calls.len(), 1); - } - - #[test] - fn pure_length_suppresses_calls_and_cache() { - let calls = vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls); - assert!(hipfire_generate::common::qwen_dflash_hit_length_cap(16, 16, false, false)); - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap(16, 16, false, true)); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "partial", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - wire_tool_calls, - fingerprint_text, - } => { - assert_eq!(*finish_reason, "length"); - assert!(!*release_tool_calls); - assert!(!*store_cache); - assert!(wire_tool_calls.is_empty()); - assert!(fingerprint_text.is_empty()); - } - other => panic!("expected length Done, got {other:?}"), + other => panic!("expected length Done, got {other:?}"), + } + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(!action.store); + assert!(hipfire_generate::qwen::qwen_dflash_apply_cache_action( + |_, _| panic!("must not insert"), + &action, + vec![1, 2] + ) + .is_none()); +} + +#[test] +fn ctx_exhausted_maps_to_length_with_budget_unspent() { + // Mid-loop `position + block_size >= ctx_capacity` break with + // generated < max_tokens: the epilogue ORs `run.ctx_exhausted` into + // the length decision, so the turn reports `length` with no tool + // release and no cache store instead of a natural `stop`. + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( + 10, 16, false, false + )); + // Wrapper-level mapping (`generate_dflash` / dense spec epilogue). + let ctx_exhausted = true; + let hit_length_cap = + ctx_exhausted || hipfire_generate::common::qwen_dflash_hit_length_cap(10, 16, false, false); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( + &fin, + hit_length_cap, + false, + "partial", + false, + ); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + fingerprint_text, + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*release_tool_calls); + assert!(!*store_cache); + assert!(wire_tool_calls.is_empty()); + assert!(fingerprint_text.is_empty()); } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(!action.store); - assert!(hipfire_generate::qwen::qwen_dflash_apply_cache_action( - |_, _| panic!("must not insert"), - &action, - vec![1, 2] - ) - .is_none()); - } - - #[test] - fn ctx_exhausted_maps_to_length_with_budget_unspent() { - // Mid-loop `position + block_size >= ctx_capacity` break with - // generated < max_tokens: the epilogue ORs `run.ctx_exhausted` into - // the length decision, so the turn reports `length` with no tool - // release and no cache store instead of a natural `stop`. - let calls = vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls); - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( - 10, 16, false, false - )); - // Wrapper-level mapping (`generate_dflash` / dense spec epilogue). - let ctx_exhausted = true; - let hit_length_cap = ctx_exhausted - || hipfire_generate::common::qwen_dflash_hit_length_cap(10, 16, false, false); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( - &fin, - hit_length_cap, - false, - "partial", - false, - ); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - wire_tool_calls, - fingerprint_text, - } => { - assert_eq!(*finish_reason, "length"); - assert!(!*release_tool_calls); - assert!(!*store_cache); - assert!(wire_tool_calls.is_empty()); - assert!(fingerprint_text.is_empty()); - } - other => panic!("expected length Done, got {other:?}"), + other => panic!("expected length Done, got {other:?}"), + } + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(!action.store); + assert!(hipfire_generate::qwen::qwen_dflash_apply_cache_action( + |_, _| panic!("must not insert"), + &action, + vec![1, 2] + ) + .is_none()); +} + +#[test] +fn final_token_eot_beats_length() { + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( + 8, 8, true, false + )); + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "ok", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + .. + } => { + assert_eq!(*finish_reason, "tool_calls"); + assert!(*release_tool_calls); + assert!(*store_cache); } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(!action.store); - assert!(hipfire_generate::qwen::qwen_dflash_apply_cache_action( - |_, _| panic!("must not insert"), - &action, - vec![1, 2] - ) - .is_none()); + other => panic!("expected tool_calls Done, got {other:?}"), } +} - #[test] - fn final_token_eot_beats_length() { - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, true, false)); - let calls = vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "ok", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - .. - } => { - assert_eq!(*finish_reason, "tool_calls"); - assert!(*release_tool_calls); - assert!(*store_cache); - } - other => panic!("expected tool_calls Done, got {other:?}"), +#[test] +fn malformed_is_error_xor_done_no_cache() { + let fin = summary_malformed(); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { + class, + retryable, + rolled_back, + message, + } => { + assert_eq!(*class, "validation"); + assert!(!*retryable); + assert!(!*rolled_back); + assert!(message.contains("malformed")); } - } - - #[test] - fn malformed_is_error_xor_done_no_cache() { - let fin = summary_malformed(); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { - class, - retryable, - rolled_back, - message, - } => { - assert_eq!(*class, "validation"); - assert!(!*retryable); - assert!(!*rolled_back); - assert!(message.contains("malformed")); - } - other => panic!("expected Malformed, got {other:?}"), + other => panic!("expected Malformed, got {other:?}"), + } + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(!action.store); + assert!(action.tool_calls.is_empty()); + assert!(!matches!( + term, + hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. } + )); +} + +#[test] +fn grammar_failure_no_calls_no_cache() { + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, true, "x", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { + class, + retryable, + message, + .. + } => { + assert_eq!(*class, "validation"); + assert!(!*retryable); + assert!(message.contains("grammar")); } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(!action.store); - assert!(action.tool_calls.is_empty()); - assert!(!matches!(term, hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. })); - } - - #[test] - fn grammar_failure_no_calls_no_cache() { - let calls = vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, true, "x", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { - class, - retryable, - message, - .. - } => { - assert_eq!(*class, "validation"); - assert!(!*retryable); - assert!(message.contains("grammar")); - } - other => panic!("expected grammar Malformed error-only, got {other:?}"), + other => panic!("expected grammar Malformed error-only, got {other:?}"), + } + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(!action.store); + assert!(action.tool_calls.is_empty()); + assert!(!matches!( + term, + hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. } + )); +} + +#[test] +fn open_think_is_error_xor_done_no_cache() { + // Production emitter (prompt-started OpenThink) -> real FinishSummary + // -> production wire terminal. No hand-built open_think mirrors. + let (stream, fin, _raw) = drive_qwen_emit("still thinking", AssistantPrefix::OpenThink); + let reasoning: String = stream + .iter() + .filter_map(|e| match e { + ClientEvent::Reasoning(text) => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(reasoning, "still thinking"); + assert!(fin.open_think, "emitter must latch open_think"); + assert_eq!(fin.finish_reason, "open_think"); + assert!(fin.events.is_empty()); + assert_eq!(fin.tool_calls, 0); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { + class, + retryable, + message, + .. + } => { + assert_eq!(*class, "validation"); + assert!(!*retryable); + assert!(message.contains("open think")); } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(!action.store); - assert!(action.tool_calls.is_empty()); - assert!(!matches!(term, hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. })); - } - - #[test] - fn open_think_is_error_xor_done_no_cache() { - // Production emitter (prompt-started OpenThink) -> real FinishSummary - // -> production wire terminal. No hand-built open_think mirrors. - let (stream, fin, _raw) = drive_qwen_emit("still thinking", AssistantPrefix::OpenThink); + other => panic!("expected open_think Malformed, got {other:?}"), + } + assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); + assert!(!matches!( + term, + hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. } + )); + // Production Malformed writer: error XOR done (GPU-less attested epilogue). + set_active_attempt_id(21); + let mut sink = Vec::new(); + if let hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { + message, + class, + retryable, + rolled_back, + } = &term + { + let ep = attest_epilogue(*rolled_back); + hipfire_generate::qwen::emit_qwen_dflash_malformed_terminal( + &mut sink, "req-ot", message, class, *retryable, &ep, + ); + } + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["attempt_id"], 21); + assert!(!out.contains(r#""type":"done""#)); +} + +#[test] +fn open_think_prompt_started_and_generated_flags() { + // (a) prompt-started OpenThink; (b) generated unclosed . + let cases = [ + ("prompt", AssistantPrefix::OpenThink, "still thinking"), + ("generated", AssistantPrefix::Plain, "pre secret"), + ]; + for (label, prefix, body) in cases { + let (stream, fin, _raw) = drive_qwen_emit(body, prefix); let reasoning: String = stream .iter() .filter_map(|e| match e { @@ -467,2767 +545,2896 @@ use hipfire_runtime::emit_text::extract_tool_calls_from_text; _ => None, }) .collect(); - assert_eq!(reasoning, "still thinking"); - assert!(fin.open_think, "emitter must latch open_think"); - assert_eq!(fin.finish_reason, "open_think"); - assert!(fin.events.is_empty()); - assert_eq!(fin.tool_calls, 0); + let expected_reasoning = if label == "prompt" { + "still thinking" + } else { + "secret" + }; + assert_eq!(reasoning, expected_reasoning, "{label}"); + assert!(fin.open_think, "{label}: open_think"); + assert_eq!(fin.finish_reason, "open_think", "{label}"); + assert_eq!(fin.tool_calls, 0, "{label}"); + assert!(fin.events.is_empty(), "{label}: no release on open_think"); let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { - class, - retryable, - message, - .. - } => { - assert_eq!(*class, "validation"); - assert!(!*retryable); - assert!(message.contains("open think")); - } - other => panic!("expected open_think Malformed, got {other:?}"), - } - assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); - assert!(!matches!(term, hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. })); - // Production Malformed writer: error XOR done (GPU-less attested epilogue). - set_active_attempt_id(21); - let mut sink = Vec::new(); - if let hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { - message, - class, - retryable, - rolled_back, - } = &term - { - let ep = attest_epilogue(*rolled_back); - hipfire_generate::qwen::emit_qwen_dflash_malformed_terminal( - &mut sink, "req-ot", message, class, *retryable, &ep, - ); - } - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["attempt_id"], 21); - assert!(!out.contains(r#""type":"done""#)); - } - - #[test] - fn open_think_prompt_started_and_generated_flags() { - // (a) prompt-started OpenThink; (b) generated unclosed . - let cases = [ - ("prompt", AssistantPrefix::OpenThink, "still thinking"), - ("generated", AssistantPrefix::Plain, "pre secret"), - ]; - for (label, prefix, body) in cases { - let (stream, fin, _raw) = drive_qwen_emit(body, prefix); - let reasoning: String = stream - .iter() - .filter_map(|e| match e { - ClientEvent::Reasoning(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - let expected_reasoning = if label == "prompt" { - "still thinking" - } else { - "secret" - }; - assert_eq!(reasoning, expected_reasoning, "{label}"); - assert!(fin.open_think, "{label}: open_think"); - assert_eq!(fin.finish_reason, "open_think", "{label}"); - assert_eq!(fin.tool_calls, 0, "{label}"); - assert!(fin.events.is_empty(), "{label}: no release on open_think"); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); - assert!( - matches!(term, hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { .. }), - "{label}: expected Malformed" - ); - assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store, "{label}"); + assert!( + matches!( + term, + hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { .. } + ), + "{label}: expected Malformed" + ); + assert!( + !hipfire_generate::qwen::qwen_dflash_cache_action(&term).store, + "{label}" + ); + } +} + +#[test] +fn producer_decoded_eot_beats_length_without_token_rescan() { + // Real emitter decoded_eot at budget boundary → stop, not length. + let tok = test_tokenizer(); + let mut ids = tok.encode("hi"); + ids.push(1); // <|im_end|> + let (_stream, fin, _raw) = drive_qwen_ids(&ids, AssistantPrefix::Plain); + assert!(fin.decoded_eot, "emitter must set decoded_eot"); + assert_eq!(fin.finish_reason, "stop"); + let generated = ids.len(); + let max_tokens = generated; + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( + generated, + max_tokens, + fin.decoded_eot, + false + )); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "hi", false); + assert!(matches!( + term, + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason: "stop", + store_cache: true, + .. } - } - - #[test] - fn producer_decoded_eot_beats_length_without_token_rescan() { - // Real emitter decoded_eot at budget boundary → stop, not length. - let tok = test_tokenizer(); - let mut ids = tok.encode("hi"); - ids.push(1); // <|im_end|> - let (_stream, fin, _raw) = drive_qwen_ids(&ids, AssistantPrefix::Plain); - assert!(fin.decoded_eot, "emitter must set decoded_eot"); - assert_eq!(fin.finish_reason, "stop"); - let generated = ids.len(); - let max_tokens = generated; - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( - generated, - max_tokens, - fin.decoded_eot, - false - )); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "hi", false); - assert!(matches!( - term, - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason: "stop", - store_cache: true, - .. - } - )); - } - - #[test] - fn split_decoded_eot_at_cap_is_stop_not_length() { - // Byte-fragment the <|im_end|> marker across tokens via 100+b map. - let marker = b"<|im_end|>"; - let mut ids: Vec = Vec::new(); - // prose "hi" - ids.push(100 + b'h' as u32); - ids.push(100 + b'i' as u32); - // split marker into two fragments - let mid = marker.len() / 2; - for &b in &marker[..mid] { - ids.push(100 + b as u32); + )); +} + +#[test] +fn split_decoded_eot_at_cap_is_stop_not_length() { + // Byte-fragment the <|im_end|> marker across tokens via 100+b map. + let marker = b"<|im_end|>"; + let mut ids: Vec = Vec::new(); + // prose "hi" + ids.push(100 + b'h' as u32); + ids.push(100 + b'i' as u32); + // split marker into two fragments + let mid = marker.len() / 2; + for &b in &marker[..mid] { + ids.push(100 + b as u32); + } + for &b in &marker[mid..] { + ids.push(100 + b as u32); + } + let (stream, fin, raw) = drive_qwen_ids(&ids, AssistantPrefix::Plain); + assert!(fin.decoded_eot, "split EOT must set decoded_eot"); + assert_eq!(fin.finish_reason, "stop"); + let visible: String = stream + .iter() + .filter_map(|ev| match ev { + ClientEvent::Token(t) => Some(t.as_str()), + _ => None, + }) + .collect(); + assert!(!visible.contains("<|im_end|>"), "marker bytes suppressed"); + assert!(visible.contains("hi")); + assert!(!raw.is_empty()); + let generated = raw.len(); + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( + generated, + generated, + fin.decoded_eot, + false + )); + let term = + hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, &visible, false); + assert!(matches!( + term, + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason: "stop", + store_cache: true, + release_tool_calls: false, + .. } - for &b in &marker[mid..] { - ids.push(100 + b as u32); + )); +} + +#[test] +fn step_budget_max_emit_zero_one_and_mid_window_prefix() { + // max_emit 0: empty emit is the defensive shape (live step returns Err). + let step0 = SpecStep::new([10, 11], 11, 1, 1).cap_emit(0); + assert!(step0.emit.is_empty()); + assert_eq!(step0.accepted, 0); + + // max_emit 1: prefix keep + seed reseeds from kept token. + let step1 = SpecStep::new([10, 11, 12], 12, 2, 2).cap_emit(1); + assert_eq!(step1.emit.as_slice(), &[10]); + assert_eq!(step1.next_seed, 10); + assert!(step1.emit.len() <= 1); + + // Mid-window semantic consume of 2 of 4 emitted tokens. + let step = SpecStep::new([10, 11, 12, 13], 13, 4, 3); + let host = hipfire_generate::qwen::spec_host_advance_after_step( + 100, + 0, + Vec::new(), + &step.emit, + step.next_seed, + 2, + ); + assert_eq!(host.emitted, vec![10, 11]); + assert_eq!(host.generated, 2); + assert_eq!(host.position, 102); + assert_eq!(host.seed_token, 11); + // Full-window consume keeps step.next_seed when prefix covers emit. + let host_full = hipfire_generate::qwen::spec_host_advance_after_step( + 100, + 0, + Vec::new(), + &step.emit, + step.next_seed, + 4, + ); + assert_eq!(host_full.emitted, vec![10, 11, 12, 13]); + assert_eq!(host_full.position, 104); + assert_eq!(host_full.seed_token, 13); + // Unconsumed tail must not inflate position/conversation. + assert_ne!(host.position, 100 + step.emit.len()); +} + +#[test] +fn spec_prefix_realign_plan_empty_raw_and_multi() { + let prompt = vec![1u32, 2, 3]; + let empty = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 99, &[]); + assert_eq!(empty.replay, prompt); + assert_eq!(empty.position, 3); + assert_eq!(empty.seed_token, 99); + + let multi = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 99, &[10, 11, 12]); + assert_eq!(multi.replay, vec![1, 2, 3, 99, 10, 11]); + assert_eq!(multi.position, 6); + assert_eq!(multi.seed_token, 12); + assert_eq!(multi.replay.len(), multi.position); + // Last raw stays the unwritten seed — never sits in KV replay. + assert_ne!(multi.replay.last().copied(), Some(multi.seed_token)); + // Naive prompt+raw drops first_token and writes the seed into KV. + let mut naive = prompt.clone(); + naive.extend_from_slice(&[10, 11, 12]); + assert_ne!(multi.replay, naive); +} + +#[test] +fn terminal_marker_mid_window_strict_prefix_realigns() { + // Spec window emits body + im_end + unobserved tail. Semantic loop + // consumes only through the terminal marker; host + realign plan must + // land exactly on that prefix (no unobserved tail in conversation or KV). + let tok = test_tokenizer(); + let prompt = vec![4u32, 5]; + let first_token = tok.encode("hi")[0]; + let body = tok.encode("ok"); + let im_end = 1u32; + let mut step_emit = body.clone(); + step_emit.push(im_end); + step_emit.extend_from_slice(&[90, 91]); + let step = SpecStep::new(step_emit.clone(), *step_emit.last().unwrap(), 4, 3); + + let mut emit = make_qwen_emit(&tok, AssistantPrefix::Plain); + let _ = emit.begin(first_token); + let mut consumed = 0usize; + let mut raw_decode: Vec = Vec::new(); + let mut hit_eos = false; + for &tok_id in &step.emit { + let outcome = emit.observe(tok_id); + if outcome.stop == Some(StopReason::GrammarViolation) { + break; } - let (stream, fin, raw) = drive_qwen_ids(&ids, AssistantPrefix::Plain); - assert!(fin.decoded_eot, "split EOT must set decoded_eot"); - assert_eq!(fin.finish_reason, "stop"); - let visible: String = stream - .iter() - .filter_map(|ev| match ev { - ClientEvent::Token(t) => Some(t.as_str()), - _ => None, - }) - .collect(); - assert!(!visible.contains("<|im_end|>"), "marker bytes suppressed"); - assert!(visible.contains("hi")); - assert!(!raw.is_empty()); - let generated = raw.len(); - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( - generated, - generated, - fin.decoded_eot, - false - )); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, &visible, false); - assert!(matches!( - term, - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason: "stop", - store_cache: true, - release_tool_calls: false, - .. - } - )); - } - - #[test] - fn step_budget_max_emit_zero_one_and_mid_window_prefix() { - // max_emit 0: empty emit is the defensive shape (live step returns Err). - let step0 = SpecStep::new([10, 11], 11, 1, 1).cap_emit(0); - assert!(step0.emit.is_empty()); - assert_eq!(step0.accepted, 0); - - // max_emit 1: prefix keep + seed reseeds from kept token. - let step1 = SpecStep::new([10, 11, 12], 12, 2, 2).cap_emit(1); - assert_eq!(step1.emit.as_slice(), &[10]); - assert_eq!(step1.next_seed, 10); - assert!(step1.emit.len() <= 1); - - // Mid-window semantic consume of 2 of 4 emitted tokens. - let step = SpecStep::new([10, 11, 12, 13], 13, 4, 3); - let host = hipfire_generate::qwen::spec_host_advance_after_step(100, 0, Vec::new(), &step.emit, step.next_seed, 2); - assert_eq!(host.emitted, vec![10, 11]); - assert_eq!(host.generated, 2); - assert_eq!(host.position, 102); - assert_eq!(host.seed_token, 11); - // Full-window consume keeps step.next_seed when prefix covers emit. - let host_full = - hipfire_generate::qwen::spec_host_advance_after_step(100, 0, Vec::new(), &step.emit, step.next_seed, 4); - assert_eq!(host_full.emitted, vec![10, 11, 12, 13]); - assert_eq!(host_full.position, 104); - assert_eq!(host_full.seed_token, 13); - // Unconsumed tail must not inflate position/conversation. - assert_ne!(host.position, 100 + step.emit.len()); - } - - #[test] - fn spec_prefix_realign_plan_empty_raw_and_multi() { - let prompt = vec![1u32, 2, 3]; - let empty = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 99, &[]); - assert_eq!(empty.replay, prompt); - assert_eq!(empty.position, 3); - assert_eq!(empty.seed_token, 99); - - let multi = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 99, &[10, 11, 12]); - assert_eq!(multi.replay, vec![1, 2, 3, 99, 10, 11]); - assert_eq!(multi.position, 6); - assert_eq!(multi.seed_token, 12); - assert_eq!(multi.replay.len(), multi.position); - // Last raw stays the unwritten seed — never sits in KV replay. - assert_ne!(multi.replay.last().copied(), Some(multi.seed_token)); - // Naive prompt+raw drops first_token and writes the seed into KV. - let mut naive = prompt.clone(); - naive.extend_from_slice(&[10, 11, 12]); - assert_ne!(multi.replay, naive); - } - - #[test] - fn terminal_marker_mid_window_strict_prefix_realigns() { - // Spec window emits body + im_end + unobserved tail. Semantic loop - // consumes only through the terminal marker; host + realign plan must - // land exactly on that prefix (no unobserved tail in conversation or KV). - let tok = test_tokenizer(); - let prompt = vec![4u32, 5]; - let first_token = tok.encode("hi")[0]; - let body = tok.encode("ok"); - let im_end = 1u32; - let mut step_emit = body.clone(); - step_emit.push(im_end); - step_emit.extend_from_slice(&[90, 91]); - let step = SpecStep::new(step_emit.clone(), *step_emit.last().unwrap(), 4, 3); - - let mut emit = make_qwen_emit(&tok, AssistantPrefix::Plain); - let _ = emit.begin(first_token); - let mut consumed = 0usize; - let mut raw_decode: Vec = Vec::new(); - let mut hit_eos = false; - for &tok_id in &step.emit { - let outcome = emit.observe(tok_id); - if outcome.stop == Some(StopReason::GrammarViolation) { - break; - } - consumed += 1; - raw_decode.push(tok_id); - if matches!( - outcome.stop, - Some(StopReason::Eos) | Some(StopReason::StopSequence) - ) { - hit_eos = true; - break; - } + consumed += 1; + raw_decode.push(tok_id); + if matches!( + outcome.stop, + Some(StopReason::Eos) | Some(StopReason::StopSequence) + ) { + hit_eos = true; + break; } - assert!(hit_eos, "im_end must stop the emitter"); - assert_eq!( - consumed, - body.len() + 1, - "must consume body+im_end only, not tail {:?}", - &step.emit[consumed..] - ); - assert!( - consumed < step.emit.len(), - "fixture must leave an unobserved speculative tail" - ); - - let position_before = prompt.len(); - let host = hipfire_generate::qwen::spec_host_advance_after_step( - position_before, - 0, - vec![first_token], - &step.emit, - step.next_seed, - consumed, - ); - assert_eq!(host.generated, consumed); - assert_eq!(host.position, position_before + consumed); - assert_eq!(host.seed_token, im_end); - assert_eq!(&host.emitted[1..], &step.emit[..consumed]); - assert!(!host.emitted.contains(&90) && !host.emitted.contains(&91)); - - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); - let mut expected_replay = prompt.clone(); - expected_replay.push(first_token); - expected_replay.extend_from_slice(&raw_decode[..raw_decode.len() - 1]); - assert_eq!(plan.replay, expected_replay); - assert_eq!(plan.position, prompt.len() + raw_decode.len()); - assert_eq!(plan.seed_token, im_end); - assert_eq!(plan.position, host.position); - assert_eq!(plan.seed_token, host.seed_token); - assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); } - - #[test] - fn empty_event_eos_mid_window_still_realigns_raw_prefix() { - // Empty-event EOS observes still advance position/raw_decode (filter - // stop on decoded marker bytes). Host + realign must track them. - let tok = test_tokenizer(); - let prompt = vec![4u32]; - let first_token = 100 + b'h' as u32; // byte-map 'h' - // Fragment <|im_end|> across byte-map tokens so filter stops without - // a single special-id observe; final fragment may yield empty events. - let marker = b"<|im_end|>"; - let mut step_emit: Vec = vec![100 + b'i' as u32]; // "i" after seed "h" - for &b in marker { - step_emit.push(100 + b as u32); - } - step_emit.extend_from_slice(&[90, 91]); // unobserved tail - let step = SpecStep::new(step_emit.clone(), 91, step_emit.len(), step_emit.len() - 1); - - let mut emit = make_qwen_emit(&tok, AssistantPrefix::Plain); - let _ = emit.begin(first_token); - let mut consumed = 0usize; - let mut raw_decode: Vec = Vec::new(); - let mut hit_eos = false; - for &tok_id in &step.emit { - let outcome = emit.observe(tok_id); - consumed += 1; - raw_decode.push(tok_id); - // Empty-event EOS still counts as a position-advancing observe. - if matches!( - outcome.stop, - Some(StopReason::Eos) | Some(StopReason::StopSequence) - ) { - hit_eos = true; - break; - } + assert!(hit_eos, "im_end must stop the emitter"); + assert_eq!( + consumed, + body.len() + 1, + "must consume body+im_end only, not tail {:?}", + &step.emit[consumed..] + ); + assert!( + consumed < step.emit.len(), + "fixture must leave an unobserved speculative tail" + ); + + let position_before = prompt.len(); + let host = hipfire_generate::qwen::spec_host_advance_after_step( + position_before, + 0, + vec![first_token], + &step.emit, + step.next_seed, + consumed, + ); + assert_eq!(host.generated, consumed); + assert_eq!(host.position, position_before + consumed); + assert_eq!(host.seed_token, im_end); + assert_eq!(&host.emitted[1..], &step.emit[..consumed]); + assert!(!host.emitted.contains(&90) && !host.emitted.contains(&91)); + + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); + let mut expected_replay = prompt.clone(); + expected_replay.push(first_token); + expected_replay.extend_from_slice(&raw_decode[..raw_decode.len() - 1]); + assert_eq!(plan.replay, expected_replay); + assert_eq!(plan.position, prompt.len() + raw_decode.len()); + assert_eq!(plan.seed_token, im_end); + assert_eq!(plan.position, host.position); + assert_eq!(plan.seed_token, host.seed_token); + assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); +} + +#[test] +fn empty_event_eos_mid_window_still_realigns_raw_prefix() { + // Empty-event EOS observes still advance position/raw_decode (filter + // stop on decoded marker bytes). Host + realign must track them. + let tok = test_tokenizer(); + let prompt = vec![4u32]; + let first_token = 100 + b'h' as u32; // byte-map 'h' + // Fragment <|im_end|> across byte-map tokens so filter stops without + // a single special-id observe; final fragment may yield empty events. + let marker = b"<|im_end|>"; + let mut step_emit: Vec = vec![100 + b'i' as u32]; // "i" after seed "h" + for &b in marker { + step_emit.push(100 + b as u32); + } + step_emit.extend_from_slice(&[90, 91]); // unobserved tail + let step = SpecStep::new(step_emit.clone(), 91, step_emit.len(), step_emit.len() - 1); + + let mut emit = make_qwen_emit(&tok, AssistantPrefix::Plain); + let _ = emit.begin(first_token); + let mut consumed = 0usize; + let mut raw_decode: Vec = Vec::new(); + let mut hit_eos = false; + for &tok_id in &step.emit { + let outcome = emit.observe(tok_id); + consumed += 1; + raw_decode.push(tok_id); + // Empty-event EOS still counts as a position-advancing observe. + if matches!( + outcome.stop, + Some(StopReason::Eos) | Some(StopReason::StopSequence) + ) { + hit_eos = true; + break; } - assert!(hit_eos, "split marker must stop via filter"); - assert!(consumed < step.emit.len(), "tail must remain unobserved"); - assert_eq!(raw_decode.len(), consumed); - - let host = hipfire_generate::qwen::spec_host_advance_after_step( - prompt.len(), - 0, - vec![first_token], - &step.emit, - step.next_seed, - consumed, - ); - assert_eq!(host.generated, consumed); - assert_eq!(host.position, prompt.len() + consumed); - assert!(!host.emitted.contains(&90)); - - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); - assert_eq!(plan.position, host.position); - assert_eq!(plan.seed_token, host.seed_token); - assert_eq!(plan.replay.len(), plan.position); - assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); - } - - #[test] - fn multi_window_then_strict_prefix_realign() { - // After a full first window, raw_decode holds W1; a second window stops - // mid-prefix. Realign replays prompt+first+raw[..-1] across both windows. - let prompt = vec![7u32, 8]; - let first_token = 50u32; - // Window 1 full consume (no realign). - let w1 = SpecStep::new([10u32, 11, 12], 12, 3, 2); - let mut raw_decode = Vec::new(); - let mut position = prompt.len(); - let mut emitted = vec![first_token]; - let mut generated = 0usize; - let host1 = hipfire_generate::qwen::spec_host_advance_after_step( - position, - generated, - emitted.clone(), - &w1.emit, - w1.next_seed, - w1.emit.len(), - ); - position = host1.position; - generated = host1.generated; - emitted = host1.emitted; - raw_decode.extend_from_slice(&w1.emit); - assert_eq!(position, prompt.len() + w1.emit.len()); - assert_eq!(host1.seed_token, 12); - - // Window 2: consume 2 of 4 (strict prefix → realign). - let w2 = SpecStep::new([20u32, 21, 22, 23], 23, 4, 3); - let consumed2 = 2usize; - raw_decode.extend_from_slice(&w2.emit[..consumed2]); - let host2 = hipfire_generate::qwen::spec_host_advance_after_step( - position, - generated, - emitted, - &w2.emit, - w2.next_seed, - consumed2, - ); - assert_eq!(host2.emitted, vec![first_token, 10, 11, 12, 20, 21]); - assert_eq!(host2.position, prompt.len() + raw_decode.len()); - assert_eq!(host2.seed_token, 21); - assert!(!host2.emitted.contains(&22) && !host2.emitted.contains(&23)); - - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); - assert_eq!( - plan.replay, - vec![7, 8, first_token, 10, 11, 12, 20] // drops last raw (21) - ); - assert_eq!(plan.position, host2.position); - assert_eq!(plan.seed_token, host2.seed_token); - assert_eq!(plan.seed_token, 21); } - - #[test] - fn forced_token_mid_window_strict_prefix_then_force_advance() { - // Think-budget force-close mid-window: observe only the forced-trigger - // prefix of step.emit, realign host/plan to that prefix, then host - // advances over the forced continuation tokens as raw_decode. - let tok = test_tokenizer(); - let prompt = vec![4u32, 5]; - let open_think = 2u32; // - - let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { - tokenizer: &tok, - eos: 9, - im_end: Some(1), - tools: None, - stop: Vec::new(), - max_think: 1, - max_tokens: 256, - assistant_prefix: AssistantPrefix::Plain, - think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, - decoded_vocab: None, - }); - - let begin = emit.begin(open_think); - assert!(begin.stop.is_none()); - - let think_body = tok.encode("x"); - assert_eq!(think_body.len(), 1); - let step_emit = vec![think_body[0], 90, 91, 92]; - let step = SpecStep::new(step_emit.clone(), 92, 4, 3); - - let mut consumed = 0usize; - let mut raw_decode: Vec = Vec::new(); - let mut forced_after: Vec = Vec::new(); - for &tok_id in &step.emit { - let outcome = emit.observe(tok_id); - if outcome.stop == Some(StopReason::GrammarViolation) { - break; - } - consumed += 1; - raw_decode.push(tok_id); - let forced = emit.take_forced(); - if !forced.is_empty() { - forced_after = forced; - break; - } - if outcome.stop.is_some() { - break; - } + assert!(hit_eos, "split marker must stop via filter"); + assert!(consumed < step.emit.len(), "tail must remain unobserved"); + assert_eq!(raw_decode.len(), consumed); + + let host = hipfire_generate::qwen::spec_host_advance_after_step( + prompt.len(), + 0, + vec![first_token], + &step.emit, + step.next_seed, + consumed, + ); + assert_eq!(host.generated, consumed); + assert_eq!(host.position, prompt.len() + consumed); + assert!(!host.emitted.contains(&90)); + + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); + assert_eq!(plan.position, host.position); + assert_eq!(plan.seed_token, host.seed_token); + assert_eq!(plan.replay.len(), plan.position); + assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); +} + +#[test] +fn multi_window_then_strict_prefix_realign() { + // After a full first window, raw_decode holds W1; a second window stops + // mid-prefix. Realign replays prompt+first+raw[..-1] across both windows. + let prompt = vec![7u32, 8]; + let first_token = 50u32; + // Window 1 full consume (no realign). + let w1 = SpecStep::new([10u32, 11, 12], 12, 3, 2); + let mut raw_decode = Vec::new(); + let mut position = prompt.len(); + let mut emitted = vec![first_token]; + let mut generated = 0usize; + let host1 = hipfire_generate::qwen::spec_host_advance_after_step( + position, + generated, + emitted.clone(), + &w1.emit, + w1.next_seed, + w1.emit.len(), + ); + position = host1.position; + generated = host1.generated; + emitted = host1.emitted; + raw_decode.extend_from_slice(&w1.emit); + assert_eq!(position, prompt.len() + w1.emit.len()); + assert_eq!(host1.seed_token, 12); + + // Window 2: consume 2 of 4 (strict prefix → realign). + let w2 = SpecStep::new([20u32, 21, 22, 23], 23, 4, 3); + let consumed2 = 2usize; + raw_decode.extend_from_slice(&w2.emit[..consumed2]); + let host2 = hipfire_generate::qwen::spec_host_advance_after_step( + position, + generated, + emitted, + &w2.emit, + w2.next_seed, + consumed2, + ); + assert_eq!(host2.emitted, vec![first_token, 10, 11, 12, 20, 21]); + assert_eq!(host2.position, prompt.len() + raw_decode.len()); + assert_eq!(host2.seed_token, 21); + assert!(!host2.emitted.contains(&22) && !host2.emitted.contains(&23)); + + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); + assert_eq!( + plan.replay, + vec![7, 8, first_token, 10, 11, 12, 20] // drops last raw (21) + ); + assert_eq!(plan.position, host2.position); + assert_eq!(plan.seed_token, host2.seed_token); + assert_eq!(plan.seed_token, 21); +} + +#[test] +fn forced_token_mid_window_strict_prefix_then_force_advance() { + // Think-budget force-close mid-window: observe only the forced-trigger + // prefix of step.emit, realign host/plan to that prefix, then host + // advances over the forced continuation tokens as raw_decode. + let tok = test_tokenizer(); + let prompt = vec![4u32, 5]; + let open_think = 2u32; // + + let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { + tokenizer: &tok, + eos: 9, + im_end: Some(1), + tools: None, + stop: Vec::new(), + max_think: 1, + max_tokens: 256, + assistant_prefix: AssistantPrefix::Plain, + think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, + decoded_vocab: None, + }); + + let begin = emit.begin(open_think); + assert!(begin.stop.is_none()); + + let think_body = tok.encode("x"); + assert_eq!(think_body.len(), 1); + let step_emit = vec![think_body[0], 90, 91, 92]; + let step = SpecStep::new(step_emit.clone(), 92, 4, 3); + + let mut consumed = 0usize; + let mut raw_decode: Vec = Vec::new(); + let mut forced_after: Vec = Vec::new(); + for &tok_id in &step.emit { + let outcome = emit.observe(tok_id); + if outcome.stop == Some(StopReason::GrammarViolation) { + break; } - assert_eq!(consumed, 1, "force must fire on the budget-hitting token"); - assert!( - !forced_after.is_empty(), - "think budget must queue continuation" - ); - assert!(consumed < step.emit.len(), "must leave unobserved tail"); - - let position_before = prompt.len(); - let host = hipfire_generate::qwen::spec_host_advance_after_step( - position_before, - 0, - vec![open_think], - &step.emit, - step.next_seed, - consumed, - ); - assert_eq!(host.generated, 1); - assert_eq!(host.position, position_before + 1); - assert_eq!(host.seed_token, think_body[0]); - assert!(!host.emitted.contains(&90)); - - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, open_think, &raw_decode); - assert_eq!(plan.position, host.position); - assert_eq!(plan.seed_token, host.seed_token); - assert_eq!(plan.replay, { - let mut r = prompt.clone(); - r.push(open_think); - r - }); - - // Pending-seed GPU tx: commit [trigger] ++ forced[..n-1]; last forced - // stays unprocessed pending seed (never double-forwarded). - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(plan.seed_token, &forced_after, true); - assert_eq!(tx.commit.first().copied(), Some(plan.seed_token)); - assert_eq!(tx.commit.len(), forced_after.len()); - assert_eq!(tx.pending_seed, *forced_after.last().unwrap()); - // Last forced is never double-forwarded: it is the pending seed, not in - // commit (except the n==1 case where commit is only the prior seed). - if forced_after.len() > 1 { - assert_eq!(&tx.commit[1..], &forced_after[..forced_after.len() - 1]); - assert_eq!( - tx.commit.last().copied(), - Some(forced_after[forced_after.len() - 2]) - ); - } else { - assert_eq!(tx.commit.as_slice(), &[plan.seed_token]); + consumed += 1; + raw_decode.push(tok_id); + let forced = emit.take_forced(); + if !forced.is_empty() { + forced_after = forced; + break; } - - // Host observes each forced token; position advances by commit.len(). - let mut position = plan.position.saturating_add(tx.position_delta); - let mut generated = host.generated; - let mut emitted = host.emitted.clone(); - let mut seed_token = tx.pending_seed; - for &ft in &forced_after { - generated += 1; - emitted.push(ft); - raw_decode.push(ft); - let fo = emit.observe(ft); - assert!( - fo.stop.is_none() || fo.stop == Some(StopReason::StopSequence), - "forced continuation should not hard-stop mid-injection: {:?}", - fo.stop - ); + if outcome.stop.is_some() { + break; } - assert_eq!(seed_token, *forced_after.last().unwrap()); - assert_eq!(position, plan.position + forced_after.len()); - assert_eq!(generated, consumed + forced_after.len()); - let mut expected_raw = step.emit[..consumed].to_vec(); - expected_raw.extend_from_slice(&forced_after); - assert_eq!(raw_decode, expected_raw); - assert!(!emitted.contains(&90) && !emitted.contains(&91) && !emitted.contains(&92)); - - // Terminal flush would commit the final pending seed exactly once. - let term = hipfire_generate::qwen::spec_terminal_pending_seed_tx(seed_token); - assert_eq!(term.commit, vec![seed_token]); - assert_eq!(term.position_delta, 1); - let position_after_flush = position + term.position_delta; - - let plan2 = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, open_think, &raw_decode); - assert_eq!(plan2.position, prompt.len() + raw_decode.len()); - assert_eq!(plan2.seed_token, seed_token); - assert_eq!(plan2.seed_token, *raw_decode.last().unwrap()); - assert_eq!(plan2.replay.len(), plan2.position); - // After terminal flush, cursor is one past the last conversation token - // (prompt + raw_decode), matching safe bake `m.seq_pos`. - assert_eq!(position_after_flush, prompt.len() + raw_decode.len() + 1); - // Realign still treats last raw as unwritten seed (pre-terminal-flush). - let mut expected_replay = prompt.clone(); - expected_replay.push(open_think); - expected_replay.extend_from_slice(&raw_decode[..raw_decode.len() - 1]); - assert_eq!(plan2.replay, expected_replay); } - - #[test] - fn cache_seq_trim_eot_vs_length_body_newline() { - let im_end = Some(1u32); - let nl: HashSet = [7u32].into_iter().collect(); - // EOT-terminated: body + im_end + nl → strip trailer. - let eot_stream = vec![10, 11, 1, 7]; - assert_eq!( - hipfire_generate::qwen::qwen_dflash_cache_seq(&eot_stream, im_end, &nl), - vec![10, 11] - ); - // Length-capped body ending on newline: restore verbatim (no im_end). - let len_stream = vec![10, 11, 7]; + assert_eq!(consumed, 1, "force must fire on the budget-hitting token"); + assert!( + !forced_after.is_empty(), + "think budget must queue continuation" + ); + assert!(consumed < step.emit.len(), "must leave unobserved tail"); + + let position_before = prompt.len(); + let host = hipfire_generate::qwen::spec_host_advance_after_step( + position_before, + 0, + vec![open_think], + &step.emit, + step.next_seed, + consumed, + ); + assert_eq!(host.generated, 1); + assert_eq!(host.position, position_before + 1); + assert_eq!(host.seed_token, think_body[0]); + assert!(!host.emitted.contains(&90)); + + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, open_think, &raw_decode); + assert_eq!(plan.position, host.position); + assert_eq!(plan.seed_token, host.seed_token); + assert_eq!(plan.replay, { + let mut r = prompt.clone(); + r.push(open_think); + r + }); + + // Pending-seed GPU tx: commit [trigger] ++ forced[..n-1]; last forced + // stays unprocessed pending seed (never double-forwarded). + let tx = + hipfire_generate::qwen::spec_forced_pending_seed_tx(plan.seed_token, &forced_after, true); + assert_eq!(tx.commit.first().copied(), Some(plan.seed_token)); + assert_eq!(tx.commit.len(), forced_after.len()); + assert_eq!(tx.pending_seed, *forced_after.last().unwrap()); + // Last forced is never double-forwarded: it is the pending seed, not in + // commit (except the n==1 case where commit is only the prior seed). + if forced_after.len() > 1 { + assert_eq!(&tx.commit[1..], &forced_after[..forced_after.len() - 1]); assert_eq!( - hipfire_generate::qwen::qwen_dflash_cache_seq(&len_stream, im_end, &nl), - vec![10, 11, 7] - ); - // Pure body, no trailer. - let body = vec![10, 11, 12]; - assert_eq!(hipfire_generate::qwen::qwen_dflash_cache_seq(&body, im_end, &nl), body); - } - - #[test] - fn step_and_forced_advance_error_helpers_are_xor_done() { - // Production fail-closed writer with GPU-less attested epilogue. - set_active_attempt_id(42); - for (what, id, needle) in [ - ("spec_step", "req-step", "spec_step:"), - ("forced", "req-fa", "forced-token"), - ] { - let mut sink = Vec::new(); - let ep = attest_epilogue(true); - hipfire_generate::qwen::emit_spec_failure_terminal(&mut sink, id, what, "boom", &ep); - let text = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&text); - assert_eq!(lines.len(), 1, "error XOR done: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["attempt_id"], 42); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], true); - assert!(lines[0]["message"].as_str().unwrap().contains(needle)); - assert!(!text.contains(r#""type":"done""#)); - assert!(!text.contains(r#""type":"tool_calls""#)); - } - // rolled_back=false + context path (sync could not be attested). - let mut sink = Vec::new(); - let ep = attest_epilogue_with_context("device_synchronize failed: test"); - hipfire_generate::qwen::emit_spec_failure_terminal(&mut sink, "req-ctx", "spec_step", "boom", &ep); - let text = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&text); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["rolled_back"], false); - assert!(lines[0]["message"] - .as_str() - .unwrap() - .contains("device_synchronize failed")); - // Wrapper None contract: no epilogue after early exit. - assert!(!qwen_dflash_epilogue_after_spec_run(false)); - assert!(qwen_dflash_epilogue_after_spec_run(true)); - } - - #[test] - fn forced_advance_error_is_xor_done_no_calls() { - set_active_attempt_id(43); + tx.commit.last().copied(), + Some(forced_after[forced_after.len() - 2]) + ); + } else { + assert_eq!(tx.commit.as_slice(), &[plan.seed_token]); + } + + // Host observes each forced token; position advances by commit.len(). + let mut position = plan.position.saturating_add(tx.position_delta); + let mut generated = host.generated; + let mut emitted = host.emitted.clone(); + let mut seed_token = tx.pending_seed; + for &ft in &forced_after { + generated += 1; + emitted.push(ft); + raw_decode.push(ft); + let fo = emit.observe(ft); + assert!( + fo.stop.is_none() || fo.stop == Some(StopReason::StopSequence), + "forced continuation should not hard-stop mid-injection: {:?}", + fo.stop + ); + } + assert_eq!(seed_token, *forced_after.last().unwrap()); + assert_eq!(position, plan.position + forced_after.len()); + assert_eq!(generated, consumed + forced_after.len()); + let mut expected_raw = step.emit[..consumed].to_vec(); + expected_raw.extend_from_slice(&forced_after); + assert_eq!(raw_decode, expected_raw); + assert!(!emitted.contains(&90) && !emitted.contains(&91) && !emitted.contains(&92)); + + // Terminal flush would commit the final pending seed exactly once. + let term = hipfire_generate::qwen::spec_terminal_pending_seed_tx(seed_token); + assert_eq!(term.commit, vec![seed_token]); + assert_eq!(term.position_delta, 1); + let position_after_flush = position + term.position_delta; + + let plan2 = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, open_think, &raw_decode); + assert_eq!(plan2.position, prompt.len() + raw_decode.len()); + assert_eq!(plan2.seed_token, seed_token); + assert_eq!(plan2.seed_token, *raw_decode.last().unwrap()); + assert_eq!(plan2.replay.len(), plan2.position); + // After terminal flush, cursor is one past the last conversation token + // (prompt + raw_decode), matching safe bake `m.seq_pos`. + assert_eq!(position_after_flush, prompt.len() + raw_decode.len() + 1); + // Realign still treats last raw as unwritten seed (pre-terminal-flush). + let mut expected_replay = prompt.clone(); + expected_replay.push(open_think); + expected_replay.extend_from_slice(&raw_decode[..raw_decode.len() - 1]); + assert_eq!(plan2.replay, expected_replay); +} + +#[test] +fn cache_seq_trim_eot_vs_length_body_newline() { + let im_end = Some(1u32); + let nl: HashSet = [7u32].into_iter().collect(); + // EOT-terminated: body + im_end + nl → strip trailer. + let eot_stream = vec![10, 11, 1, 7]; + assert_eq!( + hipfire_generate::qwen::qwen_dflash_cache_seq(&eot_stream, im_end, &nl), + vec![10, 11] + ); + // Length-capped body ending on newline: restore verbatim (no im_end). + let len_stream = vec![10, 11, 7]; + assert_eq!( + hipfire_generate::qwen::qwen_dflash_cache_seq(&len_stream, im_end, &nl), + vec![10, 11, 7] + ); + // Pure body, no trailer. + let body = vec![10, 11, 12]; + assert_eq!( + hipfire_generate::qwen::qwen_dflash_cache_seq(&body, im_end, &nl), + body + ); +} + +#[test] +fn step_and_forced_advance_error_helpers_are_xor_done() { + // Production fail-closed writer with GPU-less attested epilogue. + set_active_attempt_id(42); + for (what, id, needle) in [ + ("spec_step", "req-step", "spec_step:"), + ("forced", "req-fa", "forced-token"), + ] { let mut sink = Vec::new(); let ep = attest_epilogue(true); - hipfire_generate::qwen::emit_spec_failure_terminal(&mut sink, "req-fa", "forced", "boom", &ep); + hipfire_generate::qwen::emit_spec_failure_terminal(&mut sink, id, what, "boom", &ep); let text = String::from_utf8(sink).unwrap(); let lines = parse_jsonl(&text); - assert_eq!(lines.len(), 1); + assert_eq!(lines.len(), 1, "error XOR done: {lines:?}"); assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["attempt_id"], 43); + assert_eq!(lines[0]["attempt_id"], 42); + assert_eq!(lines[0]["retryable"], false); assert_eq!(lines[0]["rolled_back"], true); - assert!(lines[0]["message"] - .as_str() - .unwrap() - .contains("forced-token")); + assert!(lines[0]["message"].as_str().unwrap().contains(needle)); assert!(!text.contains(r#""type":"done""#)); assert!(!text.contains(r#""type":"tool_calls""#)); } - - #[test] - fn decoded_eot_beats_length_cap_helper() { - let fin = summary_stop("hi"); - assert!(hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, false, false)); - // Emitter semantic stop at cap is also not length (independent of EOT). - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, false, true)); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "hi", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - store_cache, - release_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "length"); - assert!(!*store_cache); - assert!(!*release_tool_calls); - } - other => panic!("{other:?}"), - } - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, true, false)); - let tok = test_tokenizer(); - let mut ids = tok.encode("hi"); - ids.push(1); - let (_s, fin_eot, _) = drive_qwen_ids(&ids, AssistantPrefix::Plain); - assert!(fin_eot.decoded_eot); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin_eot, false, false, "hi", false); - assert!(matches!( - term, - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason: "stop", - store_cache: true, - .. - } - )); - } - - #[test] - fn ordinary_length_cutoff_no_calls_no_cache() { - let calls = vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "x", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - wire_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "length"); - assert!(!*release_tool_calls); - assert!(!*store_cache); - assert!(wire_tool_calls.is_empty()); - } - other => panic!("{other:?}"), - } - assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); - } - - #[test] - fn cancel_is_fold_compatible_no_cache_helper() { - // Production cancel writer (same path as hipfire_generate::qwen::generate_spec abort sites). - set_active_attempt_id(11); - let mut sink = Vec::new(); - emit_qwen_ar_cancelled(&mut sink, "c", 3); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0]["type"], "aborted"); - assert_eq!(lines[0]["reason"], "client_cancelled"); - assert_eq!(lines[0]["attempt_id"], 11); - assert_eq!(lines[1]["type"], "done"); - assert_eq!(lines[1]["finish_reason"], "aborted"); - assert_eq!(lines[1]["completion_tokens"], 3); - // Cancel never goes through hipfire_generate::qwen::qwen_dflash_wire_terminal store path. - assert!(!out.contains(r#""finish_reason":"stop""#)); - } - - #[test] - fn serde_done_v2_hostile_id_roundtrip() { - set_active_attempt_id(5); - let id = "id\"quote\"\n"; - let mut sink = Vec::new(); - emit_qwen_dflash_done_terminal( - &mut sink, id, 2, 1.0, 1, 1.0, 1.0, 1.0, 1.0, 1.0, 1, 0, "stop", None, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "done"); - assert_eq!(lines[0]["id"], id); - assert_eq!(lines[0]["attempt_id"], 5); - assert_eq!(lines[0]["finish_reason"], "stop"); - assert_eq!(lines[0]["dflash"], true); - } - - #[test] - fn grammar_lifecycle_error_only_serialized() { - set_active_attempt_id(7); - let fin = summary_tool_calls(vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, true, "x", false); - let mut sink = Vec::new(); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { - message, - class, - retryable, - rolled_back, - } => { - let ep = attest_epilogue(*rolled_back); - hipfire_generate::qwen::emit_qwen_dflash_malformed_terminal( - &mut sink, "g1", message, class, *retryable, &ep, - ); - } - other => panic!("{other:?}"), - } - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["attempt_id"], 7); - assert_eq!(lines[0]["id"], "g1"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); - } - - #[test] - fn serde_v2_token_and_tool_calls_hostile_id() { - set_active_attempt_id(9); - let mut sink = Vec::new(); - let id = "a\"b\n"; - hipfire_generate::qwen::render_client_events( - &mut sink, - id, - &[ - ClientEvent::Token("hi".into()), - ClientEvent::Reasoning("r".into()), - ], - 0, - false, - ); - emit_tool_calls_event( - &mut sink, - id, - &[ToolCall { - id: None, - name: "n".into(), - arguments: serde_json::json!({"x": 1}), - rendered_body: None, - }], - ); - let out = String::from_utf8(sink).unwrap(); - for line in out.lines().filter(|l| !l.is_empty()) { - let v: serde_json::Value = serde_json::from_str(line).expect(line); - assert_eq!(v["attempt_id"], 9); - assert_eq!(v["id"], id); + // rolled_back=false + context path (sync could not be attested). + let mut sink = Vec::new(); + let ep = attest_epilogue_with_context("device_synchronize failed: test"); + hipfire_generate::qwen::emit_spec_failure_terminal( + &mut sink, + "req-ctx", + "spec_step", + "boom", + &ep, + ); + let text = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&text); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["rolled_back"], false); + assert!(lines[0]["message"] + .as_str() + .unwrap() + .contains("device_synchronize failed")); + // Wrapper None contract: no epilogue after early exit. + assert!(!qwen_dflash_epilogue_after_spec_run(false)); + assert!(qwen_dflash_epilogue_after_spec_run(true)); +} + +#[test] +fn forced_advance_error_is_xor_done_no_calls() { + set_active_attempt_id(43); + let mut sink = Vec::new(); + let ep = attest_epilogue(true); + hipfire_generate::qwen::emit_spec_failure_terminal(&mut sink, "req-fa", "forced", "boom", &ep); + let text = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&text); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["attempt_id"], 43); + assert_eq!(lines[0]["rolled_back"], true); + assert!(lines[0]["message"] + .as_str() + .unwrap() + .contains("forced-token")); + assert!(!text.contains(r#""type":"done""#)); + assert!(!text.contains(r#""type":"tool_calls""#)); +} + +#[test] +fn decoded_eot_beats_length_cap_helper() { + let fin = summary_stop("hi"); + assert!(hipfire_generate::common::qwen_dflash_hit_length_cap( + 8, 8, false, false + )); + // Emitter semantic stop at cap is also not length (independent of EOT). + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( + 8, 8, false, true + )); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "hi", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + store_cache, + release_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*store_cache); + assert!(!*release_tool_calls); } - let types: Vec<_> = parse_jsonl(&out) - .into_iter() - .map(|v| v["type"].as_str().unwrap().to_string()) - .collect(); - assert!(types.contains(&"token".to_string())); - assert!(types.contains(&"reasoning".to_string())); - assert!(types.contains(&"tool_calls".to_string())); - } - - #[test] - fn cancel_wire_helpers_carry_attempt_id() { - // Production cancel writer carries attempt_id on aborted + done. - set_active_attempt_id(3); - let mut sink = Vec::new(); - emit_qwen_ar_cancelled(&mut sink, "c1", 5); - let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0]["type"], "aborted"); - assert_eq!(lines[0]["attempt_id"], 3); - assert_eq!(lines[0]["reason"], "client_cancelled"); - assert_eq!(lines[1]["type"], "done"); - assert_eq!(lines[1]["finish_reason"], "aborted"); - assert_eq!(lines[1]["attempt_id"], 3); - assert_eq!(lines[1]["completion_tokens"], 5); - } - - #[test] - fn cache_fingerprint_uses_visible_not_raw_markers() { - let fin = summary_stop("visible only"); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "visible only", false); - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(!action.fingerprint_text.contains("")); - assert!(!action.fingerprint_text.contains("")); - assert!(action.fingerprint_text.contains("visible")); - let mut stored = None; - let fp = hipfire_generate::qwen::qwen_dflash_apply_cache_action( - |f, seq| { - stored = Some((f, seq)); - }, - &action, - vec![10, 20, 30], - ); - assert!(fp.is_some()); - let (f, seq) = stored.expect("insert"); - assert_eq!(seq, vec![10, 20, 30]); - assert_eq!( - f, - hipfire_generate::common::asst_turn_fingerprint(&action.fingerprint_text, &action.tool_calls) - ); - } - - #[test] - fn qwen_dflash_contract_version_is_v2() { - assert_eq!(QWEN_DFLASH_SEMANTIC_CONTRACT_VERSION, 2); - assert_eq!(hipfire_generate::common::gen_start_contract_version_for_arch(5), Some(2)); - assert_eq!(hipfire_generate::common::gen_start_contract_version_for_arch(6), Some(2)); - } - - #[test] - fn no_whole_output_parser_in_terminal_path() { - // Terminal path authority is FinishSummary fields only — a finish with - // empty held calls cannot invent tools from visible text markers. - let fin = FinishSummary { - events: vec![ClientEvent::Token( - "{\"name\":\"x\",\"arguments\":{}}".into(), - )], + other => panic!("{other:?}"), + } + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( + 8, 8, true, false + )); + let tok = test_tokenizer(); + let mut ids = tok.encode("hi"); + ids.push(1); + let (_s, fin_eot, _) = drive_qwen_ids(&ids, AssistantPrefix::Plain); + assert!(fin_eot.decoded_eot); + let term = + hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin_eot, false, false, "hi", false); + assert!(matches!( + term, + hipfire_generate::qwen::QwenDflashWireTerminal::Done { finish_reason: "stop", - tool_calls: 0, - visible_text: String::new(), - decoded_eot: false, - open_think: false, - }; - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); - match term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - wire_tool_calls, - .. - } => { - assert_eq!(finish_reason, "stop"); - assert!(!release_tool_calls); - assert!(wire_tool_calls.is_empty()); - } - other => panic!("expected stop Done without invented calls, got {other:?}"), - } - } - - #[test] - fn production_done_value_builder_matches_epilogue_shape() { - let v = - hipfire_generate::qwen::qwen_dflash_done_value("r", 3, 1.5, 10, 2.0, 5.0, 1.2, 2.0, 0.5, 2, 0, "length", 99); - assert_eq!(v["type"], "done"); - assert_eq!(v["finish_reason"], "length"); - assert_eq!(v["attempt_id"], 99); - assert_eq!(v["dflash"], true); - assert_eq!(v["tokens"], 3); - } - - // --- Task 4 production-seam invariants (pending-seed / cancel / evict / - // capacity / jinja / wire / rollback attestation) --- - - #[test] - fn trigger_token_retained_before_forced_suffix_tx() { - // Forced GPU tx must first commit the current pending seed (the - // force-trigger), then forced[..n-1]. The trigger is never dropped. - let trigger = 77u32; - let forced = [10u32, 11, 12]; - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(trigger, &forced, true); - assert_eq!(tx.commit[0], trigger, "trigger must lead the commit batch"); - assert_eq!(tx.commit, vec![77, 10, 11]); - assert_eq!(tx.position_delta, forced.len()); - assert_eq!(tx.commit.len(), tx.position_delta); - // Trigger is not the new pending seed unless forced was length-1. - assert_ne!(tx.pending_seed, trigger); - } - - #[test] - fn final_forced_token_is_pending_exactly_once() { - // Last forced token becomes the unprocessed pending seed and MUST NOT - // also appear in commit (no double-forward). - let forced = [20u32, 21, 22]; - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(5, &forced, true); - assert_eq!(tx.pending_seed, 22); - assert!( - !tx.commit.contains(&22), - "last forced must stay unwritten: {:?}", - tx.commit - ); - assert_eq!(tx.commit, vec![5, 20, 21]); - // Single-token forced: commit is only the prior seed; forced[0] pending. - let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(99, &[42], true); - assert_eq!(one.commit, vec![99]); - assert_eq!(one.pending_seed, 42); - assert!(!one.commit.contains(&42)); - assert_eq!(one.position_delta, 1); - } - - #[test] - fn terminal_pending_seed_flush_exactly_once() { - let seed = 314u32; - let tx = hipfire_generate::qwen::spec_terminal_pending_seed_tx(seed); - assert_eq!(tx.commit, vec![seed]); - assert_eq!(tx.position_delta, 1); - assert_eq!(tx.commit.len(), 1, "flush commits the seed once"); - // Terminal flush ends with the same logical token as conversation - // (pending_seed field equals the committed token; no second lagging seed). - assert_eq!(tx.pending_seed, seed); - } - - #[test] - fn forced_max_tokens_clip_hard_ceiling() { - // generated already includes the trigger; no GPU for tokens past budget. - let forced = [1u32, 2, 3, 4, 5]; - assert_eq!(hipfire_generate::qwen::spec_forced_tokens_within_budget(8, 10, &forced), &[1, 2]); - assert_eq!( - hipfire_generate::qwen::spec_forced_tokens_within_budget(10, 10, &forced), - &[] as &[u32] - ); - assert_eq!(hipfire_generate::qwen::spec_forced_tokens_within_budget(0, 3, &forced), &[1, 2, 3]); - assert_eq!(hipfire_generate::qwen::spec_forced_tokens_within_budget(9, 10, &forced), &[1]); - // Composition: clip then build tx — only fitting tokens become pending. - let clipped = hipfire_generate::qwen::spec_forced_tokens_within_budget(7, 10, &forced); - assert_eq!(clipped, &[1, 2, 3]); - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(70, clipped, true); - assert_eq!(tx.commit, vec![70, 1, 2]); - assert_eq!(tx.pending_seed, 3); - assert!(!tx.commit.contains(&4) && !tx.commit.contains(&5)); - } - - #[test] - fn cancellation_classification_forced_gpu_advance() { - assert_eq!( - hipfire_generate::qwen::classify_forced_gpu_advance(false), - hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed - ); - assert_eq!( - hipfire_generate::qwen::classify_forced_gpu_advance(true), - hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled - ); - // Cancelled path must use aborted+done wire, never bake the forced token. - // ErrorOnly is reserved for eviction failures (XOR below). - assert_ne!(hipfire_generate::qwen::SpecFailClosedWire::Cancelled, hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly); - set_active_attempt_id(55); - let mut sink = Vec::new(); - match hipfire_generate::qwen::classify_forced_gpu_advance(true) { - hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled => { - emit_qwen_ar_cancelled(&mut sink, "c-force", 4); - } - hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed => panic!("abort must classify Cancelled"), - } - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0]["type"], "aborted"); - assert_eq!(lines[0]["reason"], "client_cancelled"); - assert_eq!(lines[0]["attempt_id"], 55); - assert_eq!(lines[1]["type"], "done"); - assert_eq!(lines[1]["finish_reason"], "aborted"); - assert!(!out.contains(r#""type":"error""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - set_active_attempt_id(0); - } - - #[test] - fn eviction_error_terminal_exclusivity() { - // maybe_evict / on_evict Err → ErrorOnly: one fail-closed error, no done. - assert_eq!(hipfire_generate::qwen::classify_evict_failure_wire(), hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly); - set_active_attempt_id(66); - let mut sink = Vec::new(); - let ep = attest_epilogue(true); - match hipfire_generate::qwen::classify_evict_failure_wire() { - hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly => { - hipfire_generate::common::emit_fail_closed_error( - &mut sink, - Some("ev1"), - "on_evict: synthetic retain failure", - "validation", - false, - &ep, - ); - } - hipfire_generate::qwen::SpecFailClosedWire::Cancelled => panic!("evict must not classify Cancelled"), - } - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "error XOR done: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], true); - assert_eq!(lines[0]["attempt_id"], 66); - assert_eq!(lines[0]["id"], "ev1"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - // Fail-closed early exit skips wrapper epilogue (same as step failure). - assert!(!qwen_dflash_epilogue_after_spec_run(false)); - set_active_attempt_id(0); - } - - #[test] - fn strict_prefix_replay_capacity_rejection() { - let prompt = vec![1u32, 2, 3]; - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 9, &[10, 11, 12]); - // plan.replay = [1,2,3,9,10,11], position=6, seed=12 - assert_eq!(plan.replay.len(), plan.position); - assert_eq!(plan.seed_token, 12); - assert!(!plan.replay.contains(&12)); - - // Fits both caps (position must be strictly < caps — pending seed slot). - assert!(hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 64, 64, 0, false).is_ok()); - // Boundary: position == cap leaves no legal write slot for pending seed. - let err_eq = hipfire_generate::qwen::spec_prefix_realign_admit(&plan, plan.position, 64, 0, false).unwrap_err(); - assert!( - err_eq.contains("physical_cap"), - "expected position==physical_cap reject, got {err_eq}" - ); - - // Physical capacity rejection — fail closed before reset/prefill. - let err_phys = hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 5, 64, 0, false).unwrap_err(); - assert!( - err_phys.contains("physical_cap"), - "expected physical_cap reject, got {err_phys}" - ); - - // Speculator ctx capacity rejection. - let err_ctx = hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 64, 4, 0, false).unwrap_err(); - assert!( - err_ctx.contains("ctx_capacity"), - "expected ctx_capacity reject, got {err_ctx}" - ); - - // Broken invariant (replay/position mismatch) rejects even if caps large. - let broken = hipfire_generate::qwen::SpecPrefixRealignPlan { - replay: vec![1, 2], - position: 5, - seed_token: 9, - }; - let err_inv = hipfire_generate::qwen::spec_prefix_realign_admit(&broken, 100, 100, 0, false).unwrap_err(); - assert!( - err_inv.contains("invariant") || err_inv.contains("pending"), - "expected invariant reject, got {err_inv}" - ); - - // Compacted/eviction path still fails closed on oversize full-history replay. - let err_ev = hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 5, 64, 3, true).unwrap_err(); - assert!( - err_ev.contains("physical_cap") || err_ev.contains("compact"), - "expected compacted oversize reject, got {err_ev}" - ); - - // Capacity reject wires as exclusive error terminal (no done). - set_active_attempt_id(71); - let mut sink = Vec::new(); - let ep = attest_epilogue(true); - hipfire_generate::common::emit_fail_closed_error( - &mut sink, - Some("realign"), - &err_phys, - "validation", - false, - &ep, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["attempt_id"], 71); - assert!(!out.contains(r#""type":"done""#)); - set_active_attempt_id(0); - } - - #[test] - fn configured_jinja_render_fail_closed_policy() { - // Production hipfire_generate::qwen::generate_dflash configured-template Err path: - // hipfire_generate::dense::emit_active_attempt_error(class=validation, retryable=false, - // rolled_back=false, message="DFlash jinja render: …") then handled=true. - // Plain is not a silent fallback when a template is configured. - set_active_attempt_id(88); - let mut sink = Vec::new(); - let render_err = "undefined variable `messages`"; - hipfire_generate::dense::emit_active_attempt_error( - &mut sink, - Some("j1"), - &format!("DFlash jinja render: {render_err}"), - "validation", - false, - false, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 88); - assert_eq!(lines[0]["id"], "j1"); - let msg = lines[0]["message"].as_str().unwrap(); - assert!(msg.starts_with("DFlash jinja render:"), "{msg}"); - assert!(msg.contains(render_err), "{msg}"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"gen_start""#)); - // handled=true contract: early exit skips AR/done epilogue. - assert!(!qwen_dflash_epilogue_after_spec_run(false)); - set_active_attempt_id(0); - } - - #[test] - fn correlated_escaped_dflash_info_frame() { - // DFlash ctx-capacity fallback info uses serde + active attempt_id and - // must survive adversarial id/message bytes without breaking JSONL. - set_active_attempt_id(13); - let mut sink = Vec::new(); - let id = "id\"x\n\t\\"; - let message = "prompt=3 + max_tokens=9 exceeds DFlash draft ctx capacity 8 — falling back to AR (\"identical\" output)"; - emit_qwen_ar_info(&mut sink, id, message); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "info"); - assert_eq!(lines[0]["id"], id); - assert_eq!(lines[0]["message"], message); - assert_eq!(lines[0]["attempt_id"], 13); - // Round-trip proves escaping: re-serialize must still parse as one object. - let raw = out.lines().next().unwrap(); - let again: serde_json::Value = serde_json::from_str(raw).expect("serde-escaped info"); - assert_eq!(again["id"].as_str().unwrap(), id); - set_active_attempt_id(0); - } - - #[test] - fn rollback_attestation_false_on_sync_failure_surface() { - // No injectable mock GPU; production surface is hipfire_generate::common::RollbackEpilogue from - // hipfire_generate::common::fail_closed_device_sync on Err → rolled_back=false + context. - // hipfire_generate::common::emit_fail_closed_error must append context and claim rolled_back=false. - set_active_attempt_id(17); - let mut sink = Vec::new(); - let ep = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); - assert!(!ep.rolled_back); - assert!(ep - .context - .as_ref() - .unwrap() - .contains("device_synchronize failed")); - hipfire_generate::common::emit_fail_closed_error( - &mut sink, - Some("rb1"), - "forced-token advance: boom", - "validation", - false, - &ep, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 17); - let msg = lines[0]["message"].as_str().unwrap(); - assert!(msg.contains("forced-token advance: boom"), "{msg}"); - assert!(msg.contains("device_synchronize failed"), "{msg}"); - assert!(!out.contains(r#""type":"done""#)); - - // Attested success path still reports rolled_back=true without context suffix. - let mut sink_ok = Vec::new(); - let ep_ok = attest_epilogue(true); - hipfire_generate::common::emit_fail_closed_error( - &mut sink_ok, - Some("rb2"), - "spec_step: boom", - "validation", - false, - &ep_ok, - ); - let ok = parse_jsonl(&String::from_utf8(sink_ok).unwrap()); - assert_eq!(ok[0]["rolled_back"], true); - assert_eq!(ok[0]["message"], "spec_step: boom"); - set_active_attempt_id(0); - } - - #[test] - fn pending_seed_chain_trigger_clip_force_then_terminal_flush() { - // End-to-end pure chain defending the single pending-seed invariant: - // mid-window force trigger retained → budget clip → forced tx leaves - // last forced pending → safe terminal flushes that seed once. - let prompt = vec![1u32, 2]; - let first = 50u32; - // Consume force-trigger only from a wider speculative window. - let step = SpecStep::new([60u32, 61, 62], 62, 3, 2); - let host = hipfire_generate::qwen::spec_host_advance_after_step( - prompt.len(), - 0, - vec![first], - &step.emit, - step.next_seed, - 1, - ); - assert_eq!(host.seed_token, 60); // trigger retained as pending seed - assert_eq!(host.generated, 1); - - let forced_raw = [70u32, 71, 72, 73]; - // generated=1 (trigger counted); max_tokens=3 → room for 2 forced. - let forced = hipfire_generate::qwen::spec_forced_tokens_within_budget(host.generated, 3, &forced_raw); - assert_eq!(forced, &[70, 71]); - let ftx = hipfire_generate::qwen::spec_forced_pending_seed_tx(host.seed_token, forced, true); - assert_eq!(ftx.commit, vec![60, 70]); // trigger + forced[..n-1] - assert_eq!(ftx.pending_seed, 71); // last forced pending once - assert!(!ftx.commit.contains(&71)); - assert_eq!(ftx.position_delta, 2); - - let position = host.position + ftx.position_delta; - let generated = host.generated + forced.len(); - // host.position already counts the force-trigger write slot after prefill first. - assert_eq!(position, prompt.len() + 1 + ftx.position_delta); - assert_eq!(generated, 3); - - // Safe terminal: flush final pending seed exactly once. - let term = hipfire_generate::qwen::spec_terminal_pending_seed_tx(ftx.pending_seed); - assert_eq!(term.commit, vec![71]); - assert_eq!(term.position_delta, 1); - let final_pos = position + term.position_delta; - // Full history: prompt + first_token + trigger + forced (generated). - assert_eq!(final_pos, prompt.len() + 1 + generated); - - // Realign plan after force path still keeps last raw as unwritten seed. - let mut raw = vec![60u32]; - raw.extend_from_slice(forced); - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first, &raw); - assert_eq!(plan.seed_token, 71); - assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); - assert!(hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 1024, 1024, 0, false).is_ok()); - } - - // ── Task 4 Important vetoes (production seam pins) ───────────────────── - - /// max_tokens==0 rejects at hipfire_generate::qwen::generate_spec entry via the same writer the - /// production gate uses — before prefill/GPU/state/client mutation. - /// Wire: one correlated validation error, rolled_back=false, no done/aborted. - #[test] - fn zero_budget_max_tokens_preflight_error_only_no_done() { - set_active_attempt_id(101); - let mut sink = Vec::new(); - // Mirrors hipfire_generate::qwen::generate_spec entry gate (max_tokens == 0 → emit + return None). - hipfire_generate::dense::emit_active_attempt_error( - &mut sink, - Some("zb0"), - "max_tokens must be > 0", - "validation", - false, - false, - ); - let _ = std::io::Write::flush(&mut sink); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["id"], "zb0"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 101); - assert_eq!(lines[0]["message"], "max_tokens must be > 0"); - // No first token, no safe terminal flush, no aborted pair. - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"token""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - // Wrapper contract: hipfire_generate::qwen::generate_spec returned None → no epilogue. - assert!(!qwen_dflash_epilogue_after_spec_run(false)); - set_active_attempt_id(0); - } - - /// Cancel after rollback attestation: attested → aborted+done; unattested → - /// exactly one correlated nonretryable error with context and no done. - #[test] - fn cancel_after_rollback_attested_vs_unattested_wire() { - // Attested rollback keeps fold-compatible aborted + done pair. - set_active_attempt_id(202); - let mut sink_ok = Vec::new(); - let ep_ok = attest_epilogue(true); - hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink_ok, "c-ok", 7, &ep_ok); - let out_ok = String::from_utf8(sink_ok).unwrap(); - let lines_ok = parse_jsonl(&out_ok); - assert_eq!( - lines_ok.len(), - 2, - "attested cancel: aborted+done {lines_ok:?}" - ); - assert_eq!(lines_ok[0]["type"], "aborted"); - assert_eq!(lines_ok[0]["reason"], "client_cancelled"); - assert_eq!(lines_ok[0]["attempt_id"], 202); - assert_eq!(lines_ok[0]["id"], "c-ok"); - assert_eq!(lines_ok[1]["type"], "done"); - assert_eq!(lines_ok[1]["finish_reason"], "aborted"); - assert_eq!(lines_ok[1]["completion_tokens"], 7); - assert_eq!(lines_ok[1]["attempt_id"], 202); - assert!(!out_ok.contains(r#""type":"error""#)); - assert!(!out_ok.contains(r#""type":"tool_calls""#)); - - // Unattested rollback: one fail-closed error, no aborted/done. - set_active_attempt_id(203); - let mut sink_bad = Vec::new(); - let ep_bad = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); - assert!(!ep_bad.rolled_back); - hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink_bad, "c-bad", 3, &ep_bad); - let out_bad = String::from_utf8(sink_bad).unwrap(); - let lines_bad = parse_jsonl(&out_bad); - assert_eq!( - lines_bad.len(), - 1, - "unattested cancel: error only {lines_bad:?}" - ); - assert_eq!(lines_bad[0]["type"], "error"); - assert_eq!(lines_bad[0]["class"], "validation"); - assert_eq!(lines_bad[0]["retryable"], false); - assert_eq!(lines_bad[0]["rolled_back"], false); - assert_eq!(lines_bad[0]["attempt_id"], 203); - assert_eq!(lines_bad[0]["id"], "c-bad"); - let msg = lines_bad[0]["message"].as_str().unwrap(); - assert!( - msg.contains("client cancelled; fail-closed rollback could not be attested"), - "{msg}" - ); - assert!(msg.contains("device_synchronize failed"), "{msg}"); - assert!(!out_bad.contains(r#""type":"done""#)); - assert!(!out_bad.contains(r#""type":"aborted""#)); - assert!(!out_bad.contains(r#""type":"tool_calls""#)); - set_active_attempt_id(0); - } - - /// Failure-injection: each omitted reset class (incl. single-GPU s_ef_residual - /// and EP bind) keeps rolled_back=false; aggregate failure still models sync - /// as attempted; Qwen AR prefill/decode abort terminals are exclusive. - #[test] - fn rollback_attestation_omitted_reset_classes_and_ar_abort_xor() { - // Every required surface Ok + sync Ok → attested. - let all_ok = attest_rollback_steps( - &[ - ("s_matrices", Ok(())), - ("s_scales", Ok(())), - ("conv_states", Ok(())), - ("s_ef_residual", Ok(())), - ("host_cursors", Ok(())), - ("kv_compact", Ok(())), - ("checkpoints", Ok(())), - ("drafter", Ok(())), - ("adaptive", Ok(())), - ("graph_replay", Ok(())), - ("ep_bind_thread", Ok(())), - ], - Ok(()), - ); - assert!(all_ok.rolled_back); - assert!(all_ok.context.is_none()); - - // Single-GPU s_ef_residual omission/failure alone unattests. - let ef = attest_rollback_steps( - &[ - ("s_matrices", Ok(())), - ("s_scales", Ok(())), - ("conv_states", Ok(())), - ("s_ef_residual", Err("memset failed".into())), - ("ep_bind_thread", Ok(())), - ], - Ok(()), - ); - assert!(!ef.rolled_back); - let ctx = ef.context.as_deref().unwrap_or(""); - assert!(ctx.contains("s_ef_residual"), "{ctx}"); - assert!( - !ctx.contains("device_synchronize"), - "sync Ok must not appear: {ctx}" - ); - - // EP bind_thread failure alone unattests even when sync Ok. - let bind = attest_rollback_steps( - &[ - ("s_ef_residual", Ok(())), - ("ep_bind_thread", Err("hipErrorInvalidDevice".into())), - ], - Ok(()), - ); - assert!(!bind.rolled_back); - assert!( - bind.context - .as_deref() - .unwrap_or("") - .contains("ep_bind_thread"), - "{:?}", - bind.context - ); - - // Aggregate reset failure + sync still attempted (both in context). - let agg = attest_rollback_steps( - &[ - ("s_matrices", Err("m1".into())), - ("s_ef_residual", Err("ef".into())), - ("ep_bind_thread", Err("bind".into())), - ], - Err("hipErrorUnknown".into()), - ); - assert!(!agg.rolled_back); - let ctx = agg.context.as_deref().unwrap_or(""); - assert!(ctx.contains("s_matrices"), "{ctx}"); - assert!(ctx.contains("s_ef_residual"), "{ctx}"); - assert!(ctx.contains("ep_bind_thread"), "{ctx}"); - assert!(ctx.contains("device_synchronize failed"), "{ctx}"); - - // hipfire_generate::common::fail_closed_epilogue_after_sync: prior Err + sync Ok → unattested, sync ran. - let merged = hipfire_generate::common::fail_closed_epilogue_after_sync( - Err("hipfire_generate::common::reset_qwen35_recurrent: s_ef_residual memset: boom".into()), - hipfire_generate::common::RollbackEpilogue { - rolled_back: true, - context: None, - }, - ); - assert!(!merged.rolled_back); - assert!( - merged - .context - .as_deref() - .unwrap_or("") - .contains("s_ef_residual"), - "{:?}", - merged.context - ); - - // prior Err + sync Err → both preserved. - let both = hipfire_generate::common::fail_closed_epilogue_after_sync( - Err("ep rank0 bind_thread: bad".into()), - hipfire_generate::common::RollbackEpilogue { - rolled_back: false, - context: Some("device_synchronize failed: hipErrorUnknown".into()), - }, - ); - assert!(!both.rolled_back); - let ctx = both.context.as_deref().unwrap_or(""); - assert!(ctx.contains("bind_thread"), "{ctx}"); - assert!(ctx.contains("device_synchronize failed"), "{ctx}"); - - // Qwen AR prefill abort terminal exclusivity (attested vs unattested). - set_active_attempt_id(501); - let mut sink = Vec::new(); - hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink, "ar-prefill", 0, &attest_epilogue(true)); - let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0]["type"], "aborted"); - assert_eq!(lines[1]["type"], "done"); - assert_eq!(lines[1]["finish_reason"], "aborted"); - assert_eq!(lines[1]["completion_tokens"], 0); - assert!(lines.iter().all(|e| e["attempt_id"] == 501)); - - set_active_attempt_id(502); - let mut sink = Vec::new(); - hipfire_generate::common::emit_spec_cancel_after_rollback( - &mut sink, - "ar-prefill-bad", - 0, - &attest_epilogue_with_context("s_ef_residual memset: boom"), - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "prefill unattested: error only"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["rolled_back"], false); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - - // Qwen AR mid-decode abort terminal exclusivity. - set_active_attempt_id(503); - let mut sink = Vec::new(); - hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink, "ar-decode", 5, &attest_epilogue(true)); - let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0]["type"], "aborted"); - assert_eq!(lines[1]["finish_reason"], "aborted"); - assert_eq!(lines[1]["completion_tokens"], 5); - - set_active_attempt_id(504); - let mut sink = Vec::new(); - hipfire_generate::common::emit_spec_cancel_after_rollback( - &mut sink, - "ar-decode-bad", - 5, - &attest_epilogue_with_context( - "ep rank0 bind_thread: bad; device_synchronize failed: x", - ), - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "decode unattested: error only"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 504); - let msg = lines[0]["message"].as_str().unwrap(); - assert!(msg.contains("bind_thread"), "{msg}"); - assert!(msg.contains("device_synchronize failed"), "{msg}"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - set_active_attempt_id(0); - } - - /// Eviction-enabled missing optional kv_cache_mut is ErrorOnly (not panic): - /// hipfire_generate::qwen::classify_evict_failure_wire → hipfire_generate::common::emit_fail_closed_error with the production - /// post-prefill / per-cycle messages; no done/aborted/calls/cache. - #[test] - fn missing_optional_kv_cache_mut_is_error_only_not_panic() { - assert_eq!(hipfire_generate::qwen::classify_evict_failure_wire(), hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly); - assert_ne!( - hipfire_generate::qwen::SpecFailClosedWire::Cancelled, - hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly, - "missing KV hook must never classify as Cancelled" - ); - - for (attempt, id, message) in [ - (301u64, "kv-pp", "kv_cache_mut missing (post-prefill)"), - (302u64, "kv-pc", "kv_cache_mut missing (per-cycle)"), - ] { - set_active_attempt_id(attempt); - let mut sink = Vec::new(); - // Production seam: classify first, then fail-closed writer (same as - // hipfire_generate::qwen::generate_spec match slot.kv_cache_mut() { None => ... }). - let _ = hipfire_generate::qwen::classify_evict_failure_wire(); - let ep = attest_epilogue(true); - match hipfire_generate::qwen::classify_evict_failure_wire() { - hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly => { - hipfire_generate::common::emit_fail_closed_error(&mut sink, Some(id), message, "validation", false, &ep); - } - hipfire_generate::qwen::SpecFailClosedWire::Cancelled => { - panic!("kv_cache_mut missing must not classify Cancelled") - } - } - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "error XOR done for {message}: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], true); - assert_eq!(lines[0]["attempt_id"], attempt); - assert_eq!(lines[0]["id"], id); - assert_eq!(lines[0]["message"], message); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - // hipfire_generate::qwen::generate_spec returns None → wrapper skips cache store / epilogue. - assert!(!qwen_dflash_epilogue_after_spec_run(false)); - } - - // Unattested rollback on the same missing-hook path: rolled_back=false - // + context appended; still error-only (no panic surface). - set_active_attempt_id(303); - let mut sink = Vec::new(); - let ep = attest_epilogue_with_context("device_synchronize failed: test"); - hipfire_generate::common::emit_fail_closed_error( - &mut sink, - Some("kv-ua"), - "kv_cache_mut missing (post-prefill)", - "validation", - false, - &ep, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["rolled_back"], false); - let msg = lines[0]["message"].as_str().unwrap(); - assert!(msg.contains("kv_cache_mut missing (post-prefill)"), "{msg}"); - assert!(msg.contains("device_synchronize failed"), "{msg}"); - assert!(!out.contains(r#""type":"done""#)); - set_active_attempt_id(0); - } - - // ── Remaining Important Task 4 vetoes (wrapper / legacy / rewind) ── - - /// hipfire_generate::qwen::generate_dflash max_tokens==0: hipfire_generate::dense::emit_active_attempt_error then return true - /// (handled) before Jinja/render/set_sampling/gen_start. Same wire as the - /// inner hipfire_generate::qwen::generate_spec defense; wrapper must not fall through to AR. - #[test] - fn generate_dflash_zero_budget_preflight_handled_error_only() { - set_active_attempt_id(401); - let mut sink = Vec::new(); - // Mirrors hipfire_generate::qwen::generate_dflash entry (max_tokens == 0 → emit + return true). - hipfire_generate::dense::emit_active_attempt_error( - &mut sink, - Some("df-zb0"), - "max_tokens must be > 0", - "validation", - false, - false, - ); - let _ = std::io::Write::flush(&mut sink); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["id"], "df-zb0"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 401); - assert_eq!(lines[0]["message"], "max_tokens must be > 0"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"token""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - // Handled=true → caller must not fall through to AR / second envelope. - let wrapper_handled = true; - assert!(wrapper_handled); - set_active_attempt_id(0); - } - - /// hipfire_generate::dense::generate_deepseek4_spec max_tokens==0: same emit policy, plain return - /// (unit fn) before DSML render / decode-cache teardown / set_sampling. - #[test] - fn generate_deepseek4_spec_zero_budget_preflight_error_only() { - set_active_attempt_id(402); - let mut sink = Vec::new(); - // Mirrors hipfire_generate::dense::generate_deepseek4_spec entry (max_tokens == 0 → emit + return). - hipfire_generate::dense::emit_active_attempt_error( - &mut sink, - Some("ds4-zb0"), - "max_tokens must be > 0", - "validation", - false, - false, - ); - let _ = std::io::Write::flush(&mut sink); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["id"], "ds4-zb0"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 402); - assert_eq!(lines[0]["message"], "max_tokens must be > 0"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"token""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - // Unit wrapper returns (no AR fallthrough second write). - set_active_attempt_id(0); - } - - /// Legacy non-qwen hipfire_generate::qwen::generate_dflash else-branch: fail_closed_rollback.is_some() - /// || grammar_violated → hipfire_generate::common::emit_fail_closed_error only; no extract/release/ - /// cache store / done. Message classified by grammar / open_think / - /// malformed_protocol / generic. - #[test] - fn legacy_non_qwen_fail_closed_epilogue_error_only_no_extract() { - // Production message selection (qwen_semantic_v2 == false branch). - fn legacy_fail_closed_message( - grammar_violated: bool, - open_think: bool, - finish_reason: &str, - ) -> &'static str { - if grammar_violated { - "grammar violation during speculative decode" - } else if open_think || finish_reason == "open_think" { - "open think span at end of generation (validation)" - } else if finish_reason == "malformed_protocol" { - "malformed tool protocol" - } else { - "fail-closed speculative decode" - } - } - - let cases = [ - ( - true, - false, - "stop", - "grammar violation during speculative decode", - ), - ( - false, - true, - "stop", - "open think span at end of generation (validation)", - ), - ( - false, - false, - "open_think", - "open think span at end of generation (validation)", - ), - ( - false, - false, - "malformed_protocol", - "malformed tool protocol", - ), - (false, false, "length", "fail-closed speculative decode"), - ]; - - for (i, (grammar, open_think, reason, expected_msg)) in cases.iter().enumerate() { - assert_eq!( - legacy_fail_closed_message(*grammar, *open_think, reason), - *expected_msg, - "case {i} message select" + store_cache: true, + .. + } + )); +} + +#[test] +fn ordinary_length_cutoff_no_calls_no_cache() { + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "x", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*release_tool_calls); + assert!(!*store_cache); + assert!(wire_tool_calls.is_empty()); + } + other => panic!("{other:?}"), + } + assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); +} + +#[test] +fn cancel_is_fold_compatible_no_cache_helper() { + // Production cancel writer (same path as hipfire_generate::qwen::generate_spec abort sites). + set_active_attempt_id(11); + let mut sink = Vec::new(); + emit_qwen_ar_cancelled(&mut sink, "c", 3); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "aborted"); + assert_eq!(lines[0]["reason"], "client_cancelled"); + assert_eq!(lines[0]["attempt_id"], 11); + assert_eq!(lines[1]["type"], "done"); + assert_eq!(lines[1]["finish_reason"], "aborted"); + assert_eq!(lines[1]["completion_tokens"], 3); + // Cancel never goes through hipfire_generate::qwen::qwen_dflash_wire_terminal store path. + assert!(!out.contains(r#""finish_reason":"stop""#)); +} + +#[test] +fn serde_done_v2_hostile_id_roundtrip() { + set_active_attempt_id(5); + let id = "id\"quote\"\n"; + let mut sink = Vec::new(); + emit_qwen_dflash_done_terminal( + &mut sink, id, 2, 1.0, 1, 1.0, 1.0, 1.0, 1.0, 1.0, 1, 0, "stop", None, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "done"); + assert_eq!(lines[0]["id"], id); + assert_eq!(lines[0]["attempt_id"], 5); + assert_eq!(lines[0]["finish_reason"], "stop"); + assert_eq!(lines[0]["dflash"], true); +} + +#[test] +fn grammar_lifecycle_error_only_serialized() { + set_active_attempt_id(7); + let fin = summary_tool_calls(vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, true, "x", false); + let mut sink = Vec::new(); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { + message, + class, + retryable, + rolled_back, + } => { + let ep = attest_epilogue(*rolled_back); + hipfire_generate::qwen::emit_qwen_dflash_malformed_terminal( + &mut sink, "g1", message, class, *retryable, &ep, ); - // Gate: fail_closed_rollback.is_some() || grammar_violated. - let fail_closed_present = true; - let take_error_only = fail_closed_present || *grammar; - assert!(take_error_only, "case {i} must take error-only path"); - - set_active_attempt_id(500 + i as u64); - let mut sink = Vec::new(); - let ep = attest_epilogue(true); + } + other => panic!("{other:?}"), + } + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["attempt_id"], 7); + assert_eq!(lines[0]["id"], "g1"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); +} + +#[test] +fn serde_v2_token_and_tool_calls_hostile_id() { + set_active_attempt_id(9); + let mut sink = Vec::new(); + let id = "a\"b\n"; + hipfire_generate::qwen::render_client_events( + &mut sink, + id, + &[ + ClientEvent::Token("hi".into()), + ClientEvent::Reasoning("r".into()), + ], + 0, + false, + ); + emit_tool_calls_event( + &mut sink, + id, + &[ToolCall { + id: None, + name: "n".into(), + arguments: serde_json::json!({"x": 1}), + rendered_body: None, + }], + ); + let out = String::from_utf8(sink).unwrap(); + for line in out.lines().filter(|l| !l.is_empty()) { + let v: serde_json::Value = serde_json::from_str(line).expect(line); + assert_eq!(v["attempt_id"], 9); + assert_eq!(v["id"], id); + } + let types: Vec<_> = parse_jsonl(&out) + .into_iter() + .map(|v| v["type"].as_str().unwrap().to_string()) + .collect(); + assert!(types.contains(&"token".to_string())); + assert!(types.contains(&"reasoning".to_string())); + assert!(types.contains(&"tool_calls".to_string())); +} + +#[test] +fn cancel_wire_helpers_carry_attempt_id() { + // Production cancel writer carries attempt_id on aborted + done. + set_active_attempt_id(3); + let mut sink = Vec::new(); + emit_qwen_ar_cancelled(&mut sink, "c1", 5); + let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "aborted"); + assert_eq!(lines[0]["attempt_id"], 3); + assert_eq!(lines[0]["reason"], "client_cancelled"); + assert_eq!(lines[1]["type"], "done"); + assert_eq!(lines[1]["finish_reason"], "aborted"); + assert_eq!(lines[1]["attempt_id"], 3); + assert_eq!(lines[1]["completion_tokens"], 5); +} + +#[test] +fn cache_fingerprint_uses_visible_not_raw_markers() { + let fin = summary_stop("visible only"); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( + &fin, + false, + false, + "visible only", + false, + ); + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(!action.fingerprint_text.contains("")); + assert!(!action.fingerprint_text.contains("")); + assert!(action.fingerprint_text.contains("visible")); + let mut stored = None; + let fp = hipfire_generate::qwen::qwen_dflash_apply_cache_action( + |f, seq| { + stored = Some((f, seq)); + }, + &action, + vec![10, 20, 30], + ); + assert!(fp.is_some()); + let (f, seq) = stored.expect("insert"); + assert_eq!(seq, vec![10, 20, 30]); + assert_eq!( + f, + hipfire_generate::common::asst_turn_fingerprint( + &action.fingerprint_text, + &action.tool_calls + ) + ); +} + +#[test] +fn qwen_dflash_contract_version_is_v2() { + assert_eq!(QWEN_DFLASH_SEMANTIC_CONTRACT_VERSION, 2); + assert_eq!( + hipfire_generate::common::gen_start_contract_version_for_arch(5), + Some(2) + ); + assert_eq!( + hipfire_generate::common::gen_start_contract_version_for_arch(6), + Some(2) + ); +} + +#[test] +fn no_whole_output_parser_in_terminal_path() { + // Terminal path authority is FinishSummary fields only — a finish with + // empty held calls cannot invent tools from visible text markers. + let fin = FinishSummary { + events: vec![ClientEvent::Token( + "{\"name\":\"x\",\"arguments\":{}}".into(), + )], + finish_reason: "stop", + tool_calls: 0, + visible_text: String::new(), + decoded_eot: false, + open_think: false, + }; + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); + match term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + wire_tool_calls, + .. + } => { + assert_eq!(finish_reason, "stop"); + assert!(!release_tool_calls); + assert!(wire_tool_calls.is_empty()); + } + other => panic!("expected stop Done without invented calls, got {other:?}"), + } +} + +#[test] +fn production_done_value_builder_matches_epilogue_shape() { + let v = hipfire_generate::qwen::qwen_dflash_done_value( + "r", 3, 1.5, 10, 2.0, 5.0, 1.2, 2.0, 0.5, 2, 0, "length", 99, + ); + assert_eq!(v["type"], "done"); + assert_eq!(v["finish_reason"], "length"); + assert_eq!(v["attempt_id"], 99); + assert_eq!(v["dflash"], true); + assert_eq!(v["tokens"], 3); +} + +// --- Task 4 production-seam invariants (pending-seed / cancel / evict / +// capacity / jinja / wire / rollback attestation) --- + +#[test] +fn trigger_token_retained_before_forced_suffix_tx() { + // Forced GPU tx must first commit the current pending seed (the + // force-trigger), then forced[..n-1]. The trigger is never dropped. + let trigger = 77u32; + let forced = [10u32, 11, 12]; + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(trigger, &forced, true); + assert_eq!(tx.commit[0], trigger, "trigger must lead the commit batch"); + assert_eq!(tx.commit, vec![77, 10, 11]); + assert_eq!(tx.position_delta, forced.len()); + assert_eq!(tx.commit.len(), tx.position_delta); + // Trigger is not the new pending seed unless forced was length-1. + assert_ne!(tx.pending_seed, trigger); +} + +#[test] +fn final_forced_token_is_pending_exactly_once() { + // Last forced token becomes the unprocessed pending seed and MUST NOT + // also appear in commit (no double-forward). + let forced = [20u32, 21, 22]; + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(5, &forced, true); + assert_eq!(tx.pending_seed, 22); + assert!( + !tx.commit.contains(&22), + "last forced must stay unwritten: {:?}", + tx.commit + ); + assert_eq!(tx.commit, vec![5, 20, 21]); + // Single-token forced: commit is only the prior seed; forced[0] pending. + let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(99, &[42], true); + assert_eq!(one.commit, vec![99]); + assert_eq!(one.pending_seed, 42); + assert!(!one.commit.contains(&42)); + assert_eq!(one.position_delta, 1); +} + +#[test] +fn terminal_pending_seed_flush_exactly_once() { + let seed = 314u32; + let tx = hipfire_generate::qwen::spec_terminal_pending_seed_tx(seed); + assert_eq!(tx.commit, vec![seed]); + assert_eq!(tx.position_delta, 1); + assert_eq!(tx.commit.len(), 1, "flush commits the seed once"); + // Terminal flush ends with the same logical token as conversation + // (pending_seed field equals the committed token; no second lagging seed). + assert_eq!(tx.pending_seed, seed); +} + +#[test] +fn forced_max_tokens_clip_hard_ceiling() { + // generated already includes the trigger; no GPU for tokens past budget. + let forced = [1u32, 2, 3, 4, 5]; + assert_eq!( + hipfire_generate::qwen::spec_forced_tokens_within_budget(8, 10, &forced), + &[1, 2] + ); + assert_eq!( + hipfire_generate::qwen::spec_forced_tokens_within_budget(10, 10, &forced), + &[] as &[u32] + ); + assert_eq!( + hipfire_generate::qwen::spec_forced_tokens_within_budget(0, 3, &forced), + &[1, 2, 3] + ); + assert_eq!( + hipfire_generate::qwen::spec_forced_tokens_within_budget(9, 10, &forced), + &[1] + ); + // Composition: clip then build tx — only fitting tokens become pending. + let clipped = hipfire_generate::qwen::spec_forced_tokens_within_budget(7, 10, &forced); + assert_eq!(clipped, &[1, 2, 3]); + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(70, clipped, true); + assert_eq!(tx.commit, vec![70, 1, 2]); + assert_eq!(tx.pending_seed, 3); + assert!(!tx.commit.contains(&4) && !tx.commit.contains(&5)); +} + +#[test] +fn cancellation_classification_forced_gpu_advance() { + assert_eq!( + hipfire_generate::qwen::classify_forced_gpu_advance(false), + hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed + ); + assert_eq!( + hipfire_generate::qwen::classify_forced_gpu_advance(true), + hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled + ); + // Cancelled path must use aborted+done wire, never bake the forced token. + // ErrorOnly is reserved for eviction failures (XOR below). + assert_ne!( + hipfire_generate::qwen::SpecFailClosedWire::Cancelled, + hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly + ); + set_active_attempt_id(55); + let mut sink = Vec::new(); + match hipfire_generate::qwen::classify_forced_gpu_advance(true) { + hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled => { + emit_qwen_ar_cancelled(&mut sink, "c-force", 4); + } + hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed => { + panic!("abort must classify Cancelled") + } + } + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "aborted"); + assert_eq!(lines[0]["reason"], "client_cancelled"); + assert_eq!(lines[0]["attempt_id"], 55); + assert_eq!(lines[1]["type"], "done"); + assert_eq!(lines[1]["finish_reason"], "aborted"); + assert!(!out.contains(r#""type":"error""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + set_active_attempt_id(0); +} + +#[test] +fn eviction_error_terminal_exclusivity() { + // maybe_evict / on_evict Err → ErrorOnly: one fail-closed error, no done. + assert_eq!( + hipfire_generate::qwen::classify_evict_failure_wire(), + hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly + ); + set_active_attempt_id(66); + let mut sink = Vec::new(); + let ep = attest_epilogue(true); + match hipfire_generate::qwen::classify_evict_failure_wire() { + hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly => { hipfire_generate::common::emit_fail_closed_error( &mut sink, - Some("leg-fc"), - expected_msg, + Some("ev1"), + "on_evict: synthetic retain failure", "validation", false, &ep, ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "case {i}: error XOR done {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["id"], "leg-fc"); - assert_eq!(lines[0]["message"], *expected_msg); - // No held tool_calls release, no cache store, no done/aborted. - assert!(!out.contains(r#""type":"done""#), "case {i}"); - assert!(!out.contains(r#""type":"aborted""#), "case {i}"); - assert!(!out.contains(r#""type":"tool_calls""#), "case {i}"); - // Early return true from hipfire_generate::qwen::generate_dflash — no whole-output extract path. - let early_return_handled = true; - assert!(early_return_handled); } - set_active_attempt_id(0); - } - - /// hipfire_generate::qwen::generate_spec resume_from: on spec.rewind_to Err, host seq_pos / - /// conversation_tokens must NOT be truncated to ckpt first. Fail-closed - /// live rollback + one correlated "rewind_to: …" error; return None skips - /// wrapper epilogue (no done / calls / cache). - #[test] - fn rewind_to_err_freezes_host_cursors_then_fail_closed() { - // Host state as if mid-conversation before resume_from rewind. - let ckpt = 4usize; - let mut seq_pos = 12usize; - let mut conversation_tokens: Vec = (0..12).map(|t| t as u32).collect(); - let seq_before = seq_pos; - let toks_before = conversation_tokens.clone(); - - // Production order on Err: message first, then live rollback (which - // zeroes host), emit, return None — never the success truncate. - let restore_err = "DeltaNetSnapshot::restore_to: synthetic restore fail"; - let msg = format!("rewind_to: {restore_err}"); - - // Success path would do: seq_pos = ckpt; conversation_tokens.truncate(ckpt). - // Error path must NOT apply that before/without fail-closed. - let rewind_ok = false; - if rewind_ok { - seq_pos = ckpt; - conversation_tokens.truncate(ckpt); + hipfire_generate::qwen::SpecFailClosedWire::Cancelled => { + panic!("evict must not classify Cancelled") } - // Cursors still at pre-rewind values until hipfire_generate::common::production_fail_closed_rollback_live. - assert_eq!( - seq_pos, seq_before, - "must not truncate seq_pos to ckpt on Err" - ); - assert_eq!( - conversation_tokens, toks_before, - "must not truncate conversation_tokens to ckpt on Err" - ); - assert_ne!(seq_pos, ckpt); - - // Live rollback zeroes host (GPU-less stand-in for hipfire_generate::common::production_fail_closed_rollback_live). - seq_pos = 0; - conversation_tokens.clear(); - assert_eq!(seq_pos, 0); - assert!(conversation_tokens.is_empty()); - - set_active_attempt_id(601); + } + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "error XOR done: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], true); + assert_eq!(lines[0]["attempt_id"], 66); + assert_eq!(lines[0]["id"], "ev1"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + // Fail-closed early exit skips wrapper epilogue (same as step failure). + assert!(!qwen_dflash_epilogue_after_spec_run(false)); + set_active_attempt_id(0); +} + +#[test] +fn strict_prefix_replay_capacity_rejection() { + let prompt = vec![1u32, 2, 3]; + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 9, &[10, 11, 12]); + // plan.replay = [1,2,3,9,10,11], position=6, seed=12 + assert_eq!(plan.replay.len(), plan.position); + assert_eq!(plan.seed_token, 12); + assert!(!plan.replay.contains(&12)); + + // Fits both caps (position must be strictly < caps — pending seed slot). + assert!(hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 64, 64, 0, false).is_ok()); + // Boundary: position == cap leaves no legal write slot for pending seed. + let err_eq = + hipfire_generate::qwen::spec_prefix_realign_admit(&plan, plan.position, 64, 0, false) + .unwrap_err(); + assert!( + err_eq.contains("physical_cap"), + "expected position==physical_cap reject, got {err_eq}" + ); + + // Physical capacity rejection — fail closed before reset/prefill. + let err_phys = + hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 5, 64, 0, false).unwrap_err(); + assert!( + err_phys.contains("physical_cap"), + "expected physical_cap reject, got {err_phys}" + ); + + // Speculator ctx capacity rejection. + let err_ctx = + hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 64, 4, 0, false).unwrap_err(); + assert!( + err_ctx.contains("ctx_capacity"), + "expected ctx_capacity reject, got {err_ctx}" + ); + + // Broken invariant (replay/position mismatch) rejects even if caps large. + let broken = hipfire_generate::qwen::SpecPrefixRealignPlan { + replay: vec![1, 2], + position: 5, + seed_token: 9, + }; + let err_inv = + hipfire_generate::qwen::spec_prefix_realign_admit(&broken, 100, 100, 0, false).unwrap_err(); + assert!( + err_inv.contains("invariant") || err_inv.contains("pending"), + "expected invariant reject, got {err_inv}" + ); + + // Compacted/eviction path still fails closed on oversize full-history replay. + let err_ev = + hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 5, 64, 3, true).unwrap_err(); + assert!( + err_ev.contains("physical_cap") || err_ev.contains("compact"), + "expected compacted oversize reject, got {err_ev}" + ); + + // Capacity reject wires as exclusive error terminal (no done). + set_active_attempt_id(71); + let mut sink = Vec::new(); + let ep = attest_epilogue(true); + hipfire_generate::common::emit_fail_closed_error( + &mut sink, + Some("realign"), + &err_phys, + "validation", + false, + &ep, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["attempt_id"], 71); + assert!(!out.contains(r#""type":"done""#)); + set_active_attempt_id(0); +} + +#[test] +fn configured_jinja_render_fail_closed_policy() { + // Production hipfire_generate::qwen::generate_dflash configured-template Err path: + // hipfire_generate::dense::emit_active_attempt_error(class=validation, retryable=false, + // rolled_back=false, message="DFlash jinja render: …") then handled=true. + // Plain is not a silent fallback when a template is configured. + set_active_attempt_id(88); + let mut sink = Vec::new(); + let render_err = "undefined variable `messages`"; + hipfire_generate::dense::emit_active_attempt_error( + &mut sink, + Some("j1"), + &format!("DFlash jinja render: {render_err}"), + "validation", + false, + false, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 88); + assert_eq!(lines[0]["id"], "j1"); + let msg = lines[0]["message"].as_str().unwrap(); + assert!(msg.starts_with("DFlash jinja render:"), "{msg}"); + assert!(msg.contains(render_err), "{msg}"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"gen_start""#)); + // handled=true contract: early exit skips AR/done epilogue. + assert!(!qwen_dflash_epilogue_after_spec_run(false)); + set_active_attempt_id(0); +} + +#[test] +fn correlated_escaped_dflash_info_frame() { + // DFlash ctx-capacity fallback info uses serde + active attempt_id and + // must survive adversarial id/message bytes without breaking JSONL. + set_active_attempt_id(13); + let mut sink = Vec::new(); + let id = "id\"x\n\t\\"; + let message = "prompt=3 + max_tokens=9 exceeds DFlash draft ctx capacity 8 — falling back to AR (\"identical\" output)"; + emit_qwen_ar_info(&mut sink, id, message); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "info"); + assert_eq!(lines[0]["id"], id); + assert_eq!(lines[0]["message"], message); + assert_eq!(lines[0]["attempt_id"], 13); + // Round-trip proves escaping: re-serialize must still parse as one object. + let raw = out.lines().next().unwrap(); + let again: serde_json::Value = serde_json::from_str(raw).expect("serde-escaped info"); + assert_eq!(again["id"].as_str().unwrap(), id); + set_active_attempt_id(0); +} + +#[test] +fn rollback_attestation_false_on_sync_failure_surface() { + // No injectable mock GPU; production surface is hipfire_generate::common::RollbackEpilogue from + // hipfire_generate::common::fail_closed_device_sync on Err → rolled_back=false + context. + // hipfire_generate::common::emit_fail_closed_error must append context and claim rolled_back=false. + set_active_attempt_id(17); + let mut sink = Vec::new(); + let ep = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); + assert!(!ep.rolled_back); + assert!(ep + .context + .as_ref() + .unwrap() + .contains("device_synchronize failed")); + hipfire_generate::common::emit_fail_closed_error( + &mut sink, + Some("rb1"), + "forced-token advance: boom", + "validation", + false, + &ep, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 17); + let msg = lines[0]["message"].as_str().unwrap(); + assert!(msg.contains("forced-token advance: boom"), "{msg}"); + assert!(msg.contains("device_synchronize failed"), "{msg}"); + assert!(!out.contains(r#""type":"done""#)); + + // Attested success path still reports rolled_back=true without context suffix. + let mut sink_ok = Vec::new(); + let ep_ok = attest_epilogue(true); + hipfire_generate::common::emit_fail_closed_error( + &mut sink_ok, + Some("rb2"), + "spec_step: boom", + "validation", + false, + &ep_ok, + ); + let ok = parse_jsonl(&String::from_utf8(sink_ok).unwrap()); + assert_eq!(ok[0]["rolled_back"], true); + assert_eq!(ok[0]["message"], "spec_step: boom"); + set_active_attempt_id(0); +} + +#[test] +fn pending_seed_chain_trigger_clip_force_then_terminal_flush() { + // End-to-end pure chain defending the single pending-seed invariant: + // mid-window force trigger retained → budget clip → forced tx leaves + // last forced pending → safe terminal flushes that seed once. + let prompt = vec![1u32, 2]; + let first = 50u32; + // Consume force-trigger only from a wider speculative window. + let step = SpecStep::new([60u32, 61, 62], 62, 3, 2); + let host = hipfire_generate::qwen::spec_host_advance_after_step( + prompt.len(), + 0, + vec![first], + &step.emit, + step.next_seed, + 1, + ); + assert_eq!(host.seed_token, 60); // trigger retained as pending seed + assert_eq!(host.generated, 1); + + let forced_raw = [70u32, 71, 72, 73]; + // generated=1 (trigger counted); max_tokens=3 → room for 2 forced. + let forced = + hipfire_generate::qwen::spec_forced_tokens_within_budget(host.generated, 3, &forced_raw); + assert_eq!(forced, &[70, 71]); + let ftx = hipfire_generate::qwen::spec_forced_pending_seed_tx(host.seed_token, forced, true); + assert_eq!(ftx.commit, vec![60, 70]); // trigger + forced[..n-1] + assert_eq!(ftx.pending_seed, 71); // last forced pending once + assert!(!ftx.commit.contains(&71)); + assert_eq!(ftx.position_delta, 2); + + let position = host.position + ftx.position_delta; + let generated = host.generated + forced.len(); + // host.position already counts the force-trigger write slot after prefill first. + assert_eq!(position, prompt.len() + 1 + ftx.position_delta); + assert_eq!(generated, 3); + + // Safe terminal: flush final pending seed exactly once. + let term = hipfire_generate::qwen::spec_terminal_pending_seed_tx(ftx.pending_seed); + assert_eq!(term.commit, vec![71]); + assert_eq!(term.position_delta, 1); + let final_pos = position + term.position_delta; + // Full history: prompt + first_token + trigger + forced (generated). + assert_eq!(final_pos, prompt.len() + 1 + generated); + + // Realign plan after force path still keeps last raw as unwritten seed. + let mut raw = vec![60u32]; + raw.extend_from_slice(forced); + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first, &raw); + assert_eq!(plan.seed_token, 71); + assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); + assert!(hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 1024, 1024, 0, false).is_ok()); +} + +// ── Task 4 Important vetoes (production seam pins) ───────────────────── + +/// max_tokens==0 rejects at hipfire_generate::qwen::generate_spec entry via the same writer the +/// production gate uses — before prefill/GPU/state/client mutation. +/// Wire: one correlated validation error, rolled_back=false, no done/aborted. +#[test] +fn zero_budget_max_tokens_preflight_error_only_no_done() { + set_active_attempt_id(101); + let mut sink = Vec::new(); + // Mirrors hipfire_generate::qwen::generate_spec entry gate (max_tokens == 0 → emit + return None). + hipfire_generate::dense::emit_active_attempt_error( + &mut sink, + Some("zb0"), + "max_tokens must be > 0", + "validation", + false, + false, + ); + let _ = std::io::Write::flush(&mut sink); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["id"], "zb0"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 101); + assert_eq!(lines[0]["message"], "max_tokens must be > 0"); + // No first token, no safe terminal flush, no aborted pair. + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"token""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + // Wrapper contract: hipfire_generate::qwen::generate_spec returned None → no epilogue. + assert!(!qwen_dflash_epilogue_after_spec_run(false)); + set_active_attempt_id(0); +} + +/// Cancel after rollback attestation: attested → aborted+done; unattested → +/// exactly one correlated nonretryable error with context and no done. +#[test] +fn cancel_after_rollback_attested_vs_unattested_wire() { + // Attested rollback keeps fold-compatible aborted + done pair. + set_active_attempt_id(202); + let mut sink_ok = Vec::new(); + let ep_ok = attest_epilogue(true); + hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink_ok, "c-ok", 7, &ep_ok); + let out_ok = String::from_utf8(sink_ok).unwrap(); + let lines_ok = parse_jsonl(&out_ok); + assert_eq!( + lines_ok.len(), + 2, + "attested cancel: aborted+done {lines_ok:?}" + ); + assert_eq!(lines_ok[0]["type"], "aborted"); + assert_eq!(lines_ok[0]["reason"], "client_cancelled"); + assert_eq!(lines_ok[0]["attempt_id"], 202); + assert_eq!(lines_ok[0]["id"], "c-ok"); + assert_eq!(lines_ok[1]["type"], "done"); + assert_eq!(lines_ok[1]["finish_reason"], "aborted"); + assert_eq!(lines_ok[1]["completion_tokens"], 7); + assert_eq!(lines_ok[1]["attempt_id"], 202); + assert!(!out_ok.contains(r#""type":"error""#)); + assert!(!out_ok.contains(r#""type":"tool_calls""#)); + + // Unattested rollback: one fail-closed error, no aborted/done. + set_active_attempt_id(203); + let mut sink_bad = Vec::new(); + let ep_bad = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); + assert!(!ep_bad.rolled_back); + hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink_bad, "c-bad", 3, &ep_bad); + let out_bad = String::from_utf8(sink_bad).unwrap(); + let lines_bad = parse_jsonl(&out_bad); + assert_eq!( + lines_bad.len(), + 1, + "unattested cancel: error only {lines_bad:?}" + ); + assert_eq!(lines_bad[0]["type"], "error"); + assert_eq!(lines_bad[0]["class"], "validation"); + assert_eq!(lines_bad[0]["retryable"], false); + assert_eq!(lines_bad[0]["rolled_back"], false); + assert_eq!(lines_bad[0]["attempt_id"], 203); + assert_eq!(lines_bad[0]["id"], "c-bad"); + let msg = lines_bad[0]["message"].as_str().unwrap(); + assert!( + msg.contains("client cancelled; fail-closed rollback could not be attested"), + "{msg}" + ); + assert!(msg.contains("device_synchronize failed"), "{msg}"); + assert!(!out_bad.contains(r#""type":"done""#)); + assert!(!out_bad.contains(r#""type":"aborted""#)); + assert!(!out_bad.contains(r#""type":"tool_calls""#)); + set_active_attempt_id(0); +} + +/// Failure-injection: each omitted reset class (incl. single-GPU s_ef_residual +/// and EP bind) keeps rolled_back=false; aggregate failure still models sync +/// as attempted; Qwen AR prefill/decode abort terminals are exclusive. +#[test] +fn rollback_attestation_omitted_reset_classes_and_ar_abort_xor() { + // Every required surface Ok + sync Ok → attested. + let all_ok = attest_rollback_steps( + &[ + ("s_matrices", Ok(())), + ("s_scales", Ok(())), + ("conv_states", Ok(())), + ("s_ef_residual", Ok(())), + ("host_cursors", Ok(())), + ("kv_compact", Ok(())), + ("checkpoints", Ok(())), + ("drafter", Ok(())), + ("adaptive", Ok(())), + ("graph_replay", Ok(())), + ("ep_bind_thread", Ok(())), + ], + Ok(()), + ); + assert!(all_ok.rolled_back); + assert!(all_ok.context.is_none()); + + // Single-GPU s_ef_residual omission/failure alone unattests. + let ef = attest_rollback_steps( + &[ + ("s_matrices", Ok(())), + ("s_scales", Ok(())), + ("conv_states", Ok(())), + ("s_ef_residual", Err("memset failed".into())), + ("ep_bind_thread", Ok(())), + ], + Ok(()), + ); + assert!(!ef.rolled_back); + let ctx = ef.context.as_deref().unwrap_or(""); + assert!(ctx.contains("s_ef_residual"), "{ctx}"); + assert!( + !ctx.contains("device_synchronize"), + "sync Ok must not appear: {ctx}" + ); + + // EP bind_thread failure alone unattests even when sync Ok. + let bind = attest_rollback_steps( + &[ + ("s_ef_residual", Ok(())), + ("ep_bind_thread", Err("hipErrorInvalidDevice".into())), + ], + Ok(()), + ); + assert!(!bind.rolled_back); + assert!( + bind.context + .as_deref() + .unwrap_or("") + .contains("ep_bind_thread"), + "{:?}", + bind.context + ); + + // Aggregate reset failure + sync still attempted (both in context). + let agg = attest_rollback_steps( + &[ + ("s_matrices", Err("m1".into())), + ("s_ef_residual", Err("ef".into())), + ("ep_bind_thread", Err("bind".into())), + ], + Err("hipErrorUnknown".into()), + ); + assert!(!agg.rolled_back); + let ctx = agg.context.as_deref().unwrap_or(""); + assert!(ctx.contains("s_matrices"), "{ctx}"); + assert!(ctx.contains("s_ef_residual"), "{ctx}"); + assert!(ctx.contains("ep_bind_thread"), "{ctx}"); + assert!(ctx.contains("device_synchronize failed"), "{ctx}"); + + // hipfire_generate::common::fail_closed_epilogue_after_sync: prior Err + sync Ok → unattested, sync ran. + let merged = hipfire_generate::common::fail_closed_epilogue_after_sync( + Err("hipfire_generate::common::reset_qwen35_recurrent: s_ef_residual memset: boom".into()), + hipfire_generate::common::RollbackEpilogue { + rolled_back: true, + context: None, + }, + ); + assert!(!merged.rolled_back); + assert!( + merged + .context + .as_deref() + .unwrap_or("") + .contains("s_ef_residual"), + "{:?}", + merged.context + ); + + // prior Err + sync Err → both preserved. + let both = hipfire_generate::common::fail_closed_epilogue_after_sync( + Err("ep rank0 bind_thread: bad".into()), + hipfire_generate::common::RollbackEpilogue { + rolled_back: false, + context: Some("device_synchronize failed: hipErrorUnknown".into()), + }, + ); + assert!(!both.rolled_back); + let ctx = both.context.as_deref().unwrap_or(""); + assert!(ctx.contains("bind_thread"), "{ctx}"); + assert!(ctx.contains("device_synchronize failed"), "{ctx}"); + + // Qwen AR prefill abort terminal exclusivity (attested vs unattested). + set_active_attempt_id(501); + let mut sink = Vec::new(); + hipfire_generate::common::emit_spec_cancel_after_rollback( + &mut sink, + "ar-prefill", + 0, + &attest_epilogue(true), + ); + let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "aborted"); + assert_eq!(lines[1]["type"], "done"); + assert_eq!(lines[1]["finish_reason"], "aborted"); + assert_eq!(lines[1]["completion_tokens"], 0); + assert!(lines.iter().all(|e| e["attempt_id"] == 501)); + + set_active_attempt_id(502); + let mut sink = Vec::new(); + hipfire_generate::common::emit_spec_cancel_after_rollback( + &mut sink, + "ar-prefill-bad", + 0, + &attest_epilogue_with_context("s_ef_residual memset: boom"), + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "prefill unattested: error only"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["rolled_back"], false); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + + // Qwen AR mid-decode abort terminal exclusivity. + set_active_attempt_id(503); + let mut sink = Vec::new(); + hipfire_generate::common::emit_spec_cancel_after_rollback( + &mut sink, + "ar-decode", + 5, + &attest_epilogue(true), + ); + let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "aborted"); + assert_eq!(lines[1]["finish_reason"], "aborted"); + assert_eq!(lines[1]["completion_tokens"], 5); + + set_active_attempt_id(504); + let mut sink = Vec::new(); + hipfire_generate::common::emit_spec_cancel_after_rollback( + &mut sink, + "ar-decode-bad", + 5, + &attest_epilogue_with_context("ep rank0 bind_thread: bad; device_synchronize failed: x"), + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "decode unattested: error only"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 504); + let msg = lines[0]["message"].as_str().unwrap(); + assert!(msg.contains("bind_thread"), "{msg}"); + assert!(msg.contains("device_synchronize failed"), "{msg}"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + set_active_attempt_id(0); +} + +/// Eviction-enabled missing optional kv_cache_mut is ErrorOnly (not panic): +/// hipfire_generate::qwen::classify_evict_failure_wire → hipfire_generate::common::emit_fail_closed_error with the production +/// post-prefill / per-cycle messages; no done/aborted/calls/cache. +#[test] +fn missing_optional_kv_cache_mut_is_error_only_not_panic() { + assert_eq!( + hipfire_generate::qwen::classify_evict_failure_wire(), + hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly + ); + assert_ne!( + hipfire_generate::qwen::SpecFailClosedWire::Cancelled, + hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly, + "missing KV hook must never classify as Cancelled" + ); + + for (attempt, id, message) in [ + (301u64, "kv-pp", "kv_cache_mut missing (post-prefill)"), + (302u64, "kv-pc", "kv_cache_mut missing (per-cycle)"), + ] { + set_active_attempt_id(attempt); let mut sink = Vec::new(); + // Production seam: classify first, then fail-closed writer (same as + // hipfire_generate::qwen::generate_spec match slot.kv_cache_mut() { None => ... }). + let _ = hipfire_generate::qwen::classify_evict_failure_wire(); let ep = attest_epilogue(true); - hipfire_generate::common::emit_fail_closed_error(&mut sink, Some("rw-err"), &msg, "validation", false, &ep); + match hipfire_generate::qwen::classify_evict_failure_wire() { + hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly => { + hipfire_generate::common::emit_fail_closed_error( + &mut sink, + Some(id), + message, + "validation", + false, + &ep, + ); + } + hipfire_generate::qwen::SpecFailClosedWire::Cancelled => { + panic!("kv_cache_mut missing must not classify Cancelled") + } + } let out = String::from_utf8(sink).unwrap(); let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "one correlated rewind error: {lines:?}"); + assert_eq!(lines.len(), 1, "error XOR done for {message}: {lines:?}"); assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["id"], "rw-err"); assert_eq!(lines[0]["class"], "validation"); assert_eq!(lines[0]["retryable"], false); assert_eq!(lines[0]["rolled_back"], true); - assert_eq!(lines[0]["attempt_id"], 601); - assert_eq!(lines[0]["message"], msg); - assert!(lines[0]["message"] - .as_str() - .unwrap() - .starts_with("rewind_to:")); + assert_eq!(lines[0]["attempt_id"], attempt); + assert_eq!(lines[0]["id"], id); + assert_eq!(lines[0]["message"], message); assert!(!out.contains(r#""type":"done""#)); assert!(!out.contains(r#""type":"aborted""#)); assert!(!out.contains(r#""type":"tool_calls""#)); - // hipfire_generate::qwen::generate_spec returns None → wrapper skips epilogue/cache. + // hipfire_generate::qwen::generate_spec returns None → wrapper skips cache store / epilogue. assert!(!qwen_dflash_epilogue_after_spec_run(false)); - - // Unattested sync path still error-only with context suffix. - set_active_attempt_id(602); - let mut sink_ua = Vec::new(); - let ep_ua = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); - hipfire_generate::common::emit_fail_closed_error( - &mut sink_ua, - Some("rw-ua"), - &msg, - "validation", - false, - &ep_ua, - ); - let out_ua = String::from_utf8(sink_ua).unwrap(); - let lines_ua = parse_jsonl(&out_ua); - assert_eq!(lines_ua.len(), 1); - assert_eq!(lines_ua[0]["rolled_back"], false); - let m = lines_ua[0]["message"].as_str().unwrap(); - assert!(m.contains("rewind_to:"), "{m}"); - assert!(m.contains("device_synchronize failed"), "{m}"); - assert!(!out_ua.contains(r#""type":"done""#)); - set_active_attempt_id(0); } - // ── Task 4 definitive terminal-edge blockers ────────────────────────── - - /// Legacy non-qwen hipfire_generate::qwen::generate_dflash else-branch: length still emits - /// finish_reason=length but never releases held tool calls or stores - /// asst_turn_cache (partial/truncated turns are unsafe to prime). - #[test] - fn legacy_length_terminal_skips_assistant_cache_and_tool_release() { - // Production gates (hipfire_generate::qwen::generate_dflash qwen_semantic_v2=false branch): - // hit_length_cap = run.generated >= max_tokens - // stage_terminal_tool_calls on safe tool terminals before handshake - // asst_turn_cache.insert only when Commit && !hit_length_cap && !cached_seq.is_empty() - let generated = 8usize; - let max_tokens = 8usize; - let hit_length_cap = generated >= max_tokens; - assert!(hit_length_cap); - - let finish = summary_tool_calls(vec![ToolCall { - id: None, - name: "held".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]); - assert!(finish.tool_calls > 0); - - let release = !hit_length_cap && finish.tool_calls > 0; - assert!(!release, "length must not release held finish tool calls"); - - let cached_seq = vec![1u32, 2, 3]; - let mut sink: std::collections::HashMap> = std::collections::HashMap::new(); - if !hit_length_cap && !cached_seq.is_empty() { - let decoded_full = "partial answer"; - let stripped = hipfire_generate::common::strip_think_for_fingerprint(decoded_full); - let emit_text = - hipfire_runtime::tokenizer::maybe_normalize_prompt(&stripped).into_owned(); - let emit_tool_calls = extract_tool_calls_from_text(decoded_full); - let fp = hipfire_generate::common::asst_turn_fingerprint(&emit_text, &emit_tool_calls); - sink.insert(fp, cached_seq.clone()); - } - assert!( - sink.is_empty(), - "length terminal must not store asst_turn_cache" - ); - - let finish_reason = if hit_length_cap { - "length" - } else if finish.tool_calls > 0 { - "tool_calls" + // Unattested rollback on the same missing-hook path: rolled_back=false + // + context appended; still error-only (no panic surface). + set_active_attempt_id(303); + let mut sink = Vec::new(); + let ep = attest_epilogue_with_context("device_synchronize failed: test"); + hipfire_generate::common::emit_fail_closed_error( + &mut sink, + Some("kv-ua"), + "kv_cache_mut missing (post-prefill)", + "validation", + false, + &ep, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["rolled_back"], false); + let msg = lines[0]["message"].as_str().unwrap(); + assert!(msg.contains("kv_cache_mut missing (post-prefill)"), "{msg}"); + assert!(msg.contains("device_synchronize failed"), "{msg}"); + assert!(!out.contains(r#""type":"done""#)); + set_active_attempt_id(0); +} + +// ── Remaining Important Task 4 vetoes (wrapper / legacy / rewind) ── + +/// hipfire_generate::qwen::generate_dflash max_tokens==0: hipfire_generate::dense::emit_active_attempt_error then return true +/// (handled) before Jinja/render/set_sampling/gen_start. Same wire as the +/// inner hipfire_generate::qwen::generate_spec defense; wrapper must not fall through to AR. +#[test] +fn generate_dflash_zero_budget_preflight_handled_error_only() { + set_active_attempt_id(401); + let mut sink = Vec::new(); + // Mirrors hipfire_generate::qwen::generate_dflash entry (max_tokens == 0 → emit + return true). + hipfire_generate::dense::emit_active_attempt_error( + &mut sink, + Some("df-zb0"), + "max_tokens must be > 0", + "validation", + false, + false, + ); + let _ = std::io::Write::flush(&mut sink); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["id"], "df-zb0"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 401); + assert_eq!(lines[0]["message"], "max_tokens must be > 0"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"token""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + // Handled=true → caller must not fall through to AR / second envelope. + let wrapper_handled = true; + assert!(wrapper_handled); + set_active_attempt_id(0); +} + +/// hipfire_generate::dense::generate_deepseek4_spec max_tokens==0: same emit policy, plain return +/// (unit fn) before DSML render / decode-cache teardown / set_sampling. +#[test] +fn generate_deepseek4_spec_zero_budget_preflight_error_only() { + set_active_attempt_id(402); + let mut sink = Vec::new(); + // Mirrors hipfire_generate::dense::generate_deepseek4_spec entry (max_tokens == 0 → emit + return). + hipfire_generate::dense::emit_active_attempt_error( + &mut sink, + Some("ds4-zb0"), + "max_tokens must be > 0", + "validation", + false, + false, + ); + let _ = std::io::Write::flush(&mut sink); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["id"], "ds4-zb0"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 402); + assert_eq!(lines[0]["message"], "max_tokens must be > 0"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"token""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + // Unit wrapper returns (no AR fallthrough second write). + set_active_attempt_id(0); +} + +/// Legacy non-qwen hipfire_generate::qwen::generate_dflash else-branch: fail_closed_rollback.is_some() +/// || grammar_violated → hipfire_generate::common::emit_fail_closed_error only; no extract/release/ +/// cache store / done. Message classified by grammar / open_think / +/// malformed_protocol / generic. +#[test] +fn legacy_non_qwen_fail_closed_epilogue_error_only_no_extract() { + // Production message selection (qwen_semantic_v2 == false branch). + fn legacy_fail_closed_message( + grammar_violated: bool, + open_think: bool, + finish_reason: &str, + ) -> &'static str { + if grammar_violated { + "grammar violation during speculative decode" + } else if open_think || finish_reason == "open_think" { + "open think span at end of generation (validation)" + } else if finish_reason == "malformed_protocol" { + "malformed tool protocol" } else { - "stop" - }; - assert_eq!(finish_reason, "length"); - - // Safe non-length control: same gates allow release + store. - let hit_safe = 3usize >= 8usize; - assert!(!hit_safe); - assert!(!hit_safe && finish.tool_calls > 0); - let mut sink_safe = std::collections::HashMap::new(); - if !hit_safe && !cached_seq.is_empty() { - let fp = hipfire_generate::common::asst_turn_fingerprint("ok", &[]); - sink_safe.insert(fp, cached_seq.clone()); + "fail-closed speculative decode" } - assert_eq!(sink_safe.len(), 1, "safe stop still stores"); } - /// Begin-triggered forced continuation is planned with the same pure - /// pending-seed transaction as mid-window force, and is ordered before - /// any speculative step (max_tokens=1 cannot spend budget on step). - #[test] - fn begin_first_token_forced_serviced_before_spec_step() { - // After begin: generated counts first token when event-bearing. - let mut generated = 1usize; - let max_tokens = 1usize; - let seed_token = 50u32; // first_token is also the initial pending seed - let forced_begin = vec![60u32, 61, 62]; - - // Empty take_forced ⇒ Skipped (no GPU path); loop may proceed. - assert!(matches!( - // Pure stand-in for hipfire_generate::qwen::apply_spec_forced_pending_seed empty input. - { - let forced_all: &[u32] = &[]; - if forced_all.is_empty() { - hipfire_generate::qwen::SpecForcedApplyResult::Skipped - } else { - hipfire_generate::qwen::SpecForcedApplyResult::Applied - } - }, - hipfire_generate::qwen::SpecForcedApplyResult::Skipped - )); - - // Hard budget clip: generated already 1, max_tokens=1 → room 0. - let clipped = hipfire_generate::qwen::spec_forced_tokens_within_budget(generated, max_tokens, &forced_begin); - assert!( - clipped.is_empty(), - "max_tokens=1 after first token must clip all forced (no extra step budget)" - ); - // hipfire_generate::qwen::apply_spec_forced_pending_seed returns Skipped on empty clip — while - // condition `generated < max_tokens` is already false, so no spec.step. - assert!(!(!false /*first_token_is_eos*/ && generated < max_tokens)); - - // Room for forced (max_tokens=3, generated=1): same tx as mid-window. - generated = 1; - let max2 = 3usize; - let forced = hipfire_generate::qwen::spec_forced_tokens_within_budget(generated, max2, &forced_begin); - assert_eq!(forced, &[60u32, 61]); - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed_token, forced, true); - assert_eq!(tx.commit, vec![50, 60], "trigger retained; forced[..n-1]"); - assert_eq!(tx.pending_seed, 61, "last forced pending once"); - assert!(!tx.commit.contains(&61)); - assert_eq!(tx.position_delta, forced.len()); - - // Ordering contract: begin force runs before while/spec.step. - let mut phase = "begin"; - let forced_begin_nonempty = !forced_begin.is_empty(); - if forced_begin_nonempty { - phase = "begin_forced_applied"; - } - let enter_spec_step = phase == "begin_forced_applied" && generated < max2; - // After applying 2 forced, generated would be 1+2=3 → loop does not step. - let generated_after = generated + forced.len(); - assert_eq!(generated_after, 3); - assert!( - !(generated_after < max2), - "after begin force at budget, no speculative step" - ); - let _ = enter_spec_step; - assert_eq!(phase, "begin_forced_applied"); - - // hipfire_generate::qwen::classify_forced_gpu_advance still exclusive cancel vs commit. - assert!(matches!( - hipfire_generate::qwen::classify_forced_gpu_advance(true), - hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled - )); - assert!(matches!( - hipfire_generate::qwen::classify_forced_gpu_advance(false), - hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed - )); - } + let cases = [ + ( + true, + false, + "stop", + "grammar violation during speculative decode", + ), + ( + false, + true, + "stop", + "open think span at end of generation (validation)", + ), + ( + false, + false, + "open_think", + "open think span at end of generation (validation)", + ), + ( + false, + false, + "malformed_protocol", + "malformed tool protocol", + ), + (false, false, "length", "fail-closed speculative decode"), + ]; - /// Qwen first seed runs user stop-sequence detection in begin exactly like - /// later observe tokens; StopSequence terminates before any speculative step. - #[test] - fn qwen_begin_first_token_stop_sequence_terminates_before_step() { - let tok = test_tokenizer(); - let ids = tok.encode("STOP"); - assert!(!ids.is_empty()); - let first = ids[0]; - let first_text = tok.decode(&[first]); - let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { - tokenizer: &tok, - eos: 9, - im_end: Some(1), - tools: None, - stop: vec![first_text.clone()], - max_think: 0, - max_tokens: 256, - assistant_prefix: AssistantPrefix::Plain, - think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, - decoded_vocab: None, - }); - let first_begin = emit.begin(first); + for (i, (grammar, open_think, reason, expected_msg)) in cases.iter().enumerate() { assert_eq!( - first_begin.stop, - Some(StopReason::StopSequence), - "begin must surface StopSequence for first-token stop match" - ); - // hipfire_generate::qwen::generate_spec: first_token_is_eos = first_begin.stop.is_some() - let first_token_is_eos = first_begin.stop.is_some(); - assert!(first_token_is_eos); - // while !first_token_is_eos && generated < max_tokens { spec.step ... } - let mut stepped = false; - if !first_token_is_eos { - stepped = true; - } - assert!( - !stepped, - "StopSequence begin must skip every speculative step" - ); - - // Event-bearing first token still counts (Qwen always commits). - assert!( - hipfire_generate::qwen::spec_outcome_seed_committable(&first_begin), - "stop still commits the raw first token" - ); - assert!(first_begin - .events - .iter() - .any(|e| matches!(e, ClientEvent::Committed { id, .. } if *id == first))); - - // Forced begin path is still consulted, but empty take_forced is Skipped. - let forced_begin = emit.take_forced(); - assert!(forced_begin.is_empty()); - } - - // --- Task 4 reviewer blockers: forced-token / terminal-cause seams --- - - /// Non-committable pending seed (DS4 empty-event EOS) must not be prepended - /// into the forced GPU commit. Forced tokens occupy that same slot; all but - /// the final kept forced token are committed, final remains pending. - #[test] - fn noncommittable_pending_seed_omitted_from_forced_tx() { - // Single forced + non-committable seed: commit is empty (seed omitted, - // forced[0] becomes pending only) — no GPU for a lone seed replace. - let seed = 7u32; // DS4-style empty-event EOS seed - let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[42], false); - assert!( - one.commit.is_empty(), - "non-committable seed + single forced must not GPU-commit: {:?}", - one.commit + legacy_fail_closed_message(*grammar, *open_think, reason), + *expected_msg, + "case {i} message select" ); - assert_eq!(one.position_delta, 0); - assert_eq!(one.pending_seed, 42); - assert!(!one.commit.contains(&seed)); + // Gate: fail_closed_rollback.is_some() || grammar_violated. + let fail_closed_present = true; + let take_error_only = fail_closed_present || *grammar; + assert!(take_error_only, "case {i} must take error-only path"); - // Multi forced + non-committable: commit is forced[..n-1] only. - let multi = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[10, 11, 12], false); - assert_eq!( - multi.commit, - vec![10, 11], - "seed omitted; forced prefix only" + set_active_attempt_id(500 + i as u64); + let mut sink = Vec::new(); + let ep = attest_epilogue(true); + hipfire_generate::common::emit_fail_closed_error( + &mut sink, + Some("leg-fc"), + expected_msg, + "validation", + false, + &ep, ); - assert!(!multi.commit.contains(&seed)); - assert_eq!(multi.pending_seed, 12); - assert_eq!(multi.position_delta, multi.commit.len()); - assert!(!multi.commit.contains(&12), "last forced stays pending"); - - // Contrast: same inputs with committable seed retain the trigger. - let keep = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[10, 11, 12], true); - assert_eq!(keep.commit, vec![seed, 10, 11]); - assert_eq!(keep.pending_seed, 12); - } - - /// Forced suffix stages observe first, trims at the first non-None stop, - /// GPU-commits only that kept prefix, and renders only after successful - /// commit. Later forced tokens are never observed/committed/rendered. - #[test] - fn forced_suffix_stops_at_first_stop_sequence_prefix_only() { - let tok = test_tokenizer(); - // Build a stop string from a real token, then force a later token that - // must not be observed once stop fires. - let stop_ids = tok.encode("STOP"); - assert!(!stop_ids.is_empty()); - let stop_tok = stop_ids[0]; - let stop_text = tok.decode(&[stop_tok]); - let later = tok.encode("later"); - assert!(!later.is_empty()); - let later_tok = later[0]; - assert_ne!(stop_tok, later_tok); - - let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { - tokenizer: &tok, - eos: 9, - im_end: Some(1), - tools: None, - stop: vec![stop_text.clone()], - max_think: 0, - max_tokens: 256, - assistant_prefix: AssistantPrefix::Plain, - think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, - decoded_vocab: None, - }); - // Warm begin so observe path is active (forced uses observe). - let warm = tok.encode("hi"); - assert!(!warm.is_empty()); - let _ = emit.begin(warm[0]); - - // Production staging loop (hipfire_generate::qwen::apply_spec_forced_pending_seed): - let forced_all = [stop_tok, later_tok, later_tok.wrapping_add(1)]; - let mut staged: Vec<(u32, hipfire_runtime::spec::EmitOutcome)> = - Vec::with_capacity(forced_all.len()); - let mut stop_reason: Option = None; - for &ft in &forced_all { - let fo = emit.observe(ft); - let stop = fo.stop; - staged.push((ft, fo)); - if let Some(reason) = stop { - stop_reason = Some(reason); - break; + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "case {i}: error XOR done {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["id"], "leg-fc"); + assert_eq!(lines[0]["message"], *expected_msg); + // No held tool_calls release, no cache store, no done/aborted. + assert!(!out.contains(r#""type":"done""#), "case {i}"); + assert!(!out.contains(r#""type":"aborted""#), "case {i}"); + assert!(!out.contains(r#""type":"tool_calls""#), "case {i}"); + // Early return true from hipfire_generate::qwen::generate_dflash — no whole-output extract path. + let early_return_handled = true; + assert!(early_return_handled); + } + set_active_attempt_id(0); +} + +/// hipfire_generate::qwen::generate_spec resume_from: on spec.rewind_to Err, host seq_pos / +/// conversation_tokens must NOT be truncated to ckpt first. Fail-closed +/// live rollback + one correlated "rewind_to: …" error; return None skips +/// wrapper epilogue (no done / calls / cache). +#[test] +fn rewind_to_err_freezes_host_cursors_then_fail_closed() { + // Host state as if mid-conversation before resume_from rewind. + let ckpt = 4usize; + let mut seq_pos = 12usize; + let mut conversation_tokens: Vec = (0..12).map(|t| t as u32).collect(); + let seq_before = seq_pos; + let toks_before = conversation_tokens.clone(); + + // Production order on Err: message first, then live rollback (which + // zeroes host), emit, return None — never the success truncate. + let restore_err = "DeltaNetSnapshot::restore_to: synthetic restore fail"; + let msg = format!("rewind_to: {restore_err}"); + + // Success path would do: seq_pos = ckpt; conversation_tokens.truncate(ckpt). + // Error path must NOT apply that before/without fail-closed. + let rewind_ok = false; + if rewind_ok { + seq_pos = ckpt; + conversation_tokens.truncate(ckpt); + } + // Cursors still at pre-rewind values until hipfire_generate::common::production_fail_closed_rollback_live. + assert_eq!( + seq_pos, seq_before, + "must not truncate seq_pos to ckpt on Err" + ); + assert_eq!( + conversation_tokens, toks_before, + "must not truncate conversation_tokens to ckpt on Err" + ); + assert_ne!(seq_pos, ckpt); + + // Live rollback zeroes host (GPU-less stand-in for hipfire_generate::common::production_fail_closed_rollback_live). + seq_pos = 0; + conversation_tokens.clear(); + assert_eq!(seq_pos, 0); + assert!(conversation_tokens.is_empty()); + + set_active_attempt_id(601); + let mut sink = Vec::new(); + let ep = attest_epilogue(true); + hipfire_generate::common::emit_fail_closed_error( + &mut sink, + Some("rw-err"), + &msg, + "validation", + false, + &ep, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "one correlated rewind error: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["id"], "rw-err"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], true); + assert_eq!(lines[0]["attempt_id"], 601); + assert_eq!(lines[0]["message"], msg); + assert!(lines[0]["message"] + .as_str() + .unwrap() + .starts_with("rewind_to:")); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + // hipfire_generate::qwen::generate_spec returns None → wrapper skips epilogue/cache. + assert!(!qwen_dflash_epilogue_after_spec_run(false)); + + // Unattested sync path still error-only with context suffix. + set_active_attempt_id(602); + let mut sink_ua = Vec::new(); + let ep_ua = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); + hipfire_generate::common::emit_fail_closed_error( + &mut sink_ua, + Some("rw-ua"), + &msg, + "validation", + false, + &ep_ua, + ); + let out_ua = String::from_utf8(sink_ua).unwrap(); + let lines_ua = parse_jsonl(&out_ua); + assert_eq!(lines_ua.len(), 1); + assert_eq!(lines_ua[0]["rolled_back"], false); + let m = lines_ua[0]["message"].as_str().unwrap(); + assert!(m.contains("rewind_to:"), "{m}"); + assert!(m.contains("device_synchronize failed"), "{m}"); + assert!(!out_ua.contains(r#""type":"done""#)); + set_active_attempt_id(0); +} + +// ── Task 4 definitive terminal-edge blockers ────────────────────────── + +/// Legacy non-qwen hipfire_generate::qwen::generate_dflash else-branch: length still emits +/// finish_reason=length but never releases held tool calls or stores +/// asst_turn_cache (partial/truncated turns are unsafe to prime). +#[test] +fn legacy_length_terminal_skips_assistant_cache_and_tool_release() { + // Production gates (hipfire_generate::qwen::generate_dflash qwen_semantic_v2=false branch): + // hit_length_cap = run.generated >= max_tokens + // stage_terminal_tool_calls on safe tool terminals before handshake + // asst_turn_cache.insert only when Commit && !hit_length_cap && !cached_seq.is_empty() + let generated = 8usize; + let max_tokens = 8usize; + let hit_length_cap = generated >= max_tokens; + assert!(hit_length_cap); + + let finish = summary_tool_calls(vec![ToolCall { + id: None, + name: "held".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]); + assert!(finish.tool_calls > 0); + + let release = !hit_length_cap && finish.tool_calls > 0; + assert!(!release, "length must not release held finish tool calls"); + + let cached_seq = vec![1u32, 2, 3]; + let mut sink: std::collections::HashMap> = std::collections::HashMap::new(); + if !hit_length_cap && !cached_seq.is_empty() { + let decoded_full = "partial answer"; + let stripped = hipfire_generate::common::strip_think_for_fingerprint(decoded_full); + let emit_text = hipfire_runtime::tokenizer::maybe_normalize_prompt(&stripped).into_owned(); + let emit_tool_calls = extract_tool_calls_from_text(decoded_full); + let fp = hipfire_generate::common::asst_turn_fingerprint(&emit_text, &emit_tool_calls); + sink.insert(fp, cached_seq.clone()); + } + assert!( + sink.is_empty(), + "length terminal must not store asst_turn_cache" + ); + + let finish_reason = if hit_length_cap { + "length" + } else if finish.tool_calls > 0 { + "tool_calls" + } else { + "stop" + }; + assert_eq!(finish_reason, "length"); + + // Safe non-length control: same gates allow release + store. + let hit_safe = 3usize >= 8usize; + assert!(!hit_safe); + assert!(!hit_safe && finish.tool_calls > 0); + let mut sink_safe = std::collections::HashMap::new(); + if !hit_safe && !cached_seq.is_empty() { + let fp = hipfire_generate::common::asst_turn_fingerprint("ok", &[]); + sink_safe.insert(fp, cached_seq.clone()); + } + assert_eq!(sink_safe.len(), 1, "safe stop still stores"); +} + +/// Begin-triggered forced continuation is planned with the same pure +/// pending-seed transaction as mid-window force, and is ordered before +/// any speculative step (max_tokens=1 cannot spend budget on step). +#[test] +fn begin_first_token_forced_serviced_before_spec_step() { + // After begin: generated counts first token when event-bearing. + let mut generated = 1usize; + let max_tokens = 1usize; + let seed_token = 50u32; // first_token is also the initial pending seed + let forced_begin = vec![60u32, 61, 62]; + + // Empty take_forced ⇒ Skipped (no GPU path); loop may proceed. + assert!(matches!( + // Pure stand-in for hipfire_generate::qwen::apply_spec_forced_pending_seed empty input. + { + let forced_all: &[u32] = &[]; + if forced_all.is_empty() { + hipfire_generate::qwen::SpecForcedApplyResult::Skipped + } else { + hipfire_generate::qwen::SpecForcedApplyResult::Applied } + }, + hipfire_generate::qwen::SpecForcedApplyResult::Skipped + )); + + // Hard budget clip: generated already 1, max_tokens=1 → room 0. + let clipped = hipfire_generate::qwen::spec_forced_tokens_within_budget( + generated, + max_tokens, + &forced_begin, + ); + assert!( + clipped.is_empty(), + "max_tokens=1 after first token must clip all forced (no extra step budget)" + ); + // hipfire_generate::qwen::apply_spec_forced_pending_seed returns Skipped on empty clip — while + // condition `generated < max_tokens` is already false, so no spec.step. + assert!(!(!false /*first_token_is_eos*/ && generated < max_tokens)); + + // Room for forced (max_tokens=3, generated=1): same tx as mid-window. + generated = 1; + let max2 = 3usize; + let forced = + hipfire_generate::qwen::spec_forced_tokens_within_budget(generated, max2, &forced_begin); + assert_eq!(forced, &[60u32, 61]); + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed_token, forced, true); + assert_eq!(tx.commit, vec![50, 60], "trigger retained; forced[..n-1]"); + assert_eq!(tx.pending_seed, 61, "last forced pending once"); + assert!(!tx.commit.contains(&61)); + assert_eq!(tx.position_delta, forced.len()); + + // Ordering contract: begin force runs before while/spec.step. + let mut phase = "begin"; + let forced_begin_nonempty = !forced_begin.is_empty(); + if forced_begin_nonempty { + phase = "begin_forced_applied"; + } + let enter_spec_step = phase == "begin_forced_applied" && generated < max2; + // After applying 2 forced, generated would be 1+2=3 → loop does not step. + let generated_after = generated + forced.len(); + assert_eq!(generated_after, 3); + assert!( + !(generated_after < max2), + "after begin force at budget, no speculative step" + ); + let _ = enter_spec_step; + assert_eq!(phase, "begin_forced_applied"); + + // hipfire_generate::qwen::classify_forced_gpu_advance still exclusive cancel vs commit. + assert!(matches!( + hipfire_generate::qwen::classify_forced_gpu_advance(true), + hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled + )); + assert!(matches!( + hipfire_generate::qwen::classify_forced_gpu_advance(false), + hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed + )); +} + +/// Qwen first seed runs user stop-sequence detection in begin exactly like +/// later observe tokens; StopSequence terminates before any speculative step. +#[test] +fn qwen_begin_first_token_stop_sequence_terminates_before_step() { + let tok = test_tokenizer(); + let ids = tok.encode("STOP"); + assert!(!ids.is_empty()); + let first = ids[0]; + let first_text = tok.decode(&[first]); + let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { + tokenizer: &tok, + eos: 9, + im_end: Some(1), + tools: None, + stop: vec![first_text.clone()], + max_think: 0, + max_tokens: 256, + assistant_prefix: AssistantPrefix::Plain, + think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, + decoded_vocab: None, + }); + let first_begin = emit.begin(first); + assert_eq!( + first_begin.stop, + Some(StopReason::StopSequence), + "begin must surface StopSequence for first-token stop match" + ); + // hipfire_generate::qwen::generate_spec: first_token_is_eos = first_begin.stop.is_some() + let first_token_is_eos = first_begin.stop.is_some(); + assert!(first_token_is_eos); + // while !first_token_is_eos && generated < max_tokens { spec.step ... } + let mut stepped = false; + if !first_token_is_eos { + stepped = true; + } + assert!( + !stepped, + "StopSequence begin must skip every speculative step" + ); + + // Event-bearing first token still counts (Qwen always commits). + assert!( + hipfire_generate::qwen::spec_outcome_seed_committable(&first_begin), + "stop still commits the raw first token" + ); + assert!(first_begin + .events + .iter() + .any(|e| matches!(e, ClientEvent::Committed { id, .. } if *id == first))); + + // Forced begin path is still consulted, but empty take_forced is Skipped. + let forced_begin = emit.take_forced(); + assert!(forced_begin.is_empty()); +} + +// --- Task 4 reviewer blockers: forced-token / terminal-cause seams --- + +/// Non-committable pending seed (DS4 empty-event EOS) must not be prepended +/// into the forced GPU commit. Forced tokens occupy that same slot; all but +/// the final kept forced token are committed, final remains pending. +#[test] +fn noncommittable_pending_seed_omitted_from_forced_tx() { + // Single forced + non-committable seed: commit is empty (seed omitted, + // forced[0] becomes pending only) — no GPU for a lone seed replace. + let seed = 7u32; // DS4-style empty-event EOS seed + let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[42], false); + assert!( + one.commit.is_empty(), + "non-committable seed + single forced must not GPU-commit: {:?}", + one.commit + ); + assert_eq!(one.position_delta, 0); + assert_eq!(one.pending_seed, 42); + assert!(!one.commit.contains(&seed)); + + // Multi forced + non-committable: commit is forced[..n-1] only. + let multi = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[10, 11, 12], false); + assert_eq!( + multi.commit, + vec![10, 11], + "seed omitted; forced prefix only" + ); + assert!(!multi.commit.contains(&seed)); + assert_eq!(multi.pending_seed, 12); + assert_eq!(multi.position_delta, multi.commit.len()); + assert!(!multi.commit.contains(&12), "last forced stays pending"); + + // Contrast: same inputs with committable seed retain the trigger. + let keep = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[10, 11, 12], true); + assert_eq!(keep.commit, vec![seed, 10, 11]); + assert_eq!(keep.pending_seed, 12); +} + +/// Forced suffix stages observe first, trims at the first non-None stop, +/// GPU-commits only that kept prefix, and renders only after successful +/// commit. Later forced tokens are never observed/committed/rendered. +#[test] +fn forced_suffix_stops_at_first_stop_sequence_prefix_only() { + let tok = test_tokenizer(); + // Build a stop string from a real token, then force a later token that + // must not be observed once stop fires. + let stop_ids = tok.encode("STOP"); + assert!(!stop_ids.is_empty()); + let stop_tok = stop_ids[0]; + let stop_text = tok.decode(&[stop_tok]); + let later = tok.encode("later"); + assert!(!later.is_empty()); + let later_tok = later[0]; + assert_ne!(stop_tok, later_tok); + + let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { + tokenizer: &tok, + eos: 9, + im_end: Some(1), + tools: None, + stop: vec![stop_text.clone()], + max_think: 0, + max_tokens: 256, + assistant_prefix: AssistantPrefix::Plain, + think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, + decoded_vocab: None, + }); + // Warm begin so observe path is active (forced uses observe). + let warm = tok.encode("hi"); + assert!(!warm.is_empty()); + let _ = emit.begin(warm[0]); + + // Production staging loop (hipfire_generate::qwen::apply_spec_forced_pending_seed): + let forced_all = [stop_tok, later_tok, later_tok.wrapping_add(1)]; + let mut staged: Vec<(u32, hipfire_runtime::spec::EmitOutcome)> = + Vec::with_capacity(forced_all.len()); + let mut stop_reason: Option = None; + for &ft in &forced_all { + let fo = emit.observe(ft); + let stop = fo.stop; + staged.push((ft, fo)); + if let Some(reason) = stop { + stop_reason = Some(reason); + break; } - assert_eq!( - stop_reason, - Some(StopReason::StopSequence), - "first forced token matching stop must halt the suffix" - ); - assert_eq!( - staged.len(), - 1, - "later forced tokens must not be observed after stop" - ); - assert_eq!(staged[0].0, stop_tok); - - let kept: Vec = staged.iter().map(|(t, _)| *t).collect(); - assert_eq!(kept, vec![stop_tok]); - - // Commit uses the kept prefix only (incoming seed was committable). - let incoming_seed = warm[0]; - let incoming_committable = true; - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(incoming_seed, &kept, incoming_committable); - // Single kept forced: commit = [seed], pending = stop_tok. - assert_eq!(tx.commit, vec![incoming_seed]); - assert_eq!(tx.pending_seed, stop_tok); - assert!(!tx.commit.contains(&later_tok)); - assert!(!tx.commit.contains(&stop_tok)); - - // Apply result maps to Stopped(reason) — not Applied. - let apply = match stop_reason { - Some(reason) => hipfire_generate::qwen::SpecForcedApplyResult::Stopped(reason), - None => hipfire_generate::qwen::SpecForcedApplyResult::Applied, - }; - assert_eq!( - apply, - hipfire_generate::qwen::SpecForcedApplyResult::Stopped(StopReason::StopSequence) - ); - - // Render-after-commit contract: client events from staged outcomes are - // only eligible once GPU commit of `tx.commit` succeeded. Model the - // gate explicitly so a reorder (render then commit) fails this test. - let mut gpu_committed = false; - let mut rendered: Vec = Vec::new(); - // "commit" kept prefix - gpu_committed = true; - if gpu_committed { - for (ft, fo) in &staged { - if !fo.events.is_empty() { - rendered.push(*ft); - } + } + assert_eq!( + stop_reason, + Some(StopReason::StopSequence), + "first forced token matching stop must halt the suffix" + ); + assert_eq!( + staged.len(), + 1, + "later forced tokens must not be observed after stop" + ); + assert_eq!(staged[0].0, stop_tok); + + let kept: Vec = staged.iter().map(|(t, _)| *t).collect(); + assert_eq!(kept, vec![stop_tok]); + + // Commit uses the kept prefix only (incoming seed was committable). + let incoming_seed = warm[0]; + let incoming_committable = true; + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx( + incoming_seed, + &kept, + incoming_committable, + ); + // Single kept forced: commit = [seed], pending = stop_tok. + assert_eq!(tx.commit, vec![incoming_seed]); + assert_eq!(tx.pending_seed, stop_tok); + assert!(!tx.commit.contains(&later_tok)); + assert!(!tx.commit.contains(&stop_tok)); + + // Apply result maps to Stopped(reason) — not Applied. + let apply = match stop_reason { + Some(reason) => hipfire_generate::qwen::SpecForcedApplyResult::Stopped(reason), + None => hipfire_generate::qwen::SpecForcedApplyResult::Applied, + }; + assert_eq!( + apply, + hipfire_generate::qwen::SpecForcedApplyResult::Stopped(StopReason::StopSequence) + ); + + // Render-after-commit contract: client events from staged outcomes are + // only eligible once GPU commit of `tx.commit` succeeded. Model the + // gate explicitly so a reorder (render then commit) fails this test. + let mut gpu_committed = false; + let mut rendered: Vec = Vec::new(); + // "commit" kept prefix + gpu_committed = true; + if gpu_committed { + for (ft, fo) in &staged { + if !fo.events.is_empty() { + rendered.push(*ft); } } - assert!(gpu_committed); - assert_eq!( - rendered, - vec![stop_tok], - "render only kept prefix after commit" - ); - assert!(!rendered.contains(&later_tok)); } - - /// Begin and mid callers treat Stopped as turn-terminal: set semantic_stop, - /// force first_token_is_eos / hit_eos, and skip later force + all spec.step. - #[test] - fn begin_and_mid_stopped_skips_later_force_and_spec_step() { - // --- begin path (mirrors hipfire_generate::qwen::generate_spec after emit.begin) --- - let reason = StopReason::StopSequence; - let mut semantic_stop: Option = None; - let mut first_token_is_eos = false; - let apply = hipfire_generate::qwen::SpecForcedApplyResult::Stopped(reason); - match apply { - hipfire_generate::qwen::SpecForcedApplyResult::Terminal => panic!("not under test"), - hipfire_generate::qwen::SpecForcedApplyResult::Stopped(r) => { - if semantic_stop.is_none() && hipfire_generate::qwen::spec_stop_is_semantic(Some(r)) { - semantic_stop = Some(r); - } - first_token_is_eos = true; - } - hipfire_generate::qwen::SpecForcedApplyResult::Applied | hipfire_generate::qwen::SpecForcedApplyResult::Skipped => { - panic!("expected Stopped") + assert!(gpu_committed); + assert_eq!( + rendered, + vec![stop_tok], + "render only kept prefix after commit" + ); + assert!(!rendered.contains(&later_tok)); +} + +/// Begin and mid callers treat Stopped as turn-terminal: set semantic_stop, +/// force first_token_is_eos / hit_eos, and skip later force + all spec.step. +#[test] +fn begin_and_mid_stopped_skips_later_force_and_spec_step() { + // --- begin path (mirrors hipfire_generate::qwen::generate_spec after emit.begin) --- + let reason = StopReason::StopSequence; + let mut semantic_stop: Option = None; + let mut first_token_is_eos = false; + let apply = hipfire_generate::qwen::SpecForcedApplyResult::Stopped(reason); + match apply { + hipfire_generate::qwen::SpecForcedApplyResult::Terminal => panic!("not under test"), + hipfire_generate::qwen::SpecForcedApplyResult::Stopped(r) => { + if semantic_stop.is_none() && hipfire_generate::qwen::spec_stop_is_semantic(Some(r)) { + semantic_stop = Some(r); } + first_token_is_eos = true; } - assert_eq!(semantic_stop, Some(StopReason::StopSequence)); - assert!(first_token_is_eos); - - // while !first_token_is_eos && generated < max_tokens { spec.step ... } - let generated = 0usize; - let max_tokens = 16usize; - let mut stepped = false; - let mut later_force = false; - if !first_token_is_eos && generated < max_tokens { - // would take_forced + spec.step - later_force = true; - stepped = true; + hipfire_generate::qwen::SpecForcedApplyResult::Applied + | hipfire_generate::qwen::SpecForcedApplyResult::Skipped => { + panic!("expected Stopped") } - assert!( - !stepped && !later_force, - "begin Stopped must skip every subsequent force and spec.step" - ); - - // --- mid-window path (mirrors hipfire_generate::qwen::generate_spec forced_after match) --- - let mut semantic_stop_mid: Option = None; - let mut hit_eos = false; - let mut think_cap_hit = false; - let mid = hipfire_generate::qwen::SpecForcedApplyResult::Stopped(StopReason::StopSequence); - match mid { - hipfire_generate::qwen::SpecForcedApplyResult::Terminal => panic!("not under test"), - hipfire_generate::qwen::SpecForcedApplyResult::Stopped(r) => { - if semantic_stop_mid.is_none() && hipfire_generate::qwen::spec_stop_is_semantic(Some(r)) { - semantic_stop_mid = Some(r); - } - match r { - StopReason::ThinkCap => think_cap_hit = true, - StopReason::Eos | StopReason::StopSequence | StopReason::GrammarViolation => { - hit_eos = true - } - } + } + assert_eq!(semantic_stop, Some(StopReason::StopSequence)); + assert!(first_token_is_eos); + + // while !first_token_is_eos && generated < max_tokens { spec.step ... } + let generated = 0usize; + let max_tokens = 16usize; + let mut stepped = false; + let mut later_force = false; + if !first_token_is_eos && generated < max_tokens { + // would take_forced + spec.step + later_force = true; + stepped = true; + } + assert!( + !stepped && !later_force, + "begin Stopped must skip every subsequent force and spec.step" + ); + + // --- mid-window path (mirrors hipfire_generate::qwen::generate_spec forced_after match) --- + let mut semantic_stop_mid: Option = None; + let mut hit_eos = false; + let mut think_cap_hit = false; + let mid = hipfire_generate::qwen::SpecForcedApplyResult::Stopped(StopReason::StopSequence); + match mid { + hipfire_generate::qwen::SpecForcedApplyResult::Terminal => panic!("not under test"), + hipfire_generate::qwen::SpecForcedApplyResult::Stopped(r) => { + if semantic_stop_mid.is_none() && hipfire_generate::qwen::spec_stop_is_semantic(Some(r)) + { + semantic_stop_mid = Some(r); } - hipfire_generate::qwen::SpecForcedApplyResult::Applied | hipfire_generate::qwen::SpecForcedApplyResult::Skipped => { - panic!("expected Stopped") + match r { + StopReason::ThinkCap => think_cap_hit = true, + StopReason::Eos | StopReason::StopSequence | StopReason::GrammarViolation => { + hit_eos = true + } } } - assert_eq!(semantic_stop_mid, Some(StopReason::StopSequence)); - assert!(hit_eos); - assert!(!think_cap_hit); - - // After mid Stopped the cycle must not re-enter force or continue the - // outer decode as if Applied. Model the break: no second take_forced. - let mut second_force_applied = false; - if !hit_eos && !think_cap_hit { - second_force_applied = true; + hipfire_generate::qwen::SpecForcedApplyResult::Applied + | hipfire_generate::qwen::SpecForcedApplyResult::Skipped => { + panic!("expected Stopped") } - assert!( - !second_force_applied, - "mid Stopped must not apply a later forced suffix" - ); - - // hipfire_generate::common::SpecRun carries semantic_stop into the wrapper independently of EOT. - let run_semantic = semantic_stop_mid; - assert!(run_semantic.is_some()); - assert!(hipfire_generate::qwen::spec_stop_is_semantic(run_semantic)); } - - /// First-token user stop at max_tokens=1 must classify as stop (not length) - /// via semantic_stop surviving independently of decoded_eot. - #[test] - fn first_token_stop_sequence_at_max_tokens_one_is_stop_not_length() { - let tok = test_tokenizer(); - let ids = tok.encode("STOP"); - assert!(!ids.is_empty()); - let first = ids[0]; - let first_text = tok.decode(&[first]); - let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { - tokenizer: &tok, - eos: 9, - im_end: Some(1), - tools: None, - stop: vec![first_text.clone()], - max_think: 0, - max_tokens: 1, - assistant_prefix: AssistantPrefix::Plain, - think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, - decoded_vocab: None, - }); - let first_begin = emit.begin(first); - assert_eq!(first_begin.stop, Some(StopReason::StopSequence)); - - // hipfire_generate::qwen::generate_spec sticky capture (begin path). - let mut semantic_stop: Option = if hipfire_generate::qwen::spec_stop_is_semantic(first_begin.stop) { + assert_eq!(semantic_stop_mid, Some(StopReason::StopSequence)); + assert!(hit_eos); + assert!(!think_cap_hit); + + // After mid Stopped the cycle must not re-enter force or continue the + // outer decode as if Applied. Model the break: no second take_forced. + let mut second_force_applied = false; + if !hit_eos && !think_cap_hit { + second_force_applied = true; + } + assert!( + !second_force_applied, + "mid Stopped must not apply a later forced suffix" + ); + + // hipfire_generate::common::SpecRun carries semantic_stop into the wrapper independently of EOT. + let run_semantic = semantic_stop_mid; + assert!(run_semantic.is_some()); + assert!(hipfire_generate::qwen::spec_stop_is_semantic(run_semantic)); +} + +/// First-token user stop at max_tokens=1 must classify as stop (not length) +/// via semantic_stop surviving independently of decoded_eot. +#[test] +fn first_token_stop_sequence_at_max_tokens_one_is_stop_not_length() { + let tok = test_tokenizer(); + let ids = tok.encode("STOP"); + assert!(!ids.is_empty()); + let first = ids[0]; + let first_text = tok.decode(&[first]); + let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { + tokenizer: &tok, + eos: 9, + im_end: Some(1), + tools: None, + stop: vec![first_text.clone()], + max_think: 0, + max_tokens: 1, + assistant_prefix: AssistantPrefix::Plain, + think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, + decoded_vocab: None, + }); + let first_begin = emit.begin(first); + assert_eq!(first_begin.stop, Some(StopReason::StopSequence)); + + // hipfire_generate::qwen::generate_spec sticky capture (begin path). + let mut semantic_stop: Option = + if hipfire_generate::qwen::spec_stop_is_semantic(first_begin.stop) { first_begin.stop } else { None }; - assert_eq!(semantic_stop, Some(StopReason::StopSequence)); - assert!(hipfire_generate::qwen::spec_stop_is_semantic(semantic_stop)); - - // Budget spent on the first (and only) token; no decoded_eot required. - let generated = 1usize; - let max_tokens = 1usize; - let decoded_eot = false; // user stop may not set EOT - let hit_length = - hipfire_generate::common::qwen_dflash_hit_length_cap(generated, max_tokens, decoded_eot, semantic_stop.is_some()); - assert!( - !hit_length, - "semantic StopSequence at cap must not classify as length" - ); - - // Wrapper wire: stop, not length. - let fin = summary_stop(&first_text); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, hit_length, false, &first_text, false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - store_cache, - release_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "stop"); - assert!(*store_cache); - assert!(!*release_tool_calls); - } - other => panic!("expected stop Done, got {other:?}"), - } - - // Contrast: same numbers without semantic_stop → length. - assert!(hipfire_generate::common::qwen_dflash_hit_length_cap(1, 1, false, false)); - let _ = &mut semantic_stop; - } - - /// Held tool_calls + semantic stop at the budget boundary must finish as - /// tool_calls (not length). hipfire_generate::common::finish_summary_held_tool_calls feeds the wire. - #[test] - fn held_tool_calls_with_semantic_stop_at_cap_is_tool_calls_not_length() { - let calls = vec![ToolCall { - id: None, - name: "get_weather".into(), - arguments: serde_json::json!({"city": "SF"}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls.clone()); - let held = hipfire_generate::common::finish_summary_held_tool_calls(&fin); - assert_eq!(held.len(), 1); - assert_eq!(held[0].name, "get_weather"); - - // generated == max_tokens, no decoded_eot, but semantic stop sticky. - let generated = 8usize; - let max_tokens = 8usize; - let decoded_eot = false; - let semantic_stop = Some(StopReason::StopSequence); - assert!(hipfire_generate::qwen::spec_stop_is_semantic(semantic_stop)); - let hit_length = - hipfire_generate::common::qwen_dflash_hit_length_cap(generated, max_tokens, decoded_eot, semantic_stop.is_some()); - assert!(!hit_length, "semantic stop must beat length at cap"); - - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, hit_length, false, "Sure.", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - wire_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "tool_calls"); - assert!(*release_tool_calls); - assert!(*store_cache); - assert_eq!(wire_tool_calls.len(), 1); - assert_eq!(wire_tool_calls[0].name, "get_weather"); - } - other => panic!("expected tool_calls Done, got {other:?}"), - } - - // Without semantic_stop the same finish would be suppressed as length. - assert!(hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, false, false)); - let length_term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "Sure.", false); - match &length_term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - wire_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "length"); - assert!(!*release_tool_calls); - assert!(wire_tool_calls.is_empty()); - } - other => panic!("expected length Done, got {other:?}"), + assert_eq!(semantic_stop, Some(StopReason::StopSequence)); + assert!(hipfire_generate::qwen::spec_stop_is_semantic(semantic_stop)); + + // Budget spent on the first (and only) token; no decoded_eot required. + let generated = 1usize; + let max_tokens = 1usize; + let decoded_eot = false; // user stop may not set EOT + let hit_length = hipfire_generate::common::qwen_dflash_hit_length_cap( + generated, + max_tokens, + decoded_eot, + semantic_stop.is_some(), + ); + assert!( + !hit_length, + "semantic StopSequence at cap must not classify as length" + ); + + // Wrapper wire: stop, not length. + let fin = summary_stop(&first_text); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( + &fin, + hit_length, + false, + &first_text, + false, + ); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + store_cache, + release_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "stop"); + assert!(*store_cache); + assert!(!*release_tool_calls); } - } - - // ── Task 4 forced-continuation physical-cap admission ───────────────── - - /// Pure admission: no-eviction requires a free pending-seed write slot - /// after the commit (`post_position < physical_cap`). Exact-cap rejects. - #[test] - fn forced_commit_no_evict_exact_cap_rejects_pending_seed_slot() { - let physical_cap = 16usize; - let position = 12usize; - let commit_len = 4usize; // post_position == physical_cap - assert_eq!(position.saturating_add(commit_len), physical_cap); - assert!( - !hipfire_generate::qwen::spec_forced_commit_admits(position, commit_len, physical_cap, false), - "no-eviction exact-cap must reject: pending seed needs a legal slot" - ); - // One slot under cap still fits (post == cap-1). - assert!(hipfire_generate::qwen::spec_forced_commit_admits( - position, - commit_len.saturating_sub(1), - physical_cap, - false - )); - // Over-cap also rejects. - assert!(!hipfire_generate::qwen::spec_forced_commit_admits( - position, - commit_len.saturating_add(1), - physical_cap, - false - )); - } - - /// Eviction path still refuses post_position > physical_cap before any GPU - /// write. Exact-cap is the only boundary that eviction may open. - #[test] - fn forced_commit_eviction_over_cap_rejects_before_gpu() { - let physical_cap = 16usize; - let position = 12usize; - let over = 5usize; // post_position = 17 > cap - assert!(position.saturating_add(over) > physical_cap); - assert!( - !hipfire_generate::qwen::spec_forced_commit_admits(position, over, physical_cap, true), - "eviction must not admit over-cap commits" - ); - - // Deterministic pre-GPU gate: reject ⇒ no GPU commit, no staged render. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - enum Phase { - Staged, - GpuCommitted, - Rendered, - ErrorOnly, + other => panic!("expected stop Done, got {other:?}"), + } + + // Contrast: same numbers without semantic_stop → length. + assert!(hipfire_generate::common::qwen_dflash_hit_length_cap( + 1, 1, false, false + )); + let _ = &mut semantic_stop; +} + +/// Held tool_calls + semantic stop at the budget boundary must finish as +/// tool_calls (not length). hipfire_generate::common::finish_summary_held_tool_calls feeds the wire. +#[test] +fn held_tool_calls_with_semantic_stop_at_cap_is_tool_calls_not_length() { + let calls = vec![ToolCall { + id: None, + name: "get_weather".into(), + arguments: serde_json::json!({"city": "SF"}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls.clone()); + let held = hipfire_generate::common::finish_summary_held_tool_calls(&fin); + assert_eq!(held.len(), 1); + assert_eq!(held[0].name, "get_weather"); + + // generated == max_tokens, no decoded_eot, but semantic stop sticky. + let generated = 8usize; + let max_tokens = 8usize; + let decoded_eot = false; + let semantic_stop = Some(StopReason::StopSequence); + assert!(hipfire_generate::qwen::spec_stop_is_semantic(semantic_stop)); + let hit_length = hipfire_generate::common::qwen_dflash_hit_length_cap( + generated, + max_tokens, + decoded_eot, + semantic_stop.is_some(), + ); + assert!(!hit_length, "semantic stop must beat length at cap"); + + let term = + hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, hit_length, false, "Sure.", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "tool_calls"); + assert!(*release_tool_calls); + assert!(*store_cache); + assert_eq!(wire_tool_calls.len(), 1); + assert_eq!(wire_tool_calls[0].name, "get_weather"); } - let admitted = hipfire_generate::qwen::spec_forced_commit_admits(position, over, physical_cap, true); - let mut phase = Phase::Staged; - let mut rendered = 0usize; - if !admitted { - // Production: rollback + ErrorOnly terminal; discard staged events. - phase = Phase::ErrorOnly; - } else { - phase = Phase::GpuCommitted; - phase = Phase::Rendered; - rendered = 1; + other => panic!("expected tool_calls Done, got {other:?}"), + } + + // Without semantic_stop the same finish would be suppressed as length. + assert!(hipfire_generate::common::qwen_dflash_hit_length_cap( + 8, 8, false, false + )); + let length_term = + hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "Sure.", false); + match &length_term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + wire_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*release_tool_calls); + assert!(wire_tool_calls.is_empty()); } - assert_eq!(phase, Phase::ErrorOnly); - assert_eq!( - rendered, 0, - "capacity reject must never render staged events" - ); - assert_ne!(phase, Phase::GpuCommitted); - assert_ne!(phase, Phase::Rendered); - // Same wire class as maybe_evict / on_evict failures. - assert_eq!(hipfire_generate::qwen::classify_evict_failure_wire(), hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly); - } - - /// Eviction exact-cap admits only because post-commit maybe_evict+on_evict - /// is mandatory before host seed/raw/render and must leave a free seed slot. - #[test] - fn forced_commit_eviction_exact_cap_admits_with_mandatory_post_commit_evict() { - let physical_cap = 16usize; - let position = 12usize; - let commit_len = 4usize; // post_position == physical_cap - assert_eq!(position.saturating_add(commit_len), physical_cap); - - assert!( - hipfire_generate::qwen::spec_forced_commit_admits(position, commit_len, physical_cap, true), - "eviction may admit exact-cap" - ); - // Contrast: same numbers without eviction reject. - assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + other => panic!("expected length Done, got {other:?}"), + } +} + +// ── Task 4 forced-continuation physical-cap admission ───────────────── + +/// Pure admission: no-eviction requires a free pending-seed write slot +/// after the commit (`post_position < physical_cap`). Exact-cap rejects. +#[test] +fn forced_commit_no_evict_exact_cap_rejects_pending_seed_slot() { + let physical_cap = 16usize; + let position = 12usize; + let commit_len = 4usize; // post_position == physical_cap + assert_eq!(position.saturating_add(commit_len), physical_cap); + assert!( + !hipfire_generate::qwen::spec_forced_commit_admits( position, commit_len, physical_cap, false - )); - - // Ordering model for the admitted exact-cap path: GPU commit → mandatory - // post-commit eviction → require post_evict < physical_cap → only then - // host position/seed/raw/render. Skipping eviction must not reach render. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - enum Step { - Admit, - GpuCommit, - PostCommitEvict, - HostRender, - ErrorOnly, - } - let mut steps: Vec = Vec::new(); - let admitted = hipfire_generate::qwen::spec_forced_commit_admits(position, commit_len, physical_cap, true); - assert!(admitted); - steps.push(Step::Admit); - steps.push(Step::GpuCommit); - - let eviction_enabled = true; - let mut post_position = position.saturating_add(commit_len); - let mut rendered = false; - if eviction_enabled { - // Mandatory: maybe_evict + on_evict before host updates. - steps.push(Step::PostCommitEvict); - // Synthetic successful compaction frees the pending-seed slot. - post_position = physical_cap.saturating_sub(1); - if post_position >= physical_cap { - steps.push(Step::ErrorOnly); - } else { - steps.push(Step::HostRender); - rendered = true; - } + ), + "no-eviction exact-cap must reject: pending seed needs a legal slot" + ); + // One slot under cap still fits (post == cap-1). + assert!(hipfire_generate::qwen::spec_forced_commit_admits( + position, + commit_len.saturating_sub(1), + physical_cap, + false + )); + // Over-cap also rejects. + assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + position, + commit_len.saturating_add(1), + physical_cap, + false + )); +} + +/// Eviction path still refuses post_position > physical_cap before any GPU +/// write. Exact-cap is the only boundary that eviction may open. +#[test] +fn forced_commit_eviction_over_cap_rejects_before_gpu() { + let physical_cap = 16usize; + let position = 12usize; + let over = 5usize; // post_position = 17 > cap + assert!(position.saturating_add(over) > physical_cap); + assert!( + !hipfire_generate::qwen::spec_forced_commit_admits(position, over, physical_cap, true), + "eviction must not admit over-cap commits" + ); + + // Deterministic pre-GPU gate: reject ⇒ no GPU commit, no staged render. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Phase { + Staged, + GpuCommitted, + Rendered, + ErrorOnly, + } + let admitted = + hipfire_generate::qwen::spec_forced_commit_admits(position, over, physical_cap, true); + let mut phase = Phase::Staged; + let mut rendered = 0usize; + if !admitted { + // Production: rollback + ErrorOnly terminal; discard staged events. + phase = Phase::ErrorOnly; + } else { + phase = Phase::GpuCommitted; + phase = Phase::Rendered; + rendered = 1; + } + assert_eq!(phase, Phase::ErrorOnly); + assert_eq!( + rendered, 0, + "capacity reject must never render staged events" + ); + assert_ne!(phase, Phase::GpuCommitted); + assert_ne!(phase, Phase::Rendered); + // Same wire class as maybe_evict / on_evict failures. + assert_eq!( + hipfire_generate::qwen::classify_evict_failure_wire(), + hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly + ); +} + +/// Eviction exact-cap admits only because post-commit maybe_evict+on_evict +/// is mandatory before host seed/raw/render and must leave a free seed slot. +#[test] +fn forced_commit_eviction_exact_cap_admits_with_mandatory_post_commit_evict() { + let physical_cap = 16usize; + let position = 12usize; + let commit_len = 4usize; // post_position == physical_cap + assert_eq!(position.saturating_add(commit_len), physical_cap); + + assert!( + hipfire_generate::qwen::spec_forced_commit_admits(position, commit_len, physical_cap, true), + "eviction may admit exact-cap" + ); + // Contrast: same numbers without eviction reject. + assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + position, + commit_len, + physical_cap, + false + )); + + // Ordering model for the admitted exact-cap path: GPU commit → mandatory + // post-commit eviction → require post_evict < physical_cap → only then + // host position/seed/raw/render. Skipping eviction must not reach render. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Step { + Admit, + GpuCommit, + PostCommitEvict, + HostRender, + ErrorOnly, + } + let mut steps: Vec = Vec::new(); + let admitted = + hipfire_generate::qwen::spec_forced_commit_admits(position, commit_len, physical_cap, true); + assert!(admitted); + steps.push(Step::Admit); + steps.push(Step::GpuCommit); + + let eviction_enabled = true; + let mut post_position = position.saturating_add(commit_len); + let mut rendered = false; + if eviction_enabled { + // Mandatory: maybe_evict + on_evict before host updates. + steps.push(Step::PostCommitEvict); + // Synthetic successful compaction frees the pending-seed slot. + post_position = physical_cap.saturating_sub(1); + if post_position >= physical_cap { + steps.push(Step::ErrorOnly); } else { steps.push(Step::HostRender); rendered = true; } - assert_eq!( - steps, - vec![ - Step::Admit, - Step::GpuCommit, - Step::PostCommitEvict, - Step::HostRender - ] - ); - assert!(rendered); - assert!(post_position < physical_cap); - - // If post-evict still has no seed slot → ErrorOnly, no render. - let mut bad_steps: Vec = vec![Step::Admit, Step::GpuCommit, Step::PostCommitEvict]; - let bad_post = physical_cap; // eviction failed to free a slot - let mut bad_rendered = false; - if bad_post >= physical_cap { - bad_steps.push(Step::ErrorOnly); - } else { - bad_steps.push(Step::HostRender); - bad_rendered = true; - } - assert_eq!( - bad_steps, - vec![ - Step::Admit, - Step::GpuCommit, - Step::PostCommitEvict, - Step::ErrorOnly - ] - ); - assert!(!bad_rendered); - } - - /// Comfortably under the physical cap admits with or without eviction. - #[test] - fn forced_commit_under_threshold_fits() { - let physical_cap = 64usize; - let position = 10usize; - let commit_len = 3usize; - assert!(position.saturating_add(commit_len) < physical_cap); - assert!(hipfire_generate::qwen::spec_forced_commit_admits( + } else { + steps.push(Step::HostRender); + rendered = true; + } + assert_eq!( + steps, + vec![ + Step::Admit, + Step::GpuCommit, + Step::PostCommitEvict, + Step::HostRender + ] + ); + assert!(rendered); + assert!(post_position < physical_cap); + + // If post-evict still has no seed slot → ErrorOnly, no render. + let mut bad_steps: Vec = vec![Step::Admit, Step::GpuCommit, Step::PostCommitEvict]; + let bad_post = physical_cap; // eviction failed to free a slot + let mut bad_rendered = false; + if bad_post >= physical_cap { + bad_steps.push(Step::ErrorOnly); + } else { + bad_steps.push(Step::HostRender); + bad_rendered = true; + } + assert_eq!( + bad_steps, + vec![ + Step::Admit, + Step::GpuCommit, + Step::PostCommitEvict, + Step::ErrorOnly + ] + ); + assert!(!bad_rendered); +} + +/// Comfortably under the physical cap admits with or without eviction. +#[test] +fn forced_commit_under_threshold_fits() { + let physical_cap = 64usize; + let position = 10usize; + let commit_len = 3usize; + assert!(position.saturating_add(commit_len) < physical_cap); + assert!(hipfire_generate::qwen::spec_forced_commit_admits( + position, + commit_len, + physical_cap, + false + )); + assert!(hipfire_generate::qwen::spec_forced_commit_admits( + position, + commit_len, + physical_cap, + true + )); + // Empty commit (seed-only replace) is always under threshold. + assert!(hipfire_generate::qwen::spec_forced_commit_admits( + position, + 0, + physical_cap, + false + )); + assert!(hipfire_generate::qwen::spec_forced_commit_admits( + position, + 0, + physical_cap, + true + )); +} + +/// Admission uses the actual GPU commit slice (`tx.commit.len()`), never the +/// forced token count. Non-committable seeds omit the trigger and shrink +/// the commit — that shorter length is what capacity sees. +#[test] +fn forced_commit_admission_uses_tx_commit_len_not_forced_count() { + let physical_cap = 10usize; + let position = 8usize; + let seed = 7u32; + let forced = [10u32, 11, 12]; // forced.len() == 3 + + // Committable: commit = [seed, 10, 11] → len 3; post = 11 > cap. + let keep = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &forced, true); + assert_eq!(keep.commit.len(), 3); + assert_eq!(keep.commit.len(), keep.position_delta); + assert!( + !hipfire_generate::qwen::spec_forced_commit_admits( position, - commit_len, + keep.commit.len(), physical_cap, false - )); - assert!(hipfire_generate::qwen::spec_forced_commit_admits( - position, - commit_len, - physical_cap, - true - )); - // Empty commit (seed-only replace) is always under threshold. - assert!(hipfire_generate::qwen::spec_forced_commit_admits(position, 0, physical_cap, false)); - assert!(hipfire_generate::qwen::spec_forced_commit_admits(position, 0, physical_cap, true)); - } - - /// Admission uses the actual GPU commit slice (`tx.commit.len()`), never the - /// forced token count. Non-committable seeds omit the trigger and shrink - /// the commit — that shorter length is what capacity sees. - #[test] - fn forced_commit_admission_uses_tx_commit_len_not_forced_count() { - let physical_cap = 10usize; - let position = 8usize; - let seed = 7u32; - let forced = [10u32, 11, 12]; // forced.len() == 3 - - // Committable: commit = [seed, 10, 11] → len 3; post = 11 > cap. - let keep = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &forced, true); - assert_eq!(keep.commit.len(), 3); - assert_eq!(keep.commit.len(), keep.position_delta); - assert!( - !hipfire_generate::qwen::spec_forced_commit_admits(position, keep.commit.len(), physical_cap, false), - "committable commit_len=3 at pos=8 must reject under no-evict" - ); - assert!( - !hipfire_generate::qwen::spec_forced_commit_admits(position, keep.commit.len(), physical_cap, true), - "committable commit_len=3 at pos=8 is over-cap even with eviction" - ); - - // Non-committable: commit = [10, 11] → len 2 (seed omitted); post = 10. - let omit = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &forced, false); - assert_eq!(omit.commit, vec![10, 11]); - assert_eq!(omit.commit.len(), 2); - assert_eq!(omit.position_delta, omit.commit.len()); - assert_ne!( - omit.commit.len(), - forced.len(), - "must not admit against forced token count" - ); - // Using forced.len() would be wrong (post=11 over-cap); actual slice fits - // exact-cap under eviction and rejects under no-evict (needs seed slot). - assert_eq!(position.saturating_add(omit.commit.len()), physical_cap); - assert!( - !hipfire_generate::qwen::spec_forced_commit_admits(position, omit.commit.len(), physical_cap, false), - "no-evict exact-cap still needs a pending-seed slot" - ); - assert!( - hipfire_generate::qwen::spec_forced_commit_admits(position, omit.commit.len(), physical_cap, true), - "eviction admits exact-cap on the actual (shorter) commit slice" - ); - // Guard: if a caller mistakenly passed forced.len(), both modes reject. - assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + ), + "committable commit_len=3 at pos=8 must reject under no-evict" + ); + assert!( + !hipfire_generate::qwen::spec_forced_commit_admits( position, - forced.len(), + keep.commit.len(), physical_cap, true - )); - assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + ), + "committable commit_len=3 at pos=8 is over-cap even with eviction" + ); + + // Non-committable: commit = [10, 11] → len 2 (seed omitted); post = 10. + let omit = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &forced, false); + assert_eq!(omit.commit, vec![10, 11]); + assert_eq!(omit.commit.len(), 2); + assert_eq!(omit.position_delta, omit.commit.len()); + assert_ne!( + omit.commit.len(), + forced.len(), + "must not admit against forced token count" + ); + // Using forced.len() would be wrong (post=11 over-cap); actual slice fits + // exact-cap under eviction and rejects under no-evict (needs seed slot). + assert_eq!(position.saturating_add(omit.commit.len()), physical_cap); + assert!( + !hipfire_generate::qwen::spec_forced_commit_admits( position, - forced.len(), + omit.commit.len(), physical_cap, false - )); - - // Single forced + non-committable: empty commit — no GPU write. - // Admission still uses commit_len=0 (not forced.len()==1). - let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[42], false); - assert!(one.commit.is_empty()); - assert_ne!( - one.commit.len(), - 1, - "must not treat forced count as commit_len" - ); - assert!(hipfire_generate::qwen::spec_forced_commit_admits( + ), + "no-evict exact-cap still needs a pending-seed slot" + ); + assert!( + hipfire_generate::qwen::spec_forced_commit_admits( position, - one.commit.len(), - physical_cap, - false - )); - // At physical_cap with zero-length commit: no-evict still needs a free - // pending-seed slot (post == cap rejects); eviction admits exact-cap. - assert!(!hipfire_generate::qwen::spec_forced_commit_admits( - physical_cap, - one.commit.len(), - physical_cap, - false - )); - assert!(hipfire_generate::qwen::spec_forced_commit_admits( - physical_cap, - one.commit.len(), + omit.commit.len(), physical_cap, true - )); - } - - #[test] - fn dflash_client_commit_preserves_release_and_store() { - let e = hipfire_generate::qwen::qwen_client_commit_effects(ClientTerminalDecision::Commit, true, true); - assert!(e.release_tool_calls && e.store_cache && e.emit_done); - // Successful Done classify → intended flags gate release/store. - let tc = ToolCall { - id: None, - name: "read".into(), - arguments: r#"{"path":"/x"}"#.into(), - rendered_body: None, - }; - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( - &summary_tool_calls(vec![tc.clone()]), - false, - false, - "Sure.", - false, - ); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - release_tool_calls, - store_cache, - wire_tool_calls, - .. - } => { - let effects = hipfire_generate::qwen::qwen_client_commit_effects( - ClientTerminalDecision::Commit, - *release_tool_calls && !wire_tool_calls.is_empty(), - *store_cache, - ); - assert!(effects.release_tool_calls); - assert!(effects.store_cache); - assert!(effects.emit_done); - let mut action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - action.store = effects.store_cache && action.store; - assert!(action.store); - } - other => panic!("expected Done, got {other:?}"), - } - } - - #[test] - fn dflash_client_abort_suppresses_release_store_done() { - set_active_attempt_id(33); - let tc = ToolCall { - id: None, - name: "read".into(), - arguments: r#"{"path":"/x"}"#.into(), - rendered_body: None, - }; - let term = - hipfire_generate::qwen::qwen_dflash_wire_terminal(&summary_tool_calls(vec![tc]), false, false, "Sure.", false); - let hipfire_generate::qwen::QwenDflashWireTerminal::Done { + ), + "eviction admits exact-cap on the actual (shorter) commit slice" + ); + // Guard: if a caller mistakenly passed forced.len(), both modes reject. + assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + position, + forced.len(), + physical_cap, + true + )); + assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + position, + forced.len(), + physical_cap, + false + )); + + // Single forced + non-committable: empty commit — no GPU write. + // Admission still uses commit_len=0 (not forced.len()==1). + let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[42], false); + assert!(one.commit.is_empty()); + assert_ne!( + one.commit.len(), + 1, + "must not treat forced count as commit_len" + ); + assert!(hipfire_generate::qwen::spec_forced_commit_admits( + position, + one.commit.len(), + physical_cap, + false + )); + // At physical_cap with zero-length commit: no-evict still needs a free + // pending-seed slot (post == cap rejects); eviction admits exact-cap. + assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + physical_cap, + one.commit.len(), + physical_cap, + false + )); + assert!(hipfire_generate::qwen::spec_forced_commit_admits( + physical_cap, + one.commit.len(), + physical_cap, + true + )); +} + +#[test] +fn dflash_client_commit_preserves_release_and_store() { + let e = hipfire_generate::qwen::qwen_client_commit_effects( + ClientTerminalDecision::Commit, + true, + true, + ); + assert!(e.release_tool_calls && e.store_cache && e.emit_done); + // Successful Done classify → intended flags gate release/store. + let tc = ToolCall { + id: None, + name: "read".into(), + arguments: r#"{"path":"/x"}"#.into(), + rendered_body: None, + }; + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( + &summary_tool_calls(vec![tc.clone()]), + false, + false, + "Sure.", + false, + ); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { release_tool_calls, store_cache, wire_tool_calls, .. - } = &term - else { - panic!("expected Done"); - }; - let effects = hipfire_generate::qwen::qwen_client_commit_effects( - ClientTerminalDecision::Abort, - *release_tool_calls && !wire_tool_calls.is_empty(), - *store_cache, - ); - assert!(!effects.release_tool_calls); - assert!(!effects.store_cache); - assert!(!effects.emit_done); - - let mut sink = Vec::new(); - // No tool release on Abort. - let mut action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - action.store = effects.store_cache && action.store; - let mut stored = false; - let _ = hipfire_generate::qwen::qwen_dflash_apply_cache_action(|_fp, _seq| stored = true, &action, vec![1, 2, 3]); - assert!(!stored); - - let ep = hipfire_generate::common::RollbackEpilogue { - rolled_back: true, - context: None, - }; - hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink, "df-abort", 7, &ep); - let out = String::from_utf8_lossy(&sink); - assert!(!out.contains("\"type\":\"tool_calls\"")); - assert!(out.contains("\"type\":\"aborted\"")); - assert!(out.contains("\"finish_reason\":\"aborted\"")); - assert!(!out.contains("\"finish_reason\":\"tool_calls\"")); - assert!(out.contains("\"attempt_id\":33")); - } + } => { + let effects = hipfire_generate::qwen::qwen_client_commit_effects( + ClientTerminalDecision::Commit, + *release_tool_calls && !wire_tool_calls.is_empty(), + *store_cache, + ); + assert!(effects.release_tool_calls); + assert!(effects.store_cache); + assert!(effects.emit_done); + let mut action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + action.store = effects.store_cache && action.store; + assert!(action.store); + } + other => panic!("expected Done, got {other:?}"), + } +} + +#[test] +fn dflash_client_abort_suppresses_release_store_done() { + set_active_attempt_id(33); + let tc = ToolCall { + id: None, + name: "read".into(), + arguments: r#"{"path":"/x"}"#.into(), + rendered_body: None, + }; + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( + &summary_tool_calls(vec![tc]), + false, + false, + "Sure.", + false, + ); + let hipfire_generate::qwen::QwenDflashWireTerminal::Done { + release_tool_calls, + store_cache, + wire_tool_calls, + .. + } = &term + else { + panic!("expected Done"); + }; + let effects = hipfire_generate::qwen::qwen_client_commit_effects( + ClientTerminalDecision::Abort, + *release_tool_calls && !wire_tool_calls.is_empty(), + *store_cache, + ); + assert!(!effects.release_tool_calls); + assert!(!effects.store_cache); + assert!(!effects.emit_done); + + let mut sink = Vec::new(); + // No tool release on Abort. + let mut action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + action.store = effects.store_cache && action.store; + let mut stored = false; + let _ = hipfire_generate::qwen::qwen_dflash_apply_cache_action( + |_fp, _seq| stored = true, + &action, + vec![1, 2, 3], + ); + assert!(!stored); + + let ep = hipfire_generate::common::RollbackEpilogue { + rolled_back: true, + context: None, + }; + hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink, "df-abort", 7, &ep); + let out = String::from_utf8_lossy(&sink); + assert!(!out.contains("\"type\":\"tool_calls\"")); + assert!(out.contains("\"type\":\"aborted\"")); + assert!(out.contains("\"finish_reason\":\"aborted\"")); + assert!(!out.contains("\"finish_reason\":\"tool_calls\"")); + assert!(out.contains("\"attempt_id\":33")); +} diff --git a/crates/hipfire-runtime/src/dflash.rs b/crates/hipfire-runtime/src/dflash.rs index ec3ee164c..d0f38807c 100644 --- a/crates/hipfire-runtime/src/dflash.rs +++ b/crates/hipfire-runtime/src/dflash.rs @@ -724,12 +724,16 @@ impl DflashWeights { } macro_rules! take_t { ($i:expr) => { - live_t[$i].take().expect("dflash load: F32 slot taken twice") + live_t[$i] + .take() + .expect("dflash load: F32 slot taken twice") }; } macro_rules! take_w { ($i:expr) => { - live_w[$i].take().expect("dflash load: weight slot taken twice") + live_w[$i] + .take() + .expect("dflash load: weight slot taken twice") }; } let i_fc = wt!(hfq_weight( @@ -739,8 +743,12 @@ impl DflashWeights { cfg.hidden, cfg.num_extract() * cfg.hidden, )); - let i_hidden_norm = - gt!(hfq_tensor_f32(hfq, gpu, "hidden_norm.weight", vec![cfg.hidden])); + let i_hidden_norm = gt!(hfq_tensor_f32( + hfq, + gpu, + "hidden_norm.weight", + vec![cfg.hidden] + )); let i_norm = gt!(hfq_tensor_f32(hfq, gpu, "norm.weight", vec![cfg.hidden])); let conv_k = cfg.conv_kernel_size.unwrap_or(2); @@ -1581,11 +1589,7 @@ impl DflashScratch { // Slot indices (`take!`n at the build below). let need_conv = cfg.conv_kernel_size.is_some() && cfg.conv_group_size.is_some(); let need_selector = cfg.selector_rank.is_some() && cfg.selector_top_k.is_some(); - let i_conv_temp = if need_conv { - Some(at!(&[b * h])) - } else { - None - }; + let i_conv_temp = if need_conv { Some(at!(&[b * h])) } else { None }; let i_conv_dynamic = if need_conv { let k = cfg.conv_kernel_size.unwrap(); let g = cfg.conv_group_size.unwrap(); From 2d2d621c595b5d4c871440eed7f138204ab68c82 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:14:22 +0000 Subject: [PATCH 15/34] test(generate): move ctx-exhausted terminal test to its own file qwen_dflash_semantic_terminal_tests.rs carries historical rustfmt debt; CI enforces rustfmt on changed files, so adding one test there forces a 6k-line reformat. The new contract lives in qwen_dflash_ctx_exhausted_tests.rs and the debt file is restored to master byte-for-byte. --- .../tests/qwen_dflash_ctx_exhausted_tests.rs | 71 + .../qwen_dflash_semantic_terminal_tests.rs | 6300 ++++++++--------- 2 files changed, 3091 insertions(+), 3280 deletions(-) create mode 100644 crates/hipfire-generate/tests/qwen_dflash_ctx_exhausted_tests.rs diff --git a/crates/hipfire-generate/tests/qwen_dflash_ctx_exhausted_tests.rs b/crates/hipfire-generate/tests/qwen_dflash_ctx_exhausted_tests.rs new file mode 100644 index 000000000..d94acb29c --- /dev/null +++ b/crates/hipfire-generate/tests/qwen_dflash_ctx_exhausted_tests.rs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Kaden Schutt +// hipfire — see LICENSE and NOTICE in the project root. + +//! Draft-context exhaustion terminal contract. +//! +//! A mid-loop `position + block_size >= ctx_capacity` break with +//! `generated < max_tokens` must report `length` (no tool release, no cache +//! store), not a natural `stop`. Kept separate from +//! `qwen_dflash_semantic_terminal_tests.rs` so that file's rustfmt debt is not +//! dragged into this change. + +use hipfire_generate::common::qwen_dflash_hit_length_cap; +use hipfire_generate::qwen::{ + qwen_dflash_apply_cache_action, qwen_dflash_cache_action, qwen_dflash_wire_terminal, + QwenDflashWireTerminal, +}; +use hipfire_runtime::prompt_frame::ToolCall; +use hipfire_runtime::spec::{ClientEvent, FinishSummary}; + +fn summary_tool_calls(calls: Vec) -> FinishSummary { + let n = calls.len(); + FinishSummary { + events: vec![ClientEvent::ToolCalls(calls)], + finish_reason: "tool_calls", + tool_calls: n, + visible_text: "Sure.".into(), + decoded_eot: false, + open_think: false, + } +} + +#[test] +fn ctx_exhausted_maps_to_length_with_budget_unspent() { + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + // The budget alone would not have stopped this turn. + assert!(!qwen_dflash_hit_length_cap(10, 16, false, false)); + // Wrapper-level mapping (`generate_dflash` / dense spec epilogue): the + // `ctx_exhausted` flag is OR-ed into the length decision. + let ctx_exhausted = true; + let hit_length_cap = ctx_exhausted || qwen_dflash_hit_length_cap(10, 16, false, false); + let term = qwen_dflash_wire_terminal(&fin, hit_length_cap, false, "partial", false); + match &term { + QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + fingerprint_text, + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*release_tool_calls); + assert!(!*store_cache); + assert!(wire_tool_calls.is_empty()); + assert!(fingerprint_text.is_empty()); + } + other => panic!("expected length Done, got {other:?}"), + } + let action = qwen_dflash_cache_action(&term); + assert!(!action.store); + assert!( + qwen_dflash_apply_cache_action(|_, _| panic!("must not insert"), &action, vec![1, 2]) + .is_none() + ); +} diff --git a/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs b/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs index 78858bd11..38a8e59d8 100644 --- a/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs +++ b/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs @@ -17,106 +17,106 @@ use hipfire_generate::ar::*; use hipfire_generate::common::*; use hipfire_runtime::emit_text::extract_tool_calls_from_text; -use hipfire_runtime::prompt_frame::{AssistantPrefix, ToolCall}; -use hipfire_runtime::spec::{ - ClientEvent, FinishSummary, SpecEmit, SpecEmitCtx, SpecStep, StopReason, -}; -use hipfire_runtime::tokenizer::Tokenizer; -use std::collections::HashSet; - -fn summary_tool_calls(calls: Vec) -> FinishSummary { - let n = calls.len(); - FinishSummary { - events: vec![ClientEvent::ToolCalls(calls)], - finish_reason: "tool_calls", - tool_calls: n, - visible_text: "Sure.".into(), - decoded_eot: false, - open_think: false, - } -} - -fn summary_stop(visible: &str) -> FinishSummary { - FinishSummary { - events: vec![ClientEvent::Token(visible.into())], - finish_reason: "stop", - tool_calls: 0, - visible_text: visible.into(), - decoded_eot: false, - open_think: false, - } -} - -fn summary_malformed() -> FinishSummary { - FinishSummary { - events: Vec::new(), - finish_reason: "malformed_protocol", - tool_calls: 0, - visible_text: String::new(), - decoded_eot: false, - open_think: false, - } -} - -fn json_escape(s: &str) -> String { - let mut out = String::new(); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), + use hipfire_runtime::prompt_frame::{AssistantPrefix, ToolCall}; + use hipfire_runtime::spec::{ + ClientEvent, FinishSummary, SpecEmit, SpecEmitCtx, SpecStep, StopReason, + }; + use hipfire_runtime::tokenizer::Tokenizer; + use std::collections::HashSet; + + fn summary_tool_calls(calls: Vec) -> FinishSummary { + let n = calls.len(); + FinishSummary { + events: vec![ClientEvent::ToolCalls(calls)], + finish_reason: "tool_calls", + tool_calls: n, + visible_text: "Sure.".into(), + decoded_eot: false, + open_think: false, + } + } + + fn summary_stop(visible: &str) -> FinishSummary { + FinishSummary { + events: vec![ClientEvent::Token(visible.into())], + finish_reason: "stop", + tool_calls: 0, + visible_text: visible.into(), + decoded_eot: false, + open_think: false, + } + } + + fn summary_malformed() -> FinishSummary { + FinishSummary { + events: Vec::new(), + finish_reason: "malformed_protocol", + tool_calls: 0, + visible_text: String::new(), + decoded_eot: false, + open_think: false, } } - out -} - -fn byte_to_gpt2_char_test(b: u8) -> char { - let mut bs: Vec = Vec::new(); - bs.extend((b'!' as u32)..=(b'~' as u32)); - bs.extend((0xA1u32)..=(0xACu32)); - bs.extend((0xAEu32)..=(0xFFu32)); - let mut cs: Vec = bs.clone(); - let mut n: u32 = 0; - for byte in 0u32..=255u32 { - if !bs.contains(&byte) { - bs.push(byte); - cs.push(256 + n); - n += 1; + + fn json_escape(s: &str) -> String { + let mut out = String::new(); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } } + out } - for (bb, cc) in bs.into_iter().zip(cs.into_iter()) { - if bb == b as u32 { - return char::from_u32(cc).unwrap(); + + fn byte_to_gpt2_char_test(b: u8) -> char { + let mut bs: Vec = Vec::new(); + bs.extend((b'!' as u32)..=(b'~' as u32)); + bs.extend((0xA1u32)..=(0xACu32)); + bs.extend((0xAEu32)..=(0xFFu32)); + let mut cs: Vec = bs.clone(); + let mut n: u32 = 0; + for byte in 0u32..=255u32 { + if !bs.contains(&byte) { + bs.push(byte); + cs.push(256 + n); + n += 1; + } } + for (bb, cc) in bs.into_iter().zip(cs.into_iter()) { + if bb == b as u32 { + return char::from_u32(cc).unwrap(); + } + } + char::from_u32(b as u32).unwrap() } - char::from_u32(b as u32).unwrap() -} - -/// Same minimal tokenizer family as qwen35 `spec_emit` CPU tests. -fn test_tokenizer() -> Tokenizer { - let mut entries: Vec = Vec::new(); - entries.push(r#""<|im_start|>": 0"#.to_string()); - entries.push(r#""<|im_end|>": 1"#.to_string()); - entries.push(r#""": 2"#.to_string()); - entries.push(r#""": 3"#.to_string()); - entries.push(r#""system": 4"#.to_string()); - entries.push(r#""user": 5"#.to_string()); - entries.push(r#""assistant": 6"#.to_string()); - entries.push(r#""\n": 7"#.to_string()); - entries.push(r#""Ġ": 8"#.to_string()); - entries.push(r#""<|endoftext|>": 9"#.to_string()); - for b in 0u32..=255u32 { - let ch = byte_to_gpt2_char_test(b as u8); - let escaped = json_escape(&ch.to_string()); - entries.push(format!(r#""{}": {}"#, escaped, 100 + b)); - } - let vocab_block = entries.join(", "); - let json = format!( - r#"{{ + + /// Same minimal tokenizer family as qwen35 `spec_emit` CPU tests. + fn test_tokenizer() -> Tokenizer { + let mut entries: Vec = Vec::new(); + entries.push(r#""<|im_start|>": 0"#.to_string()); + entries.push(r#""<|im_end|>": 1"#.to_string()); + entries.push(r#""": 2"#.to_string()); + entries.push(r#""": 3"#.to_string()); + entries.push(r#""system": 4"#.to_string()); + entries.push(r#""user": 5"#.to_string()); + entries.push(r#""assistant": 6"#.to_string()); + entries.push(r#""\n": 7"#.to_string()); + entries.push(r#""Ġ": 8"#.to_string()); + entries.push(r#""<|endoftext|>": 9"#.to_string()); + for b in 0u32..=255u32 { + let ch = byte_to_gpt2_char_test(b as u8); + let escaped = json_escape(&ch.to_string()); + entries.push(format!(r#""{}": {}"#, escaped, 100 + b)); + } + let vocab_block = entries.join(", "); + let json = format!( + r#"{{ "model": {{"type": "BPE", "vocab": {{ {vocab} }}, "merges": []}}, "added_tokens": [ {{"id": 0, "content": "<|im_start|>", "special": true}}, @@ -126,418 +126,287 @@ fn test_tokenizer() -> Tokenizer { {{"id": 9, "content": "<|endoftext|>", "special": true}} ] }}"#, - vocab = vocab_block, - ); - Tokenizer::from_hf_json(&json).expect("test tokenizer") -} - -fn make_qwen_emit<'a>( - tok: &'a Tokenizer, - assistant_prefix: AssistantPrefix, -) -> Box { - hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { - tokenizer: tok, - eos: 9, - im_end: Some(1), - tools: Some(&[]), - stop: Vec::new(), - max_think: 0, - max_tokens: 256, - assistant_prefix, - think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, - decoded_vocab: None, - }) -} - -/// Drive production Qwen35Emit with whole-string encodes. -fn drive_qwen_emit( - text: &str, - assistant_prefix: AssistantPrefix, -) -> (Vec, FinishSummary, Vec) { - let tok = test_tokenizer(); - let ids = tok.encode(text); - assert!(!ids.is_empty(), "encode produced no tokens for {text:?}"); - let mut emit = make_qwen_emit(&tok, assistant_prefix); - let mut stream = Vec::new(); - let mut first = true; - for id in &ids { - let outcome = if first { - first = false; - emit.begin(*id) - } else { - emit.observe(*id) - }; - stream.extend(outcome.events); - if outcome.stop.is_some() { - break; + vocab = vocab_block, + ); + Tokenizer::from_hf_json(&json).expect("test tokenizer") + } + + fn make_qwen_emit<'a>( + tok: &'a Tokenizer, + assistant_prefix: AssistantPrefix, + ) -> Box { + hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { + tokenizer: tok, + eos: 9, + im_end: Some(1), + tools: Some(&[]), + stop: Vec::new(), + max_think: 0, + max_tokens: 256, + assistant_prefix, + think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, + decoded_vocab: None, + }) + } + + /// Drive production Qwen35Emit with whole-string encodes. + fn drive_qwen_emit( + text: &str, + assistant_prefix: AssistantPrefix, + ) -> (Vec, FinishSummary, Vec) { + let tok = test_tokenizer(); + let ids = tok.encode(text); + assert!(!ids.is_empty(), "encode produced no tokens for {text:?}"); + let mut emit = make_qwen_emit(&tok, assistant_prefix); + let mut stream = Vec::new(); + let mut first = true; + for id in &ids { + let outcome = if first { + first = false; + emit.begin(*id) + } else { + emit.observe(*id) + }; + stream.extend(outcome.events); + if outcome.stop.is_some() { + break; + } } + let streamed = emit.streamed_tokens().to_vec(); + let finish = emit.finish(); + (stream, finish, streamed) } - let streamed = emit.streamed_tokens().to_vec(); - let finish = emit.finish(); - (stream, finish, streamed) -} - -/// Drive production emitter token-by-token (for split-marker cases). -fn drive_qwen_ids( - ids: &[u32], - assistant_prefix: AssistantPrefix, -) -> (Vec, FinishSummary, Vec) { - let tok = test_tokenizer(); - let mut emit = make_qwen_emit(&tok, assistant_prefix); - let mut stream = Vec::new(); - let mut first = true; - for id in ids { - let outcome = if first { - first = false; - emit.begin(*id) - } else { - emit.observe(*id) - }; - stream.extend(outcome.events); - if outcome.stop.is_some() { - break; + + /// Drive production emitter token-by-token (for split-marker cases). + fn drive_qwen_ids( + ids: &[u32], + assistant_prefix: AssistantPrefix, + ) -> (Vec, FinishSummary, Vec) { + let tok = test_tokenizer(); + let mut emit = make_qwen_emit(&tok, assistant_prefix); + let mut stream = Vec::new(); + let mut first = true; + for id in ids { + let outcome = if first { + first = false; + emit.begin(*id) + } else { + emit.observe(*id) + }; + stream.extend(outcome.events); + if outcome.stop.is_some() { + break; + } } + let streamed = emit.streamed_tokens().to_vec(); + let finish = emit.finish(); + (stream, finish, streamed) } - let streamed = emit.streamed_tokens().to_vec(); - let finish = emit.finish(); - (stream, finish, streamed) -} - -fn parse_jsonl(out: &str) -> Vec { - out.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("bad jsonl {l}: {e}"))) - .collect() -} - -/// GPU-less attested epilogue for unit tests (no real device sync). -fn attest_epilogue(rolled_back: bool) -> hipfire_generate::common::RollbackEpilogue { - hipfire_generate::common::RollbackEpilogue { - rolled_back, - context: None, - } -} - -/// Attested epilogue with sync-failure context (rolled_back=false). -fn attest_epilogue_with_context(context: &str) -> hipfire_generate::common::RollbackEpilogue { - hipfire_generate::common::RollbackEpilogue { - rolled_back: false, - context: Some(context.to_string()), - } -} - -#[test] -fn safe_stop_stores_cache_no_calls() { - let fin = summary_stop("hello"); - let term = - hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "hello", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - fingerprint_text, - wire_tool_calls, - } => { - assert_eq!(*finish_reason, "stop"); - assert!(!*release_tool_calls); - assert!(*store_cache); - assert!(wire_tool_calls.is_empty()); - assert_eq!( - fingerprint_text.as_str(), - hipfire_generate::common::normalize_asst_turn_for_fingerprint("hello") - ); + + fn parse_jsonl(out: &str) -> Vec { + out.lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("bad jsonl {l}: {e}"))) + .collect() + } + + /// GPU-less attested epilogue for unit tests (no real device sync). + fn attest_epilogue(rolled_back: bool) -> hipfire_generate::common::RollbackEpilogue { + hipfire_generate::common::RollbackEpilogue { + rolled_back, + context: None, } - other => panic!("expected Done, got {other:?}"), - } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(action.store); - assert!(action.tool_calls.is_empty()); -} - -#[test] -fn tool_safe_releases_calls_and_stores() { - let calls = vec![ToolCall { - id: None, - name: "get_weather".into(), - arguments: serde_json::json!({"city": "SF"}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls.clone()); - let term = - hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "Sure.", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - wire_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "tool_calls"); - assert!(*release_tool_calls); - assert!(*store_cache); - assert_eq!(wire_tool_calls.len(), 1); - assert_eq!(wire_tool_calls[0].name, "get_weather"); + } + + /// Attested epilogue with sync-failure context (rolled_back=false). + fn attest_epilogue_with_context(context: &str) -> hipfire_generate::common::RollbackEpilogue { + hipfire_generate::common::RollbackEpilogue { + rolled_back: false, + context: Some(context.to_string()), } - other => panic!("expected Done, got {other:?}"), - } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(action.store); - assert_eq!(action.tool_calls.len(), 1); -} - -#[test] -fn pure_length_suppresses_calls_and_cache() { - let calls = vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls); - assert!(hipfire_generate::common::qwen_dflash_hit_length_cap( - 16, 16, false, false - )); - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( - 16, 16, false, true - )); - let term = - hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "partial", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - wire_tool_calls, - fingerprint_text, - } => { - assert_eq!(*finish_reason, "length"); - assert!(!*release_tool_calls); - assert!(!*store_cache); - assert!(wire_tool_calls.is_empty()); - assert!(fingerprint_text.is_empty()); + } + + #[test] + fn safe_stop_stores_cache_no_calls() { + let fin = summary_stop("hello"); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "hello", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + fingerprint_text, + wire_tool_calls, + } => { + assert_eq!(*finish_reason, "stop"); + assert!(!*release_tool_calls); + assert!(*store_cache); + assert!(wire_tool_calls.is_empty()); + assert_eq!( + fingerprint_text.as_str(), + hipfire_generate::common::normalize_asst_turn_for_fingerprint("hello") + ); + } + other => panic!("expected Done, got {other:?}"), } - other => panic!("expected length Done, got {other:?}"), - } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(!action.store); - assert!(hipfire_generate::qwen::qwen_dflash_apply_cache_action( - |_, _| panic!("must not insert"), - &action, - vec![1, 2] - ) - .is_none()); -} - -#[test] -fn ctx_exhausted_maps_to_length_with_budget_unspent() { - // Mid-loop `position + block_size >= ctx_capacity` break with - // generated < max_tokens: the epilogue ORs `run.ctx_exhausted` into - // the length decision, so the turn reports `length` with no tool - // release and no cache store instead of a natural `stop`. - let calls = vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls); - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( - 10, 16, false, false - )); - // Wrapper-level mapping (`generate_dflash` / dense spec epilogue). - let ctx_exhausted = true; - let hit_length_cap = - ctx_exhausted || hipfire_generate::common::qwen_dflash_hit_length_cap(10, 16, false, false); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( - &fin, - hit_length_cap, - false, - "partial", - false, - ); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - wire_tool_calls, - fingerprint_text, - } => { - assert_eq!(*finish_reason, "length"); - assert!(!*release_tool_calls); - assert!(!*store_cache); - assert!(wire_tool_calls.is_empty()); - assert!(fingerprint_text.is_empty()); + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(action.store); + assert!(action.tool_calls.is_empty()); + } + + #[test] + fn tool_safe_releases_calls_and_stores() { + let calls = vec![ToolCall { + id: None, + name: "get_weather".into(), + arguments: serde_json::json!({"city": "SF"}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls.clone()); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "Sure.", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "tool_calls"); + assert!(*release_tool_calls); + assert!(*store_cache); + assert_eq!(wire_tool_calls.len(), 1); + assert_eq!(wire_tool_calls[0].name, "get_weather"); + } + other => panic!("expected Done, got {other:?}"), } - other => panic!("expected length Done, got {other:?}"), - } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(!action.store); - assert!(hipfire_generate::qwen::qwen_dflash_apply_cache_action( - |_, _| panic!("must not insert"), - &action, - vec![1, 2] - ) - .is_none()); -} - -#[test] -fn final_token_eot_beats_length() { - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( - 8, 8, true, false - )); - let calls = vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "ok", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - .. - } => { - assert_eq!(*finish_reason, "tool_calls"); - assert!(*release_tool_calls); - assert!(*store_cache); + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(action.store); + assert_eq!(action.tool_calls.len(), 1); + } + + #[test] + fn pure_length_suppresses_calls_and_cache() { + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + assert!(hipfire_generate::common::qwen_dflash_hit_length_cap(16, 16, false, false)); + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap(16, 16, false, true)); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "partial", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + fingerprint_text, + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*release_tool_calls); + assert!(!*store_cache); + assert!(wire_tool_calls.is_empty()); + assert!(fingerprint_text.is_empty()); + } + other => panic!("expected length Done, got {other:?}"), } - other => panic!("expected tool_calls Done, got {other:?}"), + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(!action.store); + assert!(hipfire_generate::qwen::qwen_dflash_apply_cache_action( + |_, _| panic!("must not insert"), + &action, + vec![1, 2] + ) + .is_none()); } -} -#[test] -fn malformed_is_error_xor_done_no_cache() { - let fin = summary_malformed(); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { - class, - retryable, - rolled_back, - message, - } => { - assert_eq!(*class, "validation"); - assert!(!*retryable); - assert!(!*rolled_back); - assert!(message.contains("malformed")); + #[test] + fn final_token_eot_beats_length() { + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, true, false)); + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "ok", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + .. + } => { + assert_eq!(*finish_reason, "tool_calls"); + assert!(*release_tool_calls); + assert!(*store_cache); + } + other => panic!("expected tool_calls Done, got {other:?}"), } - other => panic!("expected Malformed, got {other:?}"), - } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(!action.store); - assert!(action.tool_calls.is_empty()); - assert!(!matches!( - term, - hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. } - )); -} - -#[test] -fn grammar_failure_no_calls_no_cache() { - let calls = vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, true, "x", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { - class, - retryable, - message, - .. - } => { - assert_eq!(*class, "validation"); - assert!(!*retryable); - assert!(message.contains("grammar")); + } + + #[test] + fn malformed_is_error_xor_done_no_cache() { + let fin = summary_malformed(); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { + class, + retryable, + rolled_back, + message, + } => { + assert_eq!(*class, "validation"); + assert!(!*retryable); + assert!(!*rolled_back); + assert!(message.contains("malformed")); + } + other => panic!("expected Malformed, got {other:?}"), } - other => panic!("expected grammar Malformed error-only, got {other:?}"), - } - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(!action.store); - assert!(action.tool_calls.is_empty()); - assert!(!matches!( - term, - hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. } - )); -} - -#[test] -fn open_think_is_error_xor_done_no_cache() { - // Production emitter (prompt-started OpenThink) -> real FinishSummary - // -> production wire terminal. No hand-built open_think mirrors. - let (stream, fin, _raw) = drive_qwen_emit("still thinking", AssistantPrefix::OpenThink); - let reasoning: String = stream - .iter() - .filter_map(|e| match e { - ClientEvent::Reasoning(text) => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(reasoning, "still thinking"); - assert!(fin.open_think, "emitter must latch open_think"); - assert_eq!(fin.finish_reason, "open_think"); - assert!(fin.events.is_empty()); - assert_eq!(fin.tool_calls, 0); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { - class, - retryable, - message, - .. - } => { - assert_eq!(*class, "validation"); - assert!(!*retryable); - assert!(message.contains("open think")); + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(!action.store); + assert!(action.tool_calls.is_empty()); + assert!(!matches!(term, hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. })); + } + + #[test] + fn grammar_failure_no_calls_no_cache() { + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, true, "x", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { + class, + retryable, + message, + .. + } => { + assert_eq!(*class, "validation"); + assert!(!*retryable); + assert!(message.contains("grammar")); + } + other => panic!("expected grammar Malformed error-only, got {other:?}"), } - other => panic!("expected open_think Malformed, got {other:?}"), - } - assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); - assert!(!matches!( - term, - hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. } - )); - // Production Malformed writer: error XOR done (GPU-less attested epilogue). - set_active_attempt_id(21); - let mut sink = Vec::new(); - if let hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { - message, - class, - retryable, - rolled_back, - } = &term - { - let ep = attest_epilogue(*rolled_back); - hipfire_generate::qwen::emit_qwen_dflash_malformed_terminal( - &mut sink, "req-ot", message, class, *retryable, &ep, - ); - } - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["attempt_id"], 21); - assert!(!out.contains(r#""type":"done""#)); -} - -#[test] -fn open_think_prompt_started_and_generated_flags() { - // (a) prompt-started OpenThink; (b) generated unclosed . - let cases = [ - ("prompt", AssistantPrefix::OpenThink, "still thinking"), - ("generated", AssistantPrefix::Plain, "pre secret"), - ]; - for (label, prefix, body) in cases { - let (stream, fin, _raw) = drive_qwen_emit(body, prefix); + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(!action.store); + assert!(action.tool_calls.is_empty()); + assert!(!matches!(term, hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. })); + } + + #[test] + fn open_think_is_error_xor_done_no_cache() { + // Production emitter (prompt-started OpenThink) -> real FinishSummary + // -> production wire terminal. No hand-built open_think mirrors. + let (stream, fin, _raw) = drive_qwen_emit("still thinking", AssistantPrefix::OpenThink); let reasoning: String = stream .iter() .filter_map(|e| match e { @@ -545,2896 +414,2767 @@ fn open_think_prompt_started_and_generated_flags() { _ => None, }) .collect(); - let expected_reasoning = if label == "prompt" { - "still thinking" - } else { - "secret" - }; - assert_eq!(reasoning, expected_reasoning, "{label}"); - assert!(fin.open_think, "{label}: open_think"); - assert_eq!(fin.finish_reason, "open_think", "{label}"); - assert_eq!(fin.tool_calls, 0, "{label}"); - assert!(fin.events.is_empty(), "{label}: no release on open_think"); + assert_eq!(reasoning, "still thinking"); + assert!(fin.open_think, "emitter must latch open_think"); + assert_eq!(fin.finish_reason, "open_think"); + assert!(fin.events.is_empty()); + assert_eq!(fin.tool_calls, 0); let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); - assert!( - matches!( - term, - hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { .. } - ), - "{label}: expected Malformed" - ); - assert!( - !hipfire_generate::qwen::qwen_dflash_cache_action(&term).store, - "{label}" - ); - } -} - -#[test] -fn producer_decoded_eot_beats_length_without_token_rescan() { - // Real emitter decoded_eot at budget boundary → stop, not length. - let tok = test_tokenizer(); - let mut ids = tok.encode("hi"); - ids.push(1); // <|im_end|> - let (_stream, fin, _raw) = drive_qwen_ids(&ids, AssistantPrefix::Plain); - assert!(fin.decoded_eot, "emitter must set decoded_eot"); - assert_eq!(fin.finish_reason, "stop"); - let generated = ids.len(); - let max_tokens = generated; - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( - generated, - max_tokens, - fin.decoded_eot, - false - )); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "hi", false); - assert!(matches!( - term, - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason: "stop", - store_cache: true, - .. - } - )); -} - -#[test] -fn split_decoded_eot_at_cap_is_stop_not_length() { - // Byte-fragment the <|im_end|> marker across tokens via 100+b map. - let marker = b"<|im_end|>"; - let mut ids: Vec = Vec::new(); - // prose "hi" - ids.push(100 + b'h' as u32); - ids.push(100 + b'i' as u32); - // split marker into two fragments - let mid = marker.len() / 2; - for &b in &marker[..mid] { - ids.push(100 + b as u32); - } - for &b in &marker[mid..] { - ids.push(100 + b as u32); - } - let (stream, fin, raw) = drive_qwen_ids(&ids, AssistantPrefix::Plain); - assert!(fin.decoded_eot, "split EOT must set decoded_eot"); - assert_eq!(fin.finish_reason, "stop"); - let visible: String = stream - .iter() - .filter_map(|ev| match ev { - ClientEvent::Token(t) => Some(t.as_str()), - _ => None, - }) - .collect(); - assert!(!visible.contains("<|im_end|>"), "marker bytes suppressed"); - assert!(visible.contains("hi")); - assert!(!raw.is_empty()); - let generated = raw.len(); - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( - generated, - generated, - fin.decoded_eot, - false - )); - let term = - hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, &visible, false); - assert!(matches!( - term, - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason: "stop", - store_cache: true, - release_tool_calls: false, - .. + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { + class, + retryable, + message, + .. + } => { + assert_eq!(*class, "validation"); + assert!(!*retryable); + assert!(message.contains("open think")); + } + other => panic!("expected open_think Malformed, got {other:?}"), } - )); -} - -#[test] -fn step_budget_max_emit_zero_one_and_mid_window_prefix() { - // max_emit 0: empty emit is the defensive shape (live step returns Err). - let step0 = SpecStep::new([10, 11], 11, 1, 1).cap_emit(0); - assert!(step0.emit.is_empty()); - assert_eq!(step0.accepted, 0); - - // max_emit 1: prefix keep + seed reseeds from kept token. - let step1 = SpecStep::new([10, 11, 12], 12, 2, 2).cap_emit(1); - assert_eq!(step1.emit.as_slice(), &[10]); - assert_eq!(step1.next_seed, 10); - assert!(step1.emit.len() <= 1); - - // Mid-window semantic consume of 2 of 4 emitted tokens. - let step = SpecStep::new([10, 11, 12, 13], 13, 4, 3); - let host = hipfire_generate::qwen::spec_host_advance_after_step( - 100, - 0, - Vec::new(), - &step.emit, - step.next_seed, - 2, - ); - assert_eq!(host.emitted, vec![10, 11]); - assert_eq!(host.generated, 2); - assert_eq!(host.position, 102); - assert_eq!(host.seed_token, 11); - // Full-window consume keeps step.next_seed when prefix covers emit. - let host_full = hipfire_generate::qwen::spec_host_advance_after_step( - 100, - 0, - Vec::new(), - &step.emit, - step.next_seed, - 4, - ); - assert_eq!(host_full.emitted, vec![10, 11, 12, 13]); - assert_eq!(host_full.position, 104); - assert_eq!(host_full.seed_token, 13); - // Unconsumed tail must not inflate position/conversation. - assert_ne!(host.position, 100 + step.emit.len()); -} - -#[test] -fn spec_prefix_realign_plan_empty_raw_and_multi() { - let prompt = vec![1u32, 2, 3]; - let empty = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 99, &[]); - assert_eq!(empty.replay, prompt); - assert_eq!(empty.position, 3); - assert_eq!(empty.seed_token, 99); - - let multi = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 99, &[10, 11, 12]); - assert_eq!(multi.replay, vec![1, 2, 3, 99, 10, 11]); - assert_eq!(multi.position, 6); - assert_eq!(multi.seed_token, 12); - assert_eq!(multi.replay.len(), multi.position); - // Last raw stays the unwritten seed — never sits in KV replay. - assert_ne!(multi.replay.last().copied(), Some(multi.seed_token)); - // Naive prompt+raw drops first_token and writes the seed into KV. - let mut naive = prompt.clone(); - naive.extend_from_slice(&[10, 11, 12]); - assert_ne!(multi.replay, naive); -} - -#[test] -fn terminal_marker_mid_window_strict_prefix_realigns() { - // Spec window emits body + im_end + unobserved tail. Semantic loop - // consumes only through the terminal marker; host + realign plan must - // land exactly on that prefix (no unobserved tail in conversation or KV). - let tok = test_tokenizer(); - let prompt = vec![4u32, 5]; - let first_token = tok.encode("hi")[0]; - let body = tok.encode("ok"); - let im_end = 1u32; - let mut step_emit = body.clone(); - step_emit.push(im_end); - step_emit.extend_from_slice(&[90, 91]); - let step = SpecStep::new(step_emit.clone(), *step_emit.last().unwrap(), 4, 3); - - let mut emit = make_qwen_emit(&tok, AssistantPrefix::Plain); - let _ = emit.begin(first_token); - let mut consumed = 0usize; - let mut raw_decode: Vec = Vec::new(); - let mut hit_eos = false; - for &tok_id in &step.emit { - let outcome = emit.observe(tok_id); - if outcome.stop == Some(StopReason::GrammarViolation) { - break; + assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); + assert!(!matches!(term, hipfire_generate::qwen::QwenDflashWireTerminal::Done { .. })); + // Production Malformed writer: error XOR done (GPU-less attested epilogue). + set_active_attempt_id(21); + let mut sink = Vec::new(); + if let hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { + message, + class, + retryable, + rolled_back, + } = &term + { + let ep = attest_epilogue(*rolled_back); + hipfire_generate::qwen::emit_qwen_dflash_malformed_terminal( + &mut sink, "req-ot", message, class, *retryable, &ep, + ); } - consumed += 1; - raw_decode.push(tok_id); - if matches!( - outcome.stop, - Some(StopReason::Eos) | Some(StopReason::StopSequence) - ) { - hit_eos = true; - break; + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["attempt_id"], 21); + assert!(!out.contains(r#""type":"done""#)); + } + + #[test] + fn open_think_prompt_started_and_generated_flags() { + // (a) prompt-started OpenThink; (b) generated unclosed . + let cases = [ + ("prompt", AssistantPrefix::OpenThink, "still thinking"), + ("generated", AssistantPrefix::Plain, "pre secret"), + ]; + for (label, prefix, body) in cases { + let (stream, fin, _raw) = drive_qwen_emit(body, prefix); + let reasoning: String = stream + .iter() + .filter_map(|e| match e { + ClientEvent::Reasoning(text) => Some(text.as_str()), + _ => None, + }) + .collect(); + let expected_reasoning = if label == "prompt" { + "still thinking" + } else { + "secret" + }; + assert_eq!(reasoning, expected_reasoning, "{label}"); + assert!(fin.open_think, "{label}: open_think"); + assert_eq!(fin.finish_reason, "open_think", "{label}"); + assert_eq!(fin.tool_calls, 0, "{label}"); + assert!(fin.events.is_empty(), "{label}: no release on open_think"); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); + assert!( + matches!(term, hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { .. }), + "{label}: expected Malformed" + ); + assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store, "{label}"); } } - assert!(hit_eos, "im_end must stop the emitter"); - assert_eq!( - consumed, - body.len() + 1, - "must consume body+im_end only, not tail {:?}", - &step.emit[consumed..] - ); - assert!( - consumed < step.emit.len(), - "fixture must leave an unobserved speculative tail" - ); - - let position_before = prompt.len(); - let host = hipfire_generate::qwen::spec_host_advance_after_step( - position_before, - 0, - vec![first_token], - &step.emit, - step.next_seed, - consumed, - ); - assert_eq!(host.generated, consumed); - assert_eq!(host.position, position_before + consumed); - assert_eq!(host.seed_token, im_end); - assert_eq!(&host.emitted[1..], &step.emit[..consumed]); - assert!(!host.emitted.contains(&90) && !host.emitted.contains(&91)); - - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); - let mut expected_replay = prompt.clone(); - expected_replay.push(first_token); - expected_replay.extend_from_slice(&raw_decode[..raw_decode.len() - 1]); - assert_eq!(plan.replay, expected_replay); - assert_eq!(plan.position, prompt.len() + raw_decode.len()); - assert_eq!(plan.seed_token, im_end); - assert_eq!(plan.position, host.position); - assert_eq!(plan.seed_token, host.seed_token); - assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); -} - -#[test] -fn empty_event_eos_mid_window_still_realigns_raw_prefix() { - // Empty-event EOS observes still advance position/raw_decode (filter - // stop on decoded marker bytes). Host + realign must track them. - let tok = test_tokenizer(); - let prompt = vec![4u32]; - let first_token = 100 + b'h' as u32; // byte-map 'h' - // Fragment <|im_end|> across byte-map tokens so filter stops without - // a single special-id observe; final fragment may yield empty events. - let marker = b"<|im_end|>"; - let mut step_emit: Vec = vec![100 + b'i' as u32]; // "i" after seed "h" - for &b in marker { - step_emit.push(100 + b as u32); - } - step_emit.extend_from_slice(&[90, 91]); // unobserved tail - let step = SpecStep::new(step_emit.clone(), 91, step_emit.len(), step_emit.len() - 1); - - let mut emit = make_qwen_emit(&tok, AssistantPrefix::Plain); - let _ = emit.begin(first_token); - let mut consumed = 0usize; - let mut raw_decode: Vec = Vec::new(); - let mut hit_eos = false; - for &tok_id in &step.emit { - let outcome = emit.observe(tok_id); - consumed += 1; - raw_decode.push(tok_id); - // Empty-event EOS still counts as a position-advancing observe. - if matches!( - outcome.stop, - Some(StopReason::Eos) | Some(StopReason::StopSequence) - ) { - hit_eos = true; - break; + + #[test] + fn producer_decoded_eot_beats_length_without_token_rescan() { + // Real emitter decoded_eot at budget boundary → stop, not length. + let tok = test_tokenizer(); + let mut ids = tok.encode("hi"); + ids.push(1); // <|im_end|> + let (_stream, fin, _raw) = drive_qwen_ids(&ids, AssistantPrefix::Plain); + assert!(fin.decoded_eot, "emitter must set decoded_eot"); + assert_eq!(fin.finish_reason, "stop"); + let generated = ids.len(); + let max_tokens = generated; + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( + generated, + max_tokens, + fin.decoded_eot, + false + )); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "hi", false); + assert!(matches!( + term, + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason: "stop", + store_cache: true, + .. + } + )); + } + + #[test] + fn split_decoded_eot_at_cap_is_stop_not_length() { + // Byte-fragment the <|im_end|> marker across tokens via 100+b map. + let marker = b"<|im_end|>"; + let mut ids: Vec = Vec::new(); + // prose "hi" + ids.push(100 + b'h' as u32); + ids.push(100 + b'i' as u32); + // split marker into two fragments + let mid = marker.len() / 2; + for &b in &marker[..mid] { + ids.push(100 + b as u32); } + for &b in &marker[mid..] { + ids.push(100 + b as u32); + } + let (stream, fin, raw) = drive_qwen_ids(&ids, AssistantPrefix::Plain); + assert!(fin.decoded_eot, "split EOT must set decoded_eot"); + assert_eq!(fin.finish_reason, "stop"); + let visible: String = stream + .iter() + .filter_map(|ev| match ev { + ClientEvent::Token(t) => Some(t.as_str()), + _ => None, + }) + .collect(); + assert!(!visible.contains("<|im_end|>"), "marker bytes suppressed"); + assert!(visible.contains("hi")); + assert!(!raw.is_empty()); + let generated = raw.len(); + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( + generated, + generated, + fin.decoded_eot, + false + )); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, &visible, false); + assert!(matches!( + term, + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason: "stop", + store_cache: true, + release_tool_calls: false, + .. + } + )); + } + + #[test] + fn step_budget_max_emit_zero_one_and_mid_window_prefix() { + // max_emit 0: empty emit is the defensive shape (live step returns Err). + let step0 = SpecStep::new([10, 11], 11, 1, 1).cap_emit(0); + assert!(step0.emit.is_empty()); + assert_eq!(step0.accepted, 0); + + // max_emit 1: prefix keep + seed reseeds from kept token. + let step1 = SpecStep::new([10, 11, 12], 12, 2, 2).cap_emit(1); + assert_eq!(step1.emit.as_slice(), &[10]); + assert_eq!(step1.next_seed, 10); + assert!(step1.emit.len() <= 1); + + // Mid-window semantic consume of 2 of 4 emitted tokens. + let step = SpecStep::new([10, 11, 12, 13], 13, 4, 3); + let host = hipfire_generate::qwen::spec_host_advance_after_step(100, 0, Vec::new(), &step.emit, step.next_seed, 2); + assert_eq!(host.emitted, vec![10, 11]); + assert_eq!(host.generated, 2); + assert_eq!(host.position, 102); + assert_eq!(host.seed_token, 11); + // Full-window consume keeps step.next_seed when prefix covers emit. + let host_full = + hipfire_generate::qwen::spec_host_advance_after_step(100, 0, Vec::new(), &step.emit, step.next_seed, 4); + assert_eq!(host_full.emitted, vec![10, 11, 12, 13]); + assert_eq!(host_full.position, 104); + assert_eq!(host_full.seed_token, 13); + // Unconsumed tail must not inflate position/conversation. + assert_ne!(host.position, 100 + step.emit.len()); + } + + #[test] + fn spec_prefix_realign_plan_empty_raw_and_multi() { + let prompt = vec![1u32, 2, 3]; + let empty = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 99, &[]); + assert_eq!(empty.replay, prompt); + assert_eq!(empty.position, 3); + assert_eq!(empty.seed_token, 99); + + let multi = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 99, &[10, 11, 12]); + assert_eq!(multi.replay, vec![1, 2, 3, 99, 10, 11]); + assert_eq!(multi.position, 6); + assert_eq!(multi.seed_token, 12); + assert_eq!(multi.replay.len(), multi.position); + // Last raw stays the unwritten seed — never sits in KV replay. + assert_ne!(multi.replay.last().copied(), Some(multi.seed_token)); + // Naive prompt+raw drops first_token and writes the seed into KV. + let mut naive = prompt.clone(); + naive.extend_from_slice(&[10, 11, 12]); + assert_ne!(multi.replay, naive); } - assert!(hit_eos, "split marker must stop via filter"); - assert!(consumed < step.emit.len(), "tail must remain unobserved"); - assert_eq!(raw_decode.len(), consumed); - - let host = hipfire_generate::qwen::spec_host_advance_after_step( - prompt.len(), - 0, - vec![first_token], - &step.emit, - step.next_seed, - consumed, - ); - assert_eq!(host.generated, consumed); - assert_eq!(host.position, prompt.len() + consumed); - assert!(!host.emitted.contains(&90)); - - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); - assert_eq!(plan.position, host.position); - assert_eq!(plan.seed_token, host.seed_token); - assert_eq!(plan.replay.len(), plan.position); - assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); -} - -#[test] -fn multi_window_then_strict_prefix_realign() { - // After a full first window, raw_decode holds W1; a second window stops - // mid-prefix. Realign replays prompt+first+raw[..-1] across both windows. - let prompt = vec![7u32, 8]; - let first_token = 50u32; - // Window 1 full consume (no realign). - let w1 = SpecStep::new([10u32, 11, 12], 12, 3, 2); - let mut raw_decode = Vec::new(); - let mut position = prompt.len(); - let mut emitted = vec![first_token]; - let mut generated = 0usize; - let host1 = hipfire_generate::qwen::spec_host_advance_after_step( - position, - generated, - emitted.clone(), - &w1.emit, - w1.next_seed, - w1.emit.len(), - ); - position = host1.position; - generated = host1.generated; - emitted = host1.emitted; - raw_decode.extend_from_slice(&w1.emit); - assert_eq!(position, prompt.len() + w1.emit.len()); - assert_eq!(host1.seed_token, 12); - - // Window 2: consume 2 of 4 (strict prefix → realign). - let w2 = SpecStep::new([20u32, 21, 22, 23], 23, 4, 3); - let consumed2 = 2usize; - raw_decode.extend_from_slice(&w2.emit[..consumed2]); - let host2 = hipfire_generate::qwen::spec_host_advance_after_step( - position, - generated, - emitted, - &w2.emit, - w2.next_seed, - consumed2, - ); - assert_eq!(host2.emitted, vec![first_token, 10, 11, 12, 20, 21]); - assert_eq!(host2.position, prompt.len() + raw_decode.len()); - assert_eq!(host2.seed_token, 21); - assert!(!host2.emitted.contains(&22) && !host2.emitted.contains(&23)); - - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); - assert_eq!( - plan.replay, - vec![7, 8, first_token, 10, 11, 12, 20] // drops last raw (21) - ); - assert_eq!(plan.position, host2.position); - assert_eq!(plan.seed_token, host2.seed_token); - assert_eq!(plan.seed_token, 21); -} - -#[test] -fn forced_token_mid_window_strict_prefix_then_force_advance() { - // Think-budget force-close mid-window: observe only the forced-trigger - // prefix of step.emit, realign host/plan to that prefix, then host - // advances over the forced continuation tokens as raw_decode. - let tok = test_tokenizer(); - let prompt = vec![4u32, 5]; - let open_think = 2u32; // - - let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { - tokenizer: &tok, - eos: 9, - im_end: Some(1), - tools: None, - stop: Vec::new(), - max_think: 1, - max_tokens: 256, - assistant_prefix: AssistantPrefix::Plain, - think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, - decoded_vocab: None, - }); - - let begin = emit.begin(open_think); - assert!(begin.stop.is_none()); - - let think_body = tok.encode("x"); - assert_eq!(think_body.len(), 1); - let step_emit = vec![think_body[0], 90, 91, 92]; - let step = SpecStep::new(step_emit.clone(), 92, 4, 3); - - let mut consumed = 0usize; - let mut raw_decode: Vec = Vec::new(); - let mut forced_after: Vec = Vec::new(); - for &tok_id in &step.emit { - let outcome = emit.observe(tok_id); - if outcome.stop == Some(StopReason::GrammarViolation) { - break; + + #[test] + fn terminal_marker_mid_window_strict_prefix_realigns() { + // Spec window emits body + im_end + unobserved tail. Semantic loop + // consumes only through the terminal marker; host + realign plan must + // land exactly on that prefix (no unobserved tail in conversation or KV). + let tok = test_tokenizer(); + let prompt = vec![4u32, 5]; + let first_token = tok.encode("hi")[0]; + let body = tok.encode("ok"); + let im_end = 1u32; + let mut step_emit = body.clone(); + step_emit.push(im_end); + step_emit.extend_from_slice(&[90, 91]); + let step = SpecStep::new(step_emit.clone(), *step_emit.last().unwrap(), 4, 3); + + let mut emit = make_qwen_emit(&tok, AssistantPrefix::Plain); + let _ = emit.begin(first_token); + let mut consumed = 0usize; + let mut raw_decode: Vec = Vec::new(); + let mut hit_eos = false; + for &tok_id in &step.emit { + let outcome = emit.observe(tok_id); + if outcome.stop == Some(StopReason::GrammarViolation) { + break; + } + consumed += 1; + raw_decode.push(tok_id); + if matches!( + outcome.stop, + Some(StopReason::Eos) | Some(StopReason::StopSequence) + ) { + hit_eos = true; + break; + } } - consumed += 1; - raw_decode.push(tok_id); - let forced = emit.take_forced(); - if !forced.is_empty() { - forced_after = forced; - break; + assert!(hit_eos, "im_end must stop the emitter"); + assert_eq!( + consumed, + body.len() + 1, + "must consume body+im_end only, not tail {:?}", + &step.emit[consumed..] + ); + assert!( + consumed < step.emit.len(), + "fixture must leave an unobserved speculative tail" + ); + + let position_before = prompt.len(); + let host = hipfire_generate::qwen::spec_host_advance_after_step( + position_before, + 0, + vec![first_token], + &step.emit, + step.next_seed, + consumed, + ); + assert_eq!(host.generated, consumed); + assert_eq!(host.position, position_before + consumed); + assert_eq!(host.seed_token, im_end); + assert_eq!(&host.emitted[1..], &step.emit[..consumed]); + assert!(!host.emitted.contains(&90) && !host.emitted.contains(&91)); + + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); + let mut expected_replay = prompt.clone(); + expected_replay.push(first_token); + expected_replay.extend_from_slice(&raw_decode[..raw_decode.len() - 1]); + assert_eq!(plan.replay, expected_replay); + assert_eq!(plan.position, prompt.len() + raw_decode.len()); + assert_eq!(plan.seed_token, im_end); + assert_eq!(plan.position, host.position); + assert_eq!(plan.seed_token, host.seed_token); + assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); + } + + #[test] + fn empty_event_eos_mid_window_still_realigns_raw_prefix() { + // Empty-event EOS observes still advance position/raw_decode (filter + // stop on decoded marker bytes). Host + realign must track them. + let tok = test_tokenizer(); + let prompt = vec![4u32]; + let first_token = 100 + b'h' as u32; // byte-map 'h' + // Fragment <|im_end|> across byte-map tokens so filter stops without + // a single special-id observe; final fragment may yield empty events. + let marker = b"<|im_end|>"; + let mut step_emit: Vec = vec![100 + b'i' as u32]; // "i" after seed "h" + for &b in marker { + step_emit.push(100 + b as u32); } - if outcome.stop.is_some() { - break; + step_emit.extend_from_slice(&[90, 91]); // unobserved tail + let step = SpecStep::new(step_emit.clone(), 91, step_emit.len(), step_emit.len() - 1); + + let mut emit = make_qwen_emit(&tok, AssistantPrefix::Plain); + let _ = emit.begin(first_token); + let mut consumed = 0usize; + let mut raw_decode: Vec = Vec::new(); + let mut hit_eos = false; + for &tok_id in &step.emit { + let outcome = emit.observe(tok_id); + consumed += 1; + raw_decode.push(tok_id); + // Empty-event EOS still counts as a position-advancing observe. + if matches!( + outcome.stop, + Some(StopReason::Eos) | Some(StopReason::StopSequence) + ) { + hit_eos = true; + break; + } } + assert!(hit_eos, "split marker must stop via filter"); + assert!(consumed < step.emit.len(), "tail must remain unobserved"); + assert_eq!(raw_decode.len(), consumed); + + let host = hipfire_generate::qwen::spec_host_advance_after_step( + prompt.len(), + 0, + vec![first_token], + &step.emit, + step.next_seed, + consumed, + ); + assert_eq!(host.generated, consumed); + assert_eq!(host.position, prompt.len() + consumed); + assert!(!host.emitted.contains(&90)); + + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); + assert_eq!(plan.position, host.position); + assert_eq!(plan.seed_token, host.seed_token); + assert_eq!(plan.replay.len(), plan.position); + assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); } - assert_eq!(consumed, 1, "force must fire on the budget-hitting token"); - assert!( - !forced_after.is_empty(), - "think budget must queue continuation" - ); - assert!(consumed < step.emit.len(), "must leave unobserved tail"); - - let position_before = prompt.len(); - let host = hipfire_generate::qwen::spec_host_advance_after_step( - position_before, - 0, - vec![open_think], - &step.emit, - step.next_seed, - consumed, - ); - assert_eq!(host.generated, 1); - assert_eq!(host.position, position_before + 1); - assert_eq!(host.seed_token, think_body[0]); - assert!(!host.emitted.contains(&90)); - - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, open_think, &raw_decode); - assert_eq!(plan.position, host.position); - assert_eq!(plan.seed_token, host.seed_token); - assert_eq!(plan.replay, { - let mut r = prompt.clone(); - r.push(open_think); - r - }); - - // Pending-seed GPU tx: commit [trigger] ++ forced[..n-1]; last forced - // stays unprocessed pending seed (never double-forwarded). - let tx = - hipfire_generate::qwen::spec_forced_pending_seed_tx(plan.seed_token, &forced_after, true); - assert_eq!(tx.commit.first().copied(), Some(plan.seed_token)); - assert_eq!(tx.commit.len(), forced_after.len()); - assert_eq!(tx.pending_seed, *forced_after.last().unwrap()); - // Last forced is never double-forwarded: it is the pending seed, not in - // commit (except the n==1 case where commit is only the prior seed). - if forced_after.len() > 1 { - assert_eq!(&tx.commit[1..], &forced_after[..forced_after.len() - 1]); + + #[test] + fn multi_window_then_strict_prefix_realign() { + // After a full first window, raw_decode holds W1; a second window stops + // mid-prefix. Realign replays prompt+first+raw[..-1] across both windows. + let prompt = vec![7u32, 8]; + let first_token = 50u32; + // Window 1 full consume (no realign). + let w1 = SpecStep::new([10u32, 11, 12], 12, 3, 2); + let mut raw_decode = Vec::new(); + let mut position = prompt.len(); + let mut emitted = vec![first_token]; + let mut generated = 0usize; + let host1 = hipfire_generate::qwen::spec_host_advance_after_step( + position, + generated, + emitted.clone(), + &w1.emit, + w1.next_seed, + w1.emit.len(), + ); + position = host1.position; + generated = host1.generated; + emitted = host1.emitted; + raw_decode.extend_from_slice(&w1.emit); + assert_eq!(position, prompt.len() + w1.emit.len()); + assert_eq!(host1.seed_token, 12); + + // Window 2: consume 2 of 4 (strict prefix → realign). + let w2 = SpecStep::new([20u32, 21, 22, 23], 23, 4, 3); + let consumed2 = 2usize; + raw_decode.extend_from_slice(&w2.emit[..consumed2]); + let host2 = hipfire_generate::qwen::spec_host_advance_after_step( + position, + generated, + emitted, + &w2.emit, + w2.next_seed, + consumed2, + ); + assert_eq!(host2.emitted, vec![first_token, 10, 11, 12, 20, 21]); + assert_eq!(host2.position, prompt.len() + raw_decode.len()); + assert_eq!(host2.seed_token, 21); + assert!(!host2.emitted.contains(&22) && !host2.emitted.contains(&23)); + + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first_token, &raw_decode); assert_eq!( - tx.commit.last().copied(), - Some(forced_after[forced_after.len() - 2]) - ); - } else { - assert_eq!(tx.commit.as_slice(), &[plan.seed_token]); - } - - // Host observes each forced token; position advances by commit.len(). - let mut position = plan.position.saturating_add(tx.position_delta); - let mut generated = host.generated; - let mut emitted = host.emitted.clone(); - let mut seed_token = tx.pending_seed; - for &ft in &forced_after { - generated += 1; - emitted.push(ft); - raw_decode.push(ft); - let fo = emit.observe(ft); + plan.replay, + vec![7, 8, first_token, 10, 11, 12, 20] // drops last raw (21) + ); + assert_eq!(plan.position, host2.position); + assert_eq!(plan.seed_token, host2.seed_token); + assert_eq!(plan.seed_token, 21); + } + + #[test] + fn forced_token_mid_window_strict_prefix_then_force_advance() { + // Think-budget force-close mid-window: observe only the forced-trigger + // prefix of step.emit, realign host/plan to that prefix, then host + // advances over the forced continuation tokens as raw_decode. + let tok = test_tokenizer(); + let prompt = vec![4u32, 5]; + let open_think = 2u32; // + + let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { + tokenizer: &tok, + eos: 9, + im_end: Some(1), + tools: None, + stop: Vec::new(), + max_think: 1, + max_tokens: 256, + assistant_prefix: AssistantPrefix::Plain, + think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, + decoded_vocab: None, + }); + + let begin = emit.begin(open_think); + assert!(begin.stop.is_none()); + + let think_body = tok.encode("x"); + assert_eq!(think_body.len(), 1); + let step_emit = vec![think_body[0], 90, 91, 92]; + let step = SpecStep::new(step_emit.clone(), 92, 4, 3); + + let mut consumed = 0usize; + let mut raw_decode: Vec = Vec::new(); + let mut forced_after: Vec = Vec::new(); + for &tok_id in &step.emit { + let outcome = emit.observe(tok_id); + if outcome.stop == Some(StopReason::GrammarViolation) { + break; + } + consumed += 1; + raw_decode.push(tok_id); + let forced = emit.take_forced(); + if !forced.is_empty() { + forced_after = forced; + break; + } + if outcome.stop.is_some() { + break; + } + } + assert_eq!(consumed, 1, "force must fire on the budget-hitting token"); assert!( - fo.stop.is_none() || fo.stop == Some(StopReason::StopSequence), - "forced continuation should not hard-stop mid-injection: {:?}", - fo.stop - ); - } - assert_eq!(seed_token, *forced_after.last().unwrap()); - assert_eq!(position, plan.position + forced_after.len()); - assert_eq!(generated, consumed + forced_after.len()); - let mut expected_raw = step.emit[..consumed].to_vec(); - expected_raw.extend_from_slice(&forced_after); - assert_eq!(raw_decode, expected_raw); - assert!(!emitted.contains(&90) && !emitted.contains(&91) && !emitted.contains(&92)); - - // Terminal flush would commit the final pending seed exactly once. - let term = hipfire_generate::qwen::spec_terminal_pending_seed_tx(seed_token); - assert_eq!(term.commit, vec![seed_token]); - assert_eq!(term.position_delta, 1); - let position_after_flush = position + term.position_delta; - - let plan2 = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, open_think, &raw_decode); - assert_eq!(plan2.position, prompt.len() + raw_decode.len()); - assert_eq!(plan2.seed_token, seed_token); - assert_eq!(plan2.seed_token, *raw_decode.last().unwrap()); - assert_eq!(plan2.replay.len(), plan2.position); - // After terminal flush, cursor is one past the last conversation token - // (prompt + raw_decode), matching safe bake `m.seq_pos`. - assert_eq!(position_after_flush, prompt.len() + raw_decode.len() + 1); - // Realign still treats last raw as unwritten seed (pre-terminal-flush). - let mut expected_replay = prompt.clone(); - expected_replay.push(open_think); - expected_replay.extend_from_slice(&raw_decode[..raw_decode.len() - 1]); - assert_eq!(plan2.replay, expected_replay); -} - -#[test] -fn cache_seq_trim_eot_vs_length_body_newline() { - let im_end = Some(1u32); - let nl: HashSet = [7u32].into_iter().collect(); - // EOT-terminated: body + im_end + nl → strip trailer. - let eot_stream = vec![10, 11, 1, 7]; - assert_eq!( - hipfire_generate::qwen::qwen_dflash_cache_seq(&eot_stream, im_end, &nl), - vec![10, 11] - ); - // Length-capped body ending on newline: restore verbatim (no im_end). - let len_stream = vec![10, 11, 7]; - assert_eq!( - hipfire_generate::qwen::qwen_dflash_cache_seq(&len_stream, im_end, &nl), - vec![10, 11, 7] - ); - // Pure body, no trailer. - let body = vec![10, 11, 12]; - assert_eq!( - hipfire_generate::qwen::qwen_dflash_cache_seq(&body, im_end, &nl), - body - ); -} - -#[test] -fn step_and_forced_advance_error_helpers_are_xor_done() { - // Production fail-closed writer with GPU-less attested epilogue. - set_active_attempt_id(42); - for (what, id, needle) in [ - ("spec_step", "req-step", "spec_step:"), - ("forced", "req-fa", "forced-token"), - ] { + !forced_after.is_empty(), + "think budget must queue continuation" + ); + assert!(consumed < step.emit.len(), "must leave unobserved tail"); + + let position_before = prompt.len(); + let host = hipfire_generate::qwen::spec_host_advance_after_step( + position_before, + 0, + vec![open_think], + &step.emit, + step.next_seed, + consumed, + ); + assert_eq!(host.generated, 1); + assert_eq!(host.position, position_before + 1); + assert_eq!(host.seed_token, think_body[0]); + assert!(!host.emitted.contains(&90)); + + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, open_think, &raw_decode); + assert_eq!(plan.position, host.position); + assert_eq!(plan.seed_token, host.seed_token); + assert_eq!(plan.replay, { + let mut r = prompt.clone(); + r.push(open_think); + r + }); + + // Pending-seed GPU tx: commit [trigger] ++ forced[..n-1]; last forced + // stays unprocessed pending seed (never double-forwarded). + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(plan.seed_token, &forced_after, true); + assert_eq!(tx.commit.first().copied(), Some(plan.seed_token)); + assert_eq!(tx.commit.len(), forced_after.len()); + assert_eq!(tx.pending_seed, *forced_after.last().unwrap()); + // Last forced is never double-forwarded: it is the pending seed, not in + // commit (except the n==1 case where commit is only the prior seed). + if forced_after.len() > 1 { + assert_eq!(&tx.commit[1..], &forced_after[..forced_after.len() - 1]); + assert_eq!( + tx.commit.last().copied(), + Some(forced_after[forced_after.len() - 2]) + ); + } else { + assert_eq!(tx.commit.as_slice(), &[plan.seed_token]); + } + + // Host observes each forced token; position advances by commit.len(). + let mut position = plan.position.saturating_add(tx.position_delta); + let mut generated = host.generated; + let mut emitted = host.emitted.clone(); + let mut seed_token = tx.pending_seed; + for &ft in &forced_after { + generated += 1; + emitted.push(ft); + raw_decode.push(ft); + let fo = emit.observe(ft); + assert!( + fo.stop.is_none() || fo.stop == Some(StopReason::StopSequence), + "forced continuation should not hard-stop mid-injection: {:?}", + fo.stop + ); + } + assert_eq!(seed_token, *forced_after.last().unwrap()); + assert_eq!(position, plan.position + forced_after.len()); + assert_eq!(generated, consumed + forced_after.len()); + let mut expected_raw = step.emit[..consumed].to_vec(); + expected_raw.extend_from_slice(&forced_after); + assert_eq!(raw_decode, expected_raw); + assert!(!emitted.contains(&90) && !emitted.contains(&91) && !emitted.contains(&92)); + + // Terminal flush would commit the final pending seed exactly once. + let term = hipfire_generate::qwen::spec_terminal_pending_seed_tx(seed_token); + assert_eq!(term.commit, vec![seed_token]); + assert_eq!(term.position_delta, 1); + let position_after_flush = position + term.position_delta; + + let plan2 = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, open_think, &raw_decode); + assert_eq!(plan2.position, prompt.len() + raw_decode.len()); + assert_eq!(plan2.seed_token, seed_token); + assert_eq!(plan2.seed_token, *raw_decode.last().unwrap()); + assert_eq!(plan2.replay.len(), plan2.position); + // After terminal flush, cursor is one past the last conversation token + // (prompt + raw_decode), matching safe bake `m.seq_pos`. + assert_eq!(position_after_flush, prompt.len() + raw_decode.len() + 1); + // Realign still treats last raw as unwritten seed (pre-terminal-flush). + let mut expected_replay = prompt.clone(); + expected_replay.push(open_think); + expected_replay.extend_from_slice(&raw_decode[..raw_decode.len() - 1]); + assert_eq!(plan2.replay, expected_replay); + } + + #[test] + fn cache_seq_trim_eot_vs_length_body_newline() { + let im_end = Some(1u32); + let nl: HashSet = [7u32].into_iter().collect(); + // EOT-terminated: body + im_end + nl → strip trailer. + let eot_stream = vec![10, 11, 1, 7]; + assert_eq!( + hipfire_generate::qwen::qwen_dflash_cache_seq(&eot_stream, im_end, &nl), + vec![10, 11] + ); + // Length-capped body ending on newline: restore verbatim (no im_end). + let len_stream = vec![10, 11, 7]; + assert_eq!( + hipfire_generate::qwen::qwen_dflash_cache_seq(&len_stream, im_end, &nl), + vec![10, 11, 7] + ); + // Pure body, no trailer. + let body = vec![10, 11, 12]; + assert_eq!(hipfire_generate::qwen::qwen_dflash_cache_seq(&body, im_end, &nl), body); + } + + #[test] + fn step_and_forced_advance_error_helpers_are_xor_done() { + // Production fail-closed writer with GPU-less attested epilogue. + set_active_attempt_id(42); + for (what, id, needle) in [ + ("spec_step", "req-step", "spec_step:"), + ("forced", "req-fa", "forced-token"), + ] { + let mut sink = Vec::new(); + let ep = attest_epilogue(true); + hipfire_generate::qwen::emit_spec_failure_terminal(&mut sink, id, what, "boom", &ep); + let text = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&text); + assert_eq!(lines.len(), 1, "error XOR done: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["attempt_id"], 42); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], true); + assert!(lines[0]["message"].as_str().unwrap().contains(needle)); + assert!(!text.contains(r#""type":"done""#)); + assert!(!text.contains(r#""type":"tool_calls""#)); + } + // rolled_back=false + context path (sync could not be attested). + let mut sink = Vec::new(); + let ep = attest_epilogue_with_context("device_synchronize failed: test"); + hipfire_generate::qwen::emit_spec_failure_terminal(&mut sink, "req-ctx", "spec_step", "boom", &ep); + let text = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&text); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["rolled_back"], false); + assert!(lines[0]["message"] + .as_str() + .unwrap() + .contains("device_synchronize failed")); + // Wrapper None contract: no epilogue after early exit. + assert!(!qwen_dflash_epilogue_after_spec_run(false)); + assert!(qwen_dflash_epilogue_after_spec_run(true)); + } + + #[test] + fn forced_advance_error_is_xor_done_no_calls() { + set_active_attempt_id(43); let mut sink = Vec::new(); let ep = attest_epilogue(true); - hipfire_generate::qwen::emit_spec_failure_terminal(&mut sink, id, what, "boom", &ep); + hipfire_generate::qwen::emit_spec_failure_terminal(&mut sink, "req-fa", "forced", "boom", &ep); let text = String::from_utf8(sink).unwrap(); let lines = parse_jsonl(&text); - assert_eq!(lines.len(), 1, "error XOR done: {lines:?}"); + assert_eq!(lines.len(), 1); assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["attempt_id"], 42); - assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["attempt_id"], 43); assert_eq!(lines[0]["rolled_back"], true); - assert!(lines[0]["message"].as_str().unwrap().contains(needle)); + assert!(lines[0]["message"] + .as_str() + .unwrap() + .contains("forced-token")); assert!(!text.contains(r#""type":"done""#)); assert!(!text.contains(r#""type":"tool_calls""#)); } - // rolled_back=false + context path (sync could not be attested). - let mut sink = Vec::new(); - let ep = attest_epilogue_with_context("device_synchronize failed: test"); - hipfire_generate::qwen::emit_spec_failure_terminal( - &mut sink, - "req-ctx", - "spec_step", - "boom", - &ep, - ); - let text = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&text); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["rolled_back"], false); - assert!(lines[0]["message"] - .as_str() - .unwrap() - .contains("device_synchronize failed")); - // Wrapper None contract: no epilogue after early exit. - assert!(!qwen_dflash_epilogue_after_spec_run(false)); - assert!(qwen_dflash_epilogue_after_spec_run(true)); -} - -#[test] -fn forced_advance_error_is_xor_done_no_calls() { - set_active_attempt_id(43); - let mut sink = Vec::new(); - let ep = attest_epilogue(true); - hipfire_generate::qwen::emit_spec_failure_terminal(&mut sink, "req-fa", "forced", "boom", &ep); - let text = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&text); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["attempt_id"], 43); - assert_eq!(lines[0]["rolled_back"], true); - assert!(lines[0]["message"] - .as_str() - .unwrap() - .contains("forced-token")); - assert!(!text.contains(r#""type":"done""#)); - assert!(!text.contains(r#""type":"tool_calls""#)); -} - -#[test] -fn decoded_eot_beats_length_cap_helper() { - let fin = summary_stop("hi"); - assert!(hipfire_generate::common::qwen_dflash_hit_length_cap( - 8, 8, false, false - )); - // Emitter semantic stop at cap is also not length (independent of EOT). - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( - 8, 8, false, true - )); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "hi", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - store_cache, - release_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "length"); - assert!(!*store_cache); - assert!(!*release_tool_calls); - } - other => panic!("{other:?}"), - } - assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap( - 8, 8, true, false - )); - let tok = test_tokenizer(); - let mut ids = tok.encode("hi"); - ids.push(1); - let (_s, fin_eot, _) = drive_qwen_ids(&ids, AssistantPrefix::Plain); - assert!(fin_eot.decoded_eot); - let term = - hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin_eot, false, false, "hi", false); - assert!(matches!( - term, - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason: "stop", - store_cache: true, - .. - } - )); -} - -#[test] -fn ordinary_length_cutoff_no_calls_no_cache() { - let calls = vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "x", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - wire_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "length"); - assert!(!*release_tool_calls); - assert!(!*store_cache); - assert!(wire_tool_calls.is_empty()); + + #[test] + fn decoded_eot_beats_length_cap_helper() { + let fin = summary_stop("hi"); + assert!(hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, false, false)); + // Emitter semantic stop at cap is also not length (independent of EOT). + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, false, true)); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "hi", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + store_cache, + release_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*store_cache); + assert!(!*release_tool_calls); + } + other => panic!("{other:?}"), } - other => panic!("{other:?}"), - } - assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); -} - -#[test] -fn cancel_is_fold_compatible_no_cache_helper() { - // Production cancel writer (same path as hipfire_generate::qwen::generate_spec abort sites). - set_active_attempt_id(11); - let mut sink = Vec::new(); - emit_qwen_ar_cancelled(&mut sink, "c", 3); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0]["type"], "aborted"); - assert_eq!(lines[0]["reason"], "client_cancelled"); - assert_eq!(lines[0]["attempt_id"], 11); - assert_eq!(lines[1]["type"], "done"); - assert_eq!(lines[1]["finish_reason"], "aborted"); - assert_eq!(lines[1]["completion_tokens"], 3); - // Cancel never goes through hipfire_generate::qwen::qwen_dflash_wire_terminal store path. - assert!(!out.contains(r#""finish_reason":"stop""#)); -} - -#[test] -fn serde_done_v2_hostile_id_roundtrip() { - set_active_attempt_id(5); - let id = "id\"quote\"\n"; - let mut sink = Vec::new(); - emit_qwen_dflash_done_terminal( - &mut sink, id, 2, 1.0, 1, 1.0, 1.0, 1.0, 1.0, 1.0, 1, 0, "stop", None, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "done"); - assert_eq!(lines[0]["id"], id); - assert_eq!(lines[0]["attempt_id"], 5); - assert_eq!(lines[0]["finish_reason"], "stop"); - assert_eq!(lines[0]["dflash"], true); -} - -#[test] -fn grammar_lifecycle_error_only_serialized() { - set_active_attempt_id(7); - let fin = summary_tool_calls(vec![ToolCall { - id: None, - name: "t".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, true, "x", false); - let mut sink = Vec::new(); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { - message, - class, - retryable, - rolled_back, - } => { - let ep = attest_epilogue(*rolled_back); - hipfire_generate::qwen::emit_qwen_dflash_malformed_terminal( - &mut sink, "g1", message, class, *retryable, &ep, - ); + assert!(!hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, true, false)); + let tok = test_tokenizer(); + let mut ids = tok.encode("hi"); + ids.push(1); + let (_s, fin_eot, _) = drive_qwen_ids(&ids, AssistantPrefix::Plain); + assert!(fin_eot.decoded_eot); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin_eot, false, false, "hi", false); + assert!(matches!( + term, + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason: "stop", + store_cache: true, + .. + } + )); + } + + #[test] + fn ordinary_length_cutoff_no_calls_no_cache() { + let calls = vec![ToolCall { + id: None, + name: "t".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "x", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*release_tool_calls); + assert!(!*store_cache); + assert!(wire_tool_calls.is_empty()); + } + other => panic!("{other:?}"), } - other => panic!("{other:?}"), - } - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["attempt_id"], 7); - assert_eq!(lines[0]["id"], "g1"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); -} - -#[test] -fn serde_v2_token_and_tool_calls_hostile_id() { - set_active_attempt_id(9); - let mut sink = Vec::new(); - let id = "a\"b\n"; - hipfire_generate::qwen::render_client_events( - &mut sink, - id, - &[ - ClientEvent::Token("hi".into()), - ClientEvent::Reasoning("r".into()), - ], - 0, - false, - ); - emit_tool_calls_event( - &mut sink, - id, - &[ToolCall { + assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); + } + + #[test] + fn cancel_is_fold_compatible_no_cache_helper() { + // Production cancel writer (same path as hipfire_generate::qwen::generate_spec abort sites). + set_active_attempt_id(11); + let mut sink = Vec::new(); + emit_qwen_ar_cancelled(&mut sink, "c", 3); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "aborted"); + assert_eq!(lines[0]["reason"], "client_cancelled"); + assert_eq!(lines[0]["attempt_id"], 11); + assert_eq!(lines[1]["type"], "done"); + assert_eq!(lines[1]["finish_reason"], "aborted"); + assert_eq!(lines[1]["completion_tokens"], 3); + // Cancel never goes through hipfire_generate::qwen::qwen_dflash_wire_terminal store path. + assert!(!out.contains(r#""finish_reason":"stop""#)); + } + + #[test] + fn serde_done_v2_hostile_id_roundtrip() { + set_active_attempt_id(5); + let id = "id\"quote\"\n"; + let mut sink = Vec::new(); + emit_qwen_dflash_done_terminal( + &mut sink, id, 2, 1.0, 1, 1.0, 1.0, 1.0, 1.0, 1.0, 1, 0, "stop", None, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "done"); + assert_eq!(lines[0]["id"], id); + assert_eq!(lines[0]["attempt_id"], 5); + assert_eq!(lines[0]["finish_reason"], "stop"); + assert_eq!(lines[0]["dflash"], true); + } + + #[test] + fn grammar_lifecycle_error_only_serialized() { + set_active_attempt_id(7); + let fin = summary_tool_calls(vec![ToolCall { id: None, - name: "n".into(), - arguments: serde_json::json!({"x": 1}), + name: "t".into(), + arguments: serde_json::json!({}), rendered_body: None, - }], - ); - let out = String::from_utf8(sink).unwrap(); - for line in out.lines().filter(|l| !l.is_empty()) { - let v: serde_json::Value = serde_json::from_str(line).expect(line); - assert_eq!(v["attempt_id"], 9); - assert_eq!(v["id"], id); - } - let types: Vec<_> = parse_jsonl(&out) - .into_iter() - .map(|v| v["type"].as_str().unwrap().to_string()) - .collect(); - assert!(types.contains(&"token".to_string())); - assert!(types.contains(&"reasoning".to_string())); - assert!(types.contains(&"tool_calls".to_string())); -} - -#[test] -fn cancel_wire_helpers_carry_attempt_id() { - // Production cancel writer carries attempt_id on aborted + done. - set_active_attempt_id(3); - let mut sink = Vec::new(); - emit_qwen_ar_cancelled(&mut sink, "c1", 5); - let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0]["type"], "aborted"); - assert_eq!(lines[0]["attempt_id"], 3); - assert_eq!(lines[0]["reason"], "client_cancelled"); - assert_eq!(lines[1]["type"], "done"); - assert_eq!(lines[1]["finish_reason"], "aborted"); - assert_eq!(lines[1]["attempt_id"], 3); - assert_eq!(lines[1]["completion_tokens"], 5); -} - -#[test] -fn cache_fingerprint_uses_visible_not_raw_markers() { - let fin = summary_stop("visible only"); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( - &fin, - false, - false, - "visible only", - false, - ); - let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - assert!(!action.fingerprint_text.contains("")); - assert!(!action.fingerprint_text.contains("")); - assert!(action.fingerprint_text.contains("visible")); - let mut stored = None; - let fp = hipfire_generate::qwen::qwen_dflash_apply_cache_action( - |f, seq| { - stored = Some((f, seq)); - }, - &action, - vec![10, 20, 30], - ); - assert!(fp.is_some()); - let (f, seq) = stored.expect("insert"); - assert_eq!(seq, vec![10, 20, 30]); - assert_eq!( - f, - hipfire_generate::common::asst_turn_fingerprint( - &action.fingerprint_text, - &action.tool_calls - ) - ); -} - -#[test] -fn qwen_dflash_contract_version_is_v2() { - assert_eq!(QWEN_DFLASH_SEMANTIC_CONTRACT_VERSION, 2); - assert_eq!( - hipfire_generate::common::gen_start_contract_version_for_arch(5), - Some(2) - ); - assert_eq!( - hipfire_generate::common::gen_start_contract_version_for_arch(6), - Some(2) - ); -} - -#[test] -fn no_whole_output_parser_in_terminal_path() { - // Terminal path authority is FinishSummary fields only — a finish with - // empty held calls cannot invent tools from visible text markers. - let fin = FinishSummary { - events: vec![ClientEvent::Token( - "{\"name\":\"x\",\"arguments\":{}}".into(), - )], - finish_reason: "stop", - tool_calls: 0, - visible_text: String::new(), - decoded_eot: false, - open_think: false, - }; - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); - match term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - wire_tool_calls, - .. - } => { - assert_eq!(finish_reason, "stop"); - assert!(!release_tool_calls); - assert!(wire_tool_calls.is_empty()); + }]); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, true, "x", false); + let mut sink = Vec::new(); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Malformed { + message, + class, + retryable, + rolled_back, + } => { + let ep = attest_epilogue(*rolled_back); + hipfire_generate::qwen::emit_qwen_dflash_malformed_terminal( + &mut sink, "g1", message, class, *retryable, &ep, + ); + } + other => panic!("{other:?}"), + } + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["attempt_id"], 7); + assert_eq!(lines[0]["id"], "g1"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!hipfire_generate::qwen::qwen_dflash_cache_action(&term).store); + } + + #[test] + fn serde_v2_token_and_tool_calls_hostile_id() { + set_active_attempt_id(9); + let mut sink = Vec::new(); + let id = "a\"b\n"; + hipfire_generate::qwen::render_client_events( + &mut sink, + id, + &[ + ClientEvent::Token("hi".into()), + ClientEvent::Reasoning("r".into()), + ], + 0, + false, + ); + emit_tool_calls_event( + &mut sink, + id, + &[ToolCall { + id: None, + name: "n".into(), + arguments: serde_json::json!({"x": 1}), + rendered_body: None, + }], + ); + let out = String::from_utf8(sink).unwrap(); + for line in out.lines().filter(|l| !l.is_empty()) { + let v: serde_json::Value = serde_json::from_str(line).expect(line); + assert_eq!(v["attempt_id"], 9); + assert_eq!(v["id"], id); + } + let types: Vec<_> = parse_jsonl(&out) + .into_iter() + .map(|v| v["type"].as_str().unwrap().to_string()) + .collect(); + assert!(types.contains(&"token".to_string())); + assert!(types.contains(&"reasoning".to_string())); + assert!(types.contains(&"tool_calls".to_string())); + } + + #[test] + fn cancel_wire_helpers_carry_attempt_id() { + // Production cancel writer carries attempt_id on aborted + done. + set_active_attempt_id(3); + let mut sink = Vec::new(); + emit_qwen_ar_cancelled(&mut sink, "c1", 5); + let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "aborted"); + assert_eq!(lines[0]["attempt_id"], 3); + assert_eq!(lines[0]["reason"], "client_cancelled"); + assert_eq!(lines[1]["type"], "done"); + assert_eq!(lines[1]["finish_reason"], "aborted"); + assert_eq!(lines[1]["attempt_id"], 3); + assert_eq!(lines[1]["completion_tokens"], 5); + } + + #[test] + fn cache_fingerprint_uses_visible_not_raw_markers() { + let fin = summary_stop("visible only"); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "visible only", false); + let action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + assert!(!action.fingerprint_text.contains("")); + assert!(!action.fingerprint_text.contains("")); + assert!(action.fingerprint_text.contains("visible")); + let mut stored = None; + let fp = hipfire_generate::qwen::qwen_dflash_apply_cache_action( + |f, seq| { + stored = Some((f, seq)); + }, + &action, + vec![10, 20, 30], + ); + assert!(fp.is_some()); + let (f, seq) = stored.expect("insert"); + assert_eq!(seq, vec![10, 20, 30]); + assert_eq!( + f, + hipfire_generate::common::asst_turn_fingerprint(&action.fingerprint_text, &action.tool_calls) + ); + } + + #[test] + fn qwen_dflash_contract_version_is_v2() { + assert_eq!(QWEN_DFLASH_SEMANTIC_CONTRACT_VERSION, 2); + assert_eq!(hipfire_generate::common::gen_start_contract_version_for_arch(5), Some(2)); + assert_eq!(hipfire_generate::common::gen_start_contract_version_for_arch(6), Some(2)); + } + + #[test] + fn no_whole_output_parser_in_terminal_path() { + // Terminal path authority is FinishSummary fields only — a finish with + // empty held calls cannot invent tools from visible text markers. + let fin = FinishSummary { + events: vec![ClientEvent::Token( + "{\"name\":\"x\",\"arguments\":{}}".into(), + )], + finish_reason: "stop", + tool_calls: 0, + visible_text: String::new(), + decoded_eot: false, + open_think: false, + }; + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, false, false, "", false); + match term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + wire_tool_calls, + .. + } => { + assert_eq!(finish_reason, "stop"); + assert!(!release_tool_calls); + assert!(wire_tool_calls.is_empty()); + } + other => panic!("expected stop Done without invented calls, got {other:?}"), + } + } + + #[test] + fn production_done_value_builder_matches_epilogue_shape() { + let v = + hipfire_generate::qwen::qwen_dflash_done_value("r", 3, 1.5, 10, 2.0, 5.0, 1.2, 2.0, 0.5, 2, 0, "length", 99); + assert_eq!(v["type"], "done"); + assert_eq!(v["finish_reason"], "length"); + assert_eq!(v["attempt_id"], 99); + assert_eq!(v["dflash"], true); + assert_eq!(v["tokens"], 3); + } + + // --- Task 4 production-seam invariants (pending-seed / cancel / evict / + // capacity / jinja / wire / rollback attestation) --- + + #[test] + fn trigger_token_retained_before_forced_suffix_tx() { + // Forced GPU tx must first commit the current pending seed (the + // force-trigger), then forced[..n-1]. The trigger is never dropped. + let trigger = 77u32; + let forced = [10u32, 11, 12]; + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(trigger, &forced, true); + assert_eq!(tx.commit[0], trigger, "trigger must lead the commit batch"); + assert_eq!(tx.commit, vec![77, 10, 11]); + assert_eq!(tx.position_delta, forced.len()); + assert_eq!(tx.commit.len(), tx.position_delta); + // Trigger is not the new pending seed unless forced was length-1. + assert_ne!(tx.pending_seed, trigger); + } + + #[test] + fn final_forced_token_is_pending_exactly_once() { + // Last forced token becomes the unprocessed pending seed and MUST NOT + // also appear in commit (no double-forward). + let forced = [20u32, 21, 22]; + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(5, &forced, true); + assert_eq!(tx.pending_seed, 22); + assert!( + !tx.commit.contains(&22), + "last forced must stay unwritten: {:?}", + tx.commit + ); + assert_eq!(tx.commit, vec![5, 20, 21]); + // Single-token forced: commit is only the prior seed; forced[0] pending. + let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(99, &[42], true); + assert_eq!(one.commit, vec![99]); + assert_eq!(one.pending_seed, 42); + assert!(!one.commit.contains(&42)); + assert_eq!(one.position_delta, 1); + } + + #[test] + fn terminal_pending_seed_flush_exactly_once() { + let seed = 314u32; + let tx = hipfire_generate::qwen::spec_terminal_pending_seed_tx(seed); + assert_eq!(tx.commit, vec![seed]); + assert_eq!(tx.position_delta, 1); + assert_eq!(tx.commit.len(), 1, "flush commits the seed once"); + // Terminal flush ends with the same logical token as conversation + // (pending_seed field equals the committed token; no second lagging seed). + assert_eq!(tx.pending_seed, seed); + } + + #[test] + fn forced_max_tokens_clip_hard_ceiling() { + // generated already includes the trigger; no GPU for tokens past budget. + let forced = [1u32, 2, 3, 4, 5]; + assert_eq!(hipfire_generate::qwen::spec_forced_tokens_within_budget(8, 10, &forced), &[1, 2]); + assert_eq!( + hipfire_generate::qwen::spec_forced_tokens_within_budget(10, 10, &forced), + &[] as &[u32] + ); + assert_eq!(hipfire_generate::qwen::spec_forced_tokens_within_budget(0, 3, &forced), &[1, 2, 3]); + assert_eq!(hipfire_generate::qwen::spec_forced_tokens_within_budget(9, 10, &forced), &[1]); + // Composition: clip then build tx — only fitting tokens become pending. + let clipped = hipfire_generate::qwen::spec_forced_tokens_within_budget(7, 10, &forced); + assert_eq!(clipped, &[1, 2, 3]); + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(70, clipped, true); + assert_eq!(tx.commit, vec![70, 1, 2]); + assert_eq!(tx.pending_seed, 3); + assert!(!tx.commit.contains(&4) && !tx.commit.contains(&5)); + } + + #[test] + fn cancellation_classification_forced_gpu_advance() { + assert_eq!( + hipfire_generate::qwen::classify_forced_gpu_advance(false), + hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed + ); + assert_eq!( + hipfire_generate::qwen::classify_forced_gpu_advance(true), + hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled + ); + // Cancelled path must use aborted+done wire, never bake the forced token. + // ErrorOnly is reserved for eviction failures (XOR below). + assert_ne!(hipfire_generate::qwen::SpecFailClosedWire::Cancelled, hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly); + set_active_attempt_id(55); + let mut sink = Vec::new(); + match hipfire_generate::qwen::classify_forced_gpu_advance(true) { + hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled => { + emit_qwen_ar_cancelled(&mut sink, "c-force", 4); + } + hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed => panic!("abort must classify Cancelled"), } - other => panic!("expected stop Done without invented calls, got {other:?}"), - } -} - -#[test] -fn production_done_value_builder_matches_epilogue_shape() { - let v = hipfire_generate::qwen::qwen_dflash_done_value( - "r", 3, 1.5, 10, 2.0, 5.0, 1.2, 2.0, 0.5, 2, 0, "length", 99, - ); - assert_eq!(v["type"], "done"); - assert_eq!(v["finish_reason"], "length"); - assert_eq!(v["attempt_id"], 99); - assert_eq!(v["dflash"], true); - assert_eq!(v["tokens"], 3); -} - -// --- Task 4 production-seam invariants (pending-seed / cancel / evict / -// capacity / jinja / wire / rollback attestation) --- - -#[test] -fn trigger_token_retained_before_forced_suffix_tx() { - // Forced GPU tx must first commit the current pending seed (the - // force-trigger), then forced[..n-1]. The trigger is never dropped. - let trigger = 77u32; - let forced = [10u32, 11, 12]; - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(trigger, &forced, true); - assert_eq!(tx.commit[0], trigger, "trigger must lead the commit batch"); - assert_eq!(tx.commit, vec![77, 10, 11]); - assert_eq!(tx.position_delta, forced.len()); - assert_eq!(tx.commit.len(), tx.position_delta); - // Trigger is not the new pending seed unless forced was length-1. - assert_ne!(tx.pending_seed, trigger); -} - -#[test] -fn final_forced_token_is_pending_exactly_once() { - // Last forced token becomes the unprocessed pending seed and MUST NOT - // also appear in commit (no double-forward). - let forced = [20u32, 21, 22]; - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(5, &forced, true); - assert_eq!(tx.pending_seed, 22); - assert!( - !tx.commit.contains(&22), - "last forced must stay unwritten: {:?}", - tx.commit - ); - assert_eq!(tx.commit, vec![5, 20, 21]); - // Single-token forced: commit is only the prior seed; forced[0] pending. - let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(99, &[42], true); - assert_eq!(one.commit, vec![99]); - assert_eq!(one.pending_seed, 42); - assert!(!one.commit.contains(&42)); - assert_eq!(one.position_delta, 1); -} - -#[test] -fn terminal_pending_seed_flush_exactly_once() { - let seed = 314u32; - let tx = hipfire_generate::qwen::spec_terminal_pending_seed_tx(seed); - assert_eq!(tx.commit, vec![seed]); - assert_eq!(tx.position_delta, 1); - assert_eq!(tx.commit.len(), 1, "flush commits the seed once"); - // Terminal flush ends with the same logical token as conversation - // (pending_seed field equals the committed token; no second lagging seed). - assert_eq!(tx.pending_seed, seed); -} - -#[test] -fn forced_max_tokens_clip_hard_ceiling() { - // generated already includes the trigger; no GPU for tokens past budget. - let forced = [1u32, 2, 3, 4, 5]; - assert_eq!( - hipfire_generate::qwen::spec_forced_tokens_within_budget(8, 10, &forced), - &[1, 2] - ); - assert_eq!( - hipfire_generate::qwen::spec_forced_tokens_within_budget(10, 10, &forced), - &[] as &[u32] - ); - assert_eq!( - hipfire_generate::qwen::spec_forced_tokens_within_budget(0, 3, &forced), - &[1, 2, 3] - ); - assert_eq!( - hipfire_generate::qwen::spec_forced_tokens_within_budget(9, 10, &forced), - &[1] - ); - // Composition: clip then build tx — only fitting tokens become pending. - let clipped = hipfire_generate::qwen::spec_forced_tokens_within_budget(7, 10, &forced); - assert_eq!(clipped, &[1, 2, 3]); - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(70, clipped, true); - assert_eq!(tx.commit, vec![70, 1, 2]); - assert_eq!(tx.pending_seed, 3); - assert!(!tx.commit.contains(&4) && !tx.commit.contains(&5)); -} - -#[test] -fn cancellation_classification_forced_gpu_advance() { - assert_eq!( - hipfire_generate::qwen::classify_forced_gpu_advance(false), - hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed - ); - assert_eq!( - hipfire_generate::qwen::classify_forced_gpu_advance(true), - hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled - ); - // Cancelled path must use aborted+done wire, never bake the forced token. - // ErrorOnly is reserved for eviction failures (XOR below). - assert_ne!( - hipfire_generate::qwen::SpecFailClosedWire::Cancelled, - hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly - ); - set_active_attempt_id(55); - let mut sink = Vec::new(); - match hipfire_generate::qwen::classify_forced_gpu_advance(true) { - hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled => { - emit_qwen_ar_cancelled(&mut sink, "c-force", 4); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "aborted"); + assert_eq!(lines[0]["reason"], "client_cancelled"); + assert_eq!(lines[0]["attempt_id"], 55); + assert_eq!(lines[1]["type"], "done"); + assert_eq!(lines[1]["finish_reason"], "aborted"); + assert!(!out.contains(r#""type":"error""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + set_active_attempt_id(0); + } + + #[test] + fn eviction_error_terminal_exclusivity() { + // maybe_evict / on_evict Err → ErrorOnly: one fail-closed error, no done. + assert_eq!(hipfire_generate::qwen::classify_evict_failure_wire(), hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly); + set_active_attempt_id(66); + let mut sink = Vec::new(); + let ep = attest_epilogue(true); + match hipfire_generate::qwen::classify_evict_failure_wire() { + hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly => { + hipfire_generate::common::emit_fail_closed_error( + &mut sink, + Some("ev1"), + "on_evict: synthetic retain failure", + "validation", + false, + &ep, + ); + } + hipfire_generate::qwen::SpecFailClosedWire::Cancelled => panic!("evict must not classify Cancelled"), } - hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed => { - panic!("abort must classify Cancelled") + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "error XOR done: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], true); + assert_eq!(lines[0]["attempt_id"], 66); + assert_eq!(lines[0]["id"], "ev1"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + // Fail-closed early exit skips wrapper epilogue (same as step failure). + assert!(!qwen_dflash_epilogue_after_spec_run(false)); + set_active_attempt_id(0); + } + + #[test] + fn strict_prefix_replay_capacity_rejection() { + let prompt = vec![1u32, 2, 3]; + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 9, &[10, 11, 12]); + // plan.replay = [1,2,3,9,10,11], position=6, seed=12 + assert_eq!(plan.replay.len(), plan.position); + assert_eq!(plan.seed_token, 12); + assert!(!plan.replay.contains(&12)); + + // Fits both caps (position must be strictly < caps — pending seed slot). + assert!(hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 64, 64, 0, false).is_ok()); + // Boundary: position == cap leaves no legal write slot for pending seed. + let err_eq = hipfire_generate::qwen::spec_prefix_realign_admit(&plan, plan.position, 64, 0, false).unwrap_err(); + assert!( + err_eq.contains("physical_cap"), + "expected position==physical_cap reject, got {err_eq}" + ); + + // Physical capacity rejection — fail closed before reset/prefill. + let err_phys = hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 5, 64, 0, false).unwrap_err(); + assert!( + err_phys.contains("physical_cap"), + "expected physical_cap reject, got {err_phys}" + ); + + // Speculator ctx capacity rejection. + let err_ctx = hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 64, 4, 0, false).unwrap_err(); + assert!( + err_ctx.contains("ctx_capacity"), + "expected ctx_capacity reject, got {err_ctx}" + ); + + // Broken invariant (replay/position mismatch) rejects even if caps large. + let broken = hipfire_generate::qwen::SpecPrefixRealignPlan { + replay: vec![1, 2], + position: 5, + seed_token: 9, + }; + let err_inv = hipfire_generate::qwen::spec_prefix_realign_admit(&broken, 100, 100, 0, false).unwrap_err(); + assert!( + err_inv.contains("invariant") || err_inv.contains("pending"), + "expected invariant reject, got {err_inv}" + ); + + // Compacted/eviction path still fails closed on oversize full-history replay. + let err_ev = hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 5, 64, 3, true).unwrap_err(); + assert!( + err_ev.contains("physical_cap") || err_ev.contains("compact"), + "expected compacted oversize reject, got {err_ev}" + ); + + // Capacity reject wires as exclusive error terminal (no done). + set_active_attempt_id(71); + let mut sink = Vec::new(); + let ep = attest_epilogue(true); + hipfire_generate::common::emit_fail_closed_error( + &mut sink, + Some("realign"), + &err_phys, + "validation", + false, + &ep, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["attempt_id"], 71); + assert!(!out.contains(r#""type":"done""#)); + set_active_attempt_id(0); + } + + #[test] + fn configured_jinja_render_fail_closed_policy() { + // Production hipfire_generate::qwen::generate_dflash configured-template Err path: + // hipfire_generate::dense::emit_active_attempt_error(class=validation, retryable=false, + // rolled_back=false, message="DFlash jinja render: …") then handled=true. + // Plain is not a silent fallback when a template is configured. + set_active_attempt_id(88); + let mut sink = Vec::new(); + let render_err = "undefined variable `messages`"; + hipfire_generate::dense::emit_active_attempt_error( + &mut sink, + Some("j1"), + &format!("DFlash jinja render: {render_err}"), + "validation", + false, + false, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 88); + assert_eq!(lines[0]["id"], "j1"); + let msg = lines[0]["message"].as_str().unwrap(); + assert!(msg.starts_with("DFlash jinja render:"), "{msg}"); + assert!(msg.contains(render_err), "{msg}"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"gen_start""#)); + // handled=true contract: early exit skips AR/done epilogue. + assert!(!qwen_dflash_epilogue_after_spec_run(false)); + set_active_attempt_id(0); + } + + #[test] + fn correlated_escaped_dflash_info_frame() { + // DFlash ctx-capacity fallback info uses serde + active attempt_id and + // must survive adversarial id/message bytes without breaking JSONL. + set_active_attempt_id(13); + let mut sink = Vec::new(); + let id = "id\"x\n\t\\"; + let message = "prompt=3 + max_tokens=9 exceeds DFlash draft ctx capacity 8 — falling back to AR (\"identical\" output)"; + emit_qwen_ar_info(&mut sink, id, message); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "info"); + assert_eq!(lines[0]["id"], id); + assert_eq!(lines[0]["message"], message); + assert_eq!(lines[0]["attempt_id"], 13); + // Round-trip proves escaping: re-serialize must still parse as one object. + let raw = out.lines().next().unwrap(); + let again: serde_json::Value = serde_json::from_str(raw).expect("serde-escaped info"); + assert_eq!(again["id"].as_str().unwrap(), id); + set_active_attempt_id(0); + } + + #[test] + fn rollback_attestation_false_on_sync_failure_surface() { + // No injectable mock GPU; production surface is hipfire_generate::common::RollbackEpilogue from + // hipfire_generate::common::fail_closed_device_sync on Err → rolled_back=false + context. + // hipfire_generate::common::emit_fail_closed_error must append context and claim rolled_back=false. + set_active_attempt_id(17); + let mut sink = Vec::new(); + let ep = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); + assert!(!ep.rolled_back); + assert!(ep + .context + .as_ref() + .unwrap() + .contains("device_synchronize failed")); + hipfire_generate::common::emit_fail_closed_error( + &mut sink, + Some("rb1"), + "forced-token advance: boom", + "validation", + false, + &ep, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 17); + let msg = lines[0]["message"].as_str().unwrap(); + assert!(msg.contains("forced-token advance: boom"), "{msg}"); + assert!(msg.contains("device_synchronize failed"), "{msg}"); + assert!(!out.contains(r#""type":"done""#)); + + // Attested success path still reports rolled_back=true without context suffix. + let mut sink_ok = Vec::new(); + let ep_ok = attest_epilogue(true); + hipfire_generate::common::emit_fail_closed_error( + &mut sink_ok, + Some("rb2"), + "spec_step: boom", + "validation", + false, + &ep_ok, + ); + let ok = parse_jsonl(&String::from_utf8(sink_ok).unwrap()); + assert_eq!(ok[0]["rolled_back"], true); + assert_eq!(ok[0]["message"], "spec_step: boom"); + set_active_attempt_id(0); + } + + #[test] + fn pending_seed_chain_trigger_clip_force_then_terminal_flush() { + // End-to-end pure chain defending the single pending-seed invariant: + // mid-window force trigger retained → budget clip → forced tx leaves + // last forced pending → safe terminal flushes that seed once. + let prompt = vec![1u32, 2]; + let first = 50u32; + // Consume force-trigger only from a wider speculative window. + let step = SpecStep::new([60u32, 61, 62], 62, 3, 2); + let host = hipfire_generate::qwen::spec_host_advance_after_step( + prompt.len(), + 0, + vec![first], + &step.emit, + step.next_seed, + 1, + ); + assert_eq!(host.seed_token, 60); // trigger retained as pending seed + assert_eq!(host.generated, 1); + + let forced_raw = [70u32, 71, 72, 73]; + // generated=1 (trigger counted); max_tokens=3 → room for 2 forced. + let forced = hipfire_generate::qwen::spec_forced_tokens_within_budget(host.generated, 3, &forced_raw); + assert_eq!(forced, &[70, 71]); + let ftx = hipfire_generate::qwen::spec_forced_pending_seed_tx(host.seed_token, forced, true); + assert_eq!(ftx.commit, vec![60, 70]); // trigger + forced[..n-1] + assert_eq!(ftx.pending_seed, 71); // last forced pending once + assert!(!ftx.commit.contains(&71)); + assert_eq!(ftx.position_delta, 2); + + let position = host.position + ftx.position_delta; + let generated = host.generated + forced.len(); + // host.position already counts the force-trigger write slot after prefill first. + assert_eq!(position, prompt.len() + 1 + ftx.position_delta); + assert_eq!(generated, 3); + + // Safe terminal: flush final pending seed exactly once. + let term = hipfire_generate::qwen::spec_terminal_pending_seed_tx(ftx.pending_seed); + assert_eq!(term.commit, vec![71]); + assert_eq!(term.position_delta, 1); + let final_pos = position + term.position_delta; + // Full history: prompt + first_token + trigger + forced (generated). + assert_eq!(final_pos, prompt.len() + 1 + generated); + + // Realign plan after force path still keeps last raw as unwritten seed. + let mut raw = vec![60u32]; + raw.extend_from_slice(forced); + let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first, &raw); + assert_eq!(plan.seed_token, 71); + assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); + assert!(hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 1024, 1024, 0, false).is_ok()); + } + + // ── Task 4 Important vetoes (production seam pins) ───────────────────── + + /// max_tokens==0 rejects at hipfire_generate::qwen::generate_spec entry via the same writer the + /// production gate uses — before prefill/GPU/state/client mutation. + /// Wire: one correlated validation error, rolled_back=false, no done/aborted. + #[test] + fn zero_budget_max_tokens_preflight_error_only_no_done() { + set_active_attempt_id(101); + let mut sink = Vec::new(); + // Mirrors hipfire_generate::qwen::generate_spec entry gate (max_tokens == 0 → emit + return None). + hipfire_generate::dense::emit_active_attempt_error( + &mut sink, + Some("zb0"), + "max_tokens must be > 0", + "validation", + false, + false, + ); + let _ = std::io::Write::flush(&mut sink); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["id"], "zb0"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 101); + assert_eq!(lines[0]["message"], "max_tokens must be > 0"); + // No first token, no safe terminal flush, no aborted pair. + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"token""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + // Wrapper contract: hipfire_generate::qwen::generate_spec returned None → no epilogue. + assert!(!qwen_dflash_epilogue_after_spec_run(false)); + set_active_attempt_id(0); + } + + /// Cancel after rollback attestation: attested → aborted+done; unattested → + /// exactly one correlated nonretryable error with context and no done. + #[test] + fn cancel_after_rollback_attested_vs_unattested_wire() { + // Attested rollback keeps fold-compatible aborted + done pair. + set_active_attempt_id(202); + let mut sink_ok = Vec::new(); + let ep_ok = attest_epilogue(true); + hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink_ok, "c-ok", 7, &ep_ok); + let out_ok = String::from_utf8(sink_ok).unwrap(); + let lines_ok = parse_jsonl(&out_ok); + assert_eq!( + lines_ok.len(), + 2, + "attested cancel: aborted+done {lines_ok:?}" + ); + assert_eq!(lines_ok[0]["type"], "aborted"); + assert_eq!(lines_ok[0]["reason"], "client_cancelled"); + assert_eq!(lines_ok[0]["attempt_id"], 202); + assert_eq!(lines_ok[0]["id"], "c-ok"); + assert_eq!(lines_ok[1]["type"], "done"); + assert_eq!(lines_ok[1]["finish_reason"], "aborted"); + assert_eq!(lines_ok[1]["completion_tokens"], 7); + assert_eq!(lines_ok[1]["attempt_id"], 202); + assert!(!out_ok.contains(r#""type":"error""#)); + assert!(!out_ok.contains(r#""type":"tool_calls""#)); + + // Unattested rollback: one fail-closed error, no aborted/done. + set_active_attempt_id(203); + let mut sink_bad = Vec::new(); + let ep_bad = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); + assert!(!ep_bad.rolled_back); + hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink_bad, "c-bad", 3, &ep_bad); + let out_bad = String::from_utf8(sink_bad).unwrap(); + let lines_bad = parse_jsonl(&out_bad); + assert_eq!( + lines_bad.len(), + 1, + "unattested cancel: error only {lines_bad:?}" + ); + assert_eq!(lines_bad[0]["type"], "error"); + assert_eq!(lines_bad[0]["class"], "validation"); + assert_eq!(lines_bad[0]["retryable"], false); + assert_eq!(lines_bad[0]["rolled_back"], false); + assert_eq!(lines_bad[0]["attempt_id"], 203); + assert_eq!(lines_bad[0]["id"], "c-bad"); + let msg = lines_bad[0]["message"].as_str().unwrap(); + assert!( + msg.contains("client cancelled; fail-closed rollback could not be attested"), + "{msg}" + ); + assert!(msg.contains("device_synchronize failed"), "{msg}"); + assert!(!out_bad.contains(r#""type":"done""#)); + assert!(!out_bad.contains(r#""type":"aborted""#)); + assert!(!out_bad.contains(r#""type":"tool_calls""#)); + set_active_attempt_id(0); + } + + /// Failure-injection: each omitted reset class (incl. single-GPU s_ef_residual + /// and EP bind) keeps rolled_back=false; aggregate failure still models sync + /// as attempted; Qwen AR prefill/decode abort terminals are exclusive. + #[test] + fn rollback_attestation_omitted_reset_classes_and_ar_abort_xor() { + // Every required surface Ok + sync Ok → attested. + let all_ok = attest_rollback_steps( + &[ + ("s_matrices", Ok(())), + ("s_scales", Ok(())), + ("conv_states", Ok(())), + ("s_ef_residual", Ok(())), + ("host_cursors", Ok(())), + ("kv_compact", Ok(())), + ("checkpoints", Ok(())), + ("drafter", Ok(())), + ("adaptive", Ok(())), + ("graph_replay", Ok(())), + ("ep_bind_thread", Ok(())), + ], + Ok(()), + ); + assert!(all_ok.rolled_back); + assert!(all_ok.context.is_none()); + + // Single-GPU s_ef_residual omission/failure alone unattests. + let ef = attest_rollback_steps( + &[ + ("s_matrices", Ok(())), + ("s_scales", Ok(())), + ("conv_states", Ok(())), + ("s_ef_residual", Err("memset failed".into())), + ("ep_bind_thread", Ok(())), + ], + Ok(()), + ); + assert!(!ef.rolled_back); + let ctx = ef.context.as_deref().unwrap_or(""); + assert!(ctx.contains("s_ef_residual"), "{ctx}"); + assert!( + !ctx.contains("device_synchronize"), + "sync Ok must not appear: {ctx}" + ); + + // EP bind_thread failure alone unattests even when sync Ok. + let bind = attest_rollback_steps( + &[ + ("s_ef_residual", Ok(())), + ("ep_bind_thread", Err("hipErrorInvalidDevice".into())), + ], + Ok(()), + ); + assert!(!bind.rolled_back); + assert!( + bind.context + .as_deref() + .unwrap_or("") + .contains("ep_bind_thread"), + "{:?}", + bind.context + ); + + // Aggregate reset failure + sync still attempted (both in context). + let agg = attest_rollback_steps( + &[ + ("s_matrices", Err("m1".into())), + ("s_ef_residual", Err("ef".into())), + ("ep_bind_thread", Err("bind".into())), + ], + Err("hipErrorUnknown".into()), + ); + assert!(!agg.rolled_back); + let ctx = agg.context.as_deref().unwrap_or(""); + assert!(ctx.contains("s_matrices"), "{ctx}"); + assert!(ctx.contains("s_ef_residual"), "{ctx}"); + assert!(ctx.contains("ep_bind_thread"), "{ctx}"); + assert!(ctx.contains("device_synchronize failed"), "{ctx}"); + + // hipfire_generate::common::fail_closed_epilogue_after_sync: prior Err + sync Ok → unattested, sync ran. + let merged = hipfire_generate::common::fail_closed_epilogue_after_sync( + Err("hipfire_generate::common::reset_qwen35_recurrent: s_ef_residual memset: boom".into()), + hipfire_generate::common::RollbackEpilogue { + rolled_back: true, + context: None, + }, + ); + assert!(!merged.rolled_back); + assert!( + merged + .context + .as_deref() + .unwrap_or("") + .contains("s_ef_residual"), + "{:?}", + merged.context + ); + + // prior Err + sync Err → both preserved. + let both = hipfire_generate::common::fail_closed_epilogue_after_sync( + Err("ep rank0 bind_thread: bad".into()), + hipfire_generate::common::RollbackEpilogue { + rolled_back: false, + context: Some("device_synchronize failed: hipErrorUnknown".into()), + }, + ); + assert!(!both.rolled_back); + let ctx = both.context.as_deref().unwrap_or(""); + assert!(ctx.contains("bind_thread"), "{ctx}"); + assert!(ctx.contains("device_synchronize failed"), "{ctx}"); + + // Qwen AR prefill abort terminal exclusivity (attested vs unattested). + set_active_attempt_id(501); + let mut sink = Vec::new(); + hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink, "ar-prefill", 0, &attest_epilogue(true)); + let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "aborted"); + assert_eq!(lines[1]["type"], "done"); + assert_eq!(lines[1]["finish_reason"], "aborted"); + assert_eq!(lines[1]["completion_tokens"], 0); + assert!(lines.iter().all(|e| e["attempt_id"] == 501)); + + set_active_attempt_id(502); + let mut sink = Vec::new(); + hipfire_generate::common::emit_spec_cancel_after_rollback( + &mut sink, + "ar-prefill-bad", + 0, + &attest_epilogue_with_context("s_ef_residual memset: boom"), + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "prefill unattested: error only"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["rolled_back"], false); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + + // Qwen AR mid-decode abort terminal exclusivity. + set_active_attempt_id(503); + let mut sink = Vec::new(); + hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink, "ar-decode", 5, &attest_epilogue(true)); + let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["type"], "aborted"); + assert_eq!(lines[1]["finish_reason"], "aborted"); + assert_eq!(lines[1]["completion_tokens"], 5); + + set_active_attempt_id(504); + let mut sink = Vec::new(); + hipfire_generate::common::emit_spec_cancel_after_rollback( + &mut sink, + "ar-decode-bad", + 5, + &attest_epilogue_with_context( + "ep rank0 bind_thread: bad; device_synchronize failed: x", + ), + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "decode unattested: error only"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 504); + let msg = lines[0]["message"].as_str().unwrap(); + assert!(msg.contains("bind_thread"), "{msg}"); + assert!(msg.contains("device_synchronize failed"), "{msg}"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + set_active_attempt_id(0); + } + + /// Eviction-enabled missing optional kv_cache_mut is ErrorOnly (not panic): + /// hipfire_generate::qwen::classify_evict_failure_wire → hipfire_generate::common::emit_fail_closed_error with the production + /// post-prefill / per-cycle messages; no done/aborted/calls/cache. + #[test] + fn missing_optional_kv_cache_mut_is_error_only_not_panic() { + assert_eq!(hipfire_generate::qwen::classify_evict_failure_wire(), hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly); + assert_ne!( + hipfire_generate::qwen::SpecFailClosedWire::Cancelled, + hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly, + "missing KV hook must never classify as Cancelled" + ); + + for (attempt, id, message) in [ + (301u64, "kv-pp", "kv_cache_mut missing (post-prefill)"), + (302u64, "kv-pc", "kv_cache_mut missing (per-cycle)"), + ] { + set_active_attempt_id(attempt); + let mut sink = Vec::new(); + // Production seam: classify first, then fail-closed writer (same as + // hipfire_generate::qwen::generate_spec match slot.kv_cache_mut() { None => ... }). + let _ = hipfire_generate::qwen::classify_evict_failure_wire(); + let ep = attest_epilogue(true); + match hipfire_generate::qwen::classify_evict_failure_wire() { + hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly => { + hipfire_generate::common::emit_fail_closed_error(&mut sink, Some(id), message, "validation", false, &ep); + } + hipfire_generate::qwen::SpecFailClosedWire::Cancelled => { + panic!("kv_cache_mut missing must not classify Cancelled") + } + } + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "error XOR done for {message}: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], true); + assert_eq!(lines[0]["attempt_id"], attempt); + assert_eq!(lines[0]["id"], id); + assert_eq!(lines[0]["message"], message); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + // hipfire_generate::qwen::generate_spec returns None → wrapper skips cache store / epilogue. + assert!(!qwen_dflash_epilogue_after_spec_run(false)); } + + // Unattested rollback on the same missing-hook path: rolled_back=false + // + context appended; still error-only (no panic surface). + set_active_attempt_id(303); + let mut sink = Vec::new(); + let ep = attest_epilogue_with_context("device_synchronize failed: test"); + hipfire_generate::common::emit_fail_closed_error( + &mut sink, + Some("kv-ua"), + "kv_cache_mut missing (post-prefill)", + "validation", + false, + &ep, + ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["rolled_back"], false); + let msg = lines[0]["message"].as_str().unwrap(); + assert!(msg.contains("kv_cache_mut missing (post-prefill)"), "{msg}"); + assert!(msg.contains("device_synchronize failed"), "{msg}"); + assert!(!out.contains(r#""type":"done""#)); + set_active_attempt_id(0); + } + + // ── Remaining Important Task 4 vetoes (wrapper / legacy / rewind) ── + + /// hipfire_generate::qwen::generate_dflash max_tokens==0: hipfire_generate::dense::emit_active_attempt_error then return true + /// (handled) before Jinja/render/set_sampling/gen_start. Same wire as the + /// inner hipfire_generate::qwen::generate_spec defense; wrapper must not fall through to AR. + #[test] + fn generate_dflash_zero_budget_preflight_handled_error_only() { + set_active_attempt_id(401); + let mut sink = Vec::new(); + // Mirrors hipfire_generate::qwen::generate_dflash entry (max_tokens == 0 → emit + return true). + hipfire_generate::dense::emit_active_attempt_error( + &mut sink, + Some("df-zb0"), + "max_tokens must be > 0", + "validation", + false, + false, + ); + let _ = std::io::Write::flush(&mut sink); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["id"], "df-zb0"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 401); + assert_eq!(lines[0]["message"], "max_tokens must be > 0"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"token""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + // Handled=true → caller must not fall through to AR / second envelope. + let wrapper_handled = true; + assert!(wrapper_handled); + set_active_attempt_id(0); + } + + /// hipfire_generate::dense::generate_deepseek4_spec max_tokens==0: same emit policy, plain return + /// (unit fn) before DSML render / decode-cache teardown / set_sampling. + #[test] + fn generate_deepseek4_spec_zero_budget_preflight_error_only() { + set_active_attempt_id(402); + let mut sink = Vec::new(); + // Mirrors hipfire_generate::dense::generate_deepseek4_spec entry (max_tokens == 0 → emit + return). + hipfire_generate::dense::emit_active_attempt_error( + &mut sink, + Some("ds4-zb0"), + "max_tokens must be > 0", + "validation", + false, + false, + ); + let _ = std::io::Write::flush(&mut sink); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["id"], "ds4-zb0"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["rolled_back"], false); + assert_eq!(lines[0]["attempt_id"], 402); + assert_eq!(lines[0]["message"], "max_tokens must be > 0"); + assert!(!out.contains(r#""type":"done""#)); + assert!(!out.contains(r#""type":"aborted""#)); + assert!(!out.contains(r#""type":"token""#)); + assert!(!out.contains(r#""type":"tool_calls""#)); + // Unit wrapper returns (no AR fallthrough second write). + set_active_attempt_id(0); } - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0]["type"], "aborted"); - assert_eq!(lines[0]["reason"], "client_cancelled"); - assert_eq!(lines[0]["attempt_id"], 55); - assert_eq!(lines[1]["type"], "done"); - assert_eq!(lines[1]["finish_reason"], "aborted"); - assert!(!out.contains(r#""type":"error""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - set_active_attempt_id(0); -} - -#[test] -fn eviction_error_terminal_exclusivity() { - // maybe_evict / on_evict Err → ErrorOnly: one fail-closed error, no done. - assert_eq!( - hipfire_generate::qwen::classify_evict_failure_wire(), - hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly - ); - set_active_attempt_id(66); - let mut sink = Vec::new(); - let ep = attest_epilogue(true); - match hipfire_generate::qwen::classify_evict_failure_wire() { - hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly => { + + /// Legacy non-qwen hipfire_generate::qwen::generate_dflash else-branch: fail_closed_rollback.is_some() + /// || grammar_violated → hipfire_generate::common::emit_fail_closed_error only; no extract/release/ + /// cache store / done. Message classified by grammar / open_think / + /// malformed_protocol / generic. + #[test] + fn legacy_non_qwen_fail_closed_epilogue_error_only_no_extract() { + // Production message selection (qwen_semantic_v2 == false branch). + fn legacy_fail_closed_message( + grammar_violated: bool, + open_think: bool, + finish_reason: &str, + ) -> &'static str { + if grammar_violated { + "grammar violation during speculative decode" + } else if open_think || finish_reason == "open_think" { + "open think span at end of generation (validation)" + } else if finish_reason == "malformed_protocol" { + "malformed tool protocol" + } else { + "fail-closed speculative decode" + } + } + + let cases = [ + ( + true, + false, + "stop", + "grammar violation during speculative decode", + ), + ( + false, + true, + "stop", + "open think span at end of generation (validation)", + ), + ( + false, + false, + "open_think", + "open think span at end of generation (validation)", + ), + ( + false, + false, + "malformed_protocol", + "malformed tool protocol", + ), + (false, false, "length", "fail-closed speculative decode"), + ]; + + for (i, (grammar, open_think, reason, expected_msg)) in cases.iter().enumerate() { + assert_eq!( + legacy_fail_closed_message(*grammar, *open_think, reason), + *expected_msg, + "case {i} message select" + ); + // Gate: fail_closed_rollback.is_some() || grammar_violated. + let fail_closed_present = true; + let take_error_only = fail_closed_present || *grammar; + assert!(take_error_only, "case {i} must take error-only path"); + + set_active_attempt_id(500 + i as u64); + let mut sink = Vec::new(); + let ep = attest_epilogue(true); hipfire_generate::common::emit_fail_closed_error( &mut sink, - Some("ev1"), - "on_evict: synthetic retain failure", + Some("leg-fc"), + expected_msg, "validation", false, &ep, ); + let out = String::from_utf8(sink).unwrap(); + let lines = parse_jsonl(&out); + assert_eq!(lines.len(), 1, "case {i}: error XOR done {lines:?}"); + assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["class"], "validation"); + assert_eq!(lines[0]["retryable"], false); + assert_eq!(lines[0]["id"], "leg-fc"); + assert_eq!(lines[0]["message"], *expected_msg); + // No held tool_calls release, no cache store, no done/aborted. + assert!(!out.contains(r#""type":"done""#), "case {i}"); + assert!(!out.contains(r#""type":"aborted""#), "case {i}"); + assert!(!out.contains(r#""type":"tool_calls""#), "case {i}"); + // Early return true from hipfire_generate::qwen::generate_dflash — no whole-output extract path. + let early_return_handled = true; + assert!(early_return_handled); } - hipfire_generate::qwen::SpecFailClosedWire::Cancelled => { - panic!("evict must not classify Cancelled") - } + set_active_attempt_id(0); } - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "error XOR done: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], true); - assert_eq!(lines[0]["attempt_id"], 66); - assert_eq!(lines[0]["id"], "ev1"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - // Fail-closed early exit skips wrapper epilogue (same as step failure). - assert!(!qwen_dflash_epilogue_after_spec_run(false)); - set_active_attempt_id(0); -} - -#[test] -fn strict_prefix_replay_capacity_rejection() { - let prompt = vec![1u32, 2, 3]; - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, 9, &[10, 11, 12]); - // plan.replay = [1,2,3,9,10,11], position=6, seed=12 - assert_eq!(plan.replay.len(), plan.position); - assert_eq!(plan.seed_token, 12); - assert!(!plan.replay.contains(&12)); - - // Fits both caps (position must be strictly < caps — pending seed slot). - assert!(hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 64, 64, 0, false).is_ok()); - // Boundary: position == cap leaves no legal write slot for pending seed. - let err_eq = - hipfire_generate::qwen::spec_prefix_realign_admit(&plan, plan.position, 64, 0, false) - .unwrap_err(); - assert!( - err_eq.contains("physical_cap"), - "expected position==physical_cap reject, got {err_eq}" - ); - - // Physical capacity rejection — fail closed before reset/prefill. - let err_phys = - hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 5, 64, 0, false).unwrap_err(); - assert!( - err_phys.contains("physical_cap"), - "expected physical_cap reject, got {err_phys}" - ); - - // Speculator ctx capacity rejection. - let err_ctx = - hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 64, 4, 0, false).unwrap_err(); - assert!( - err_ctx.contains("ctx_capacity"), - "expected ctx_capacity reject, got {err_ctx}" - ); - - // Broken invariant (replay/position mismatch) rejects even if caps large. - let broken = hipfire_generate::qwen::SpecPrefixRealignPlan { - replay: vec![1, 2], - position: 5, - seed_token: 9, - }; - let err_inv = - hipfire_generate::qwen::spec_prefix_realign_admit(&broken, 100, 100, 0, false).unwrap_err(); - assert!( - err_inv.contains("invariant") || err_inv.contains("pending"), - "expected invariant reject, got {err_inv}" - ); - - // Compacted/eviction path still fails closed on oversize full-history replay. - let err_ev = - hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 5, 64, 3, true).unwrap_err(); - assert!( - err_ev.contains("physical_cap") || err_ev.contains("compact"), - "expected compacted oversize reject, got {err_ev}" - ); - - // Capacity reject wires as exclusive error terminal (no done). - set_active_attempt_id(71); - let mut sink = Vec::new(); - let ep = attest_epilogue(true); - hipfire_generate::common::emit_fail_closed_error( - &mut sink, - Some("realign"), - &err_phys, - "validation", - false, - &ep, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["attempt_id"], 71); - assert!(!out.contains(r#""type":"done""#)); - set_active_attempt_id(0); -} - -#[test] -fn configured_jinja_render_fail_closed_policy() { - // Production hipfire_generate::qwen::generate_dflash configured-template Err path: - // hipfire_generate::dense::emit_active_attempt_error(class=validation, retryable=false, - // rolled_back=false, message="DFlash jinja render: …") then handled=true. - // Plain is not a silent fallback when a template is configured. - set_active_attempt_id(88); - let mut sink = Vec::new(); - let render_err = "undefined variable `messages`"; - hipfire_generate::dense::emit_active_attempt_error( - &mut sink, - Some("j1"), - &format!("DFlash jinja render: {render_err}"), - "validation", - false, - false, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 88); - assert_eq!(lines[0]["id"], "j1"); - let msg = lines[0]["message"].as_str().unwrap(); - assert!(msg.starts_with("DFlash jinja render:"), "{msg}"); - assert!(msg.contains(render_err), "{msg}"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"gen_start""#)); - // handled=true contract: early exit skips AR/done epilogue. - assert!(!qwen_dflash_epilogue_after_spec_run(false)); - set_active_attempt_id(0); -} - -#[test] -fn correlated_escaped_dflash_info_frame() { - // DFlash ctx-capacity fallback info uses serde + active attempt_id and - // must survive adversarial id/message bytes without breaking JSONL. - set_active_attempt_id(13); - let mut sink = Vec::new(); - let id = "id\"x\n\t\\"; - let message = "prompt=3 + max_tokens=9 exceeds DFlash draft ctx capacity 8 — falling back to AR (\"identical\" output)"; - emit_qwen_ar_info(&mut sink, id, message); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "info"); - assert_eq!(lines[0]["id"], id); - assert_eq!(lines[0]["message"], message); - assert_eq!(lines[0]["attempt_id"], 13); - // Round-trip proves escaping: re-serialize must still parse as one object. - let raw = out.lines().next().unwrap(); - let again: serde_json::Value = serde_json::from_str(raw).expect("serde-escaped info"); - assert_eq!(again["id"].as_str().unwrap(), id); - set_active_attempt_id(0); -} - -#[test] -fn rollback_attestation_false_on_sync_failure_surface() { - // No injectable mock GPU; production surface is hipfire_generate::common::RollbackEpilogue from - // hipfire_generate::common::fail_closed_device_sync on Err → rolled_back=false + context. - // hipfire_generate::common::emit_fail_closed_error must append context and claim rolled_back=false. - set_active_attempt_id(17); - let mut sink = Vec::new(); - let ep = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); - assert!(!ep.rolled_back); - assert!(ep - .context - .as_ref() - .unwrap() - .contains("device_synchronize failed")); - hipfire_generate::common::emit_fail_closed_error( - &mut sink, - Some("rb1"), - "forced-token advance: boom", - "validation", - false, - &ep, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 17); - let msg = lines[0]["message"].as_str().unwrap(); - assert!(msg.contains("forced-token advance: boom"), "{msg}"); - assert!(msg.contains("device_synchronize failed"), "{msg}"); - assert!(!out.contains(r#""type":"done""#)); - - // Attested success path still reports rolled_back=true without context suffix. - let mut sink_ok = Vec::new(); - let ep_ok = attest_epilogue(true); - hipfire_generate::common::emit_fail_closed_error( - &mut sink_ok, - Some("rb2"), - "spec_step: boom", - "validation", - false, - &ep_ok, - ); - let ok = parse_jsonl(&String::from_utf8(sink_ok).unwrap()); - assert_eq!(ok[0]["rolled_back"], true); - assert_eq!(ok[0]["message"], "spec_step: boom"); - set_active_attempt_id(0); -} - -#[test] -fn pending_seed_chain_trigger_clip_force_then_terminal_flush() { - // End-to-end pure chain defending the single pending-seed invariant: - // mid-window force trigger retained → budget clip → forced tx leaves - // last forced pending → safe terminal flushes that seed once. - let prompt = vec![1u32, 2]; - let first = 50u32; - // Consume force-trigger only from a wider speculative window. - let step = SpecStep::new([60u32, 61, 62], 62, 3, 2); - let host = hipfire_generate::qwen::spec_host_advance_after_step( - prompt.len(), - 0, - vec![first], - &step.emit, - step.next_seed, - 1, - ); - assert_eq!(host.seed_token, 60); // trigger retained as pending seed - assert_eq!(host.generated, 1); - - let forced_raw = [70u32, 71, 72, 73]; - // generated=1 (trigger counted); max_tokens=3 → room for 2 forced. - let forced = - hipfire_generate::qwen::spec_forced_tokens_within_budget(host.generated, 3, &forced_raw); - assert_eq!(forced, &[70, 71]); - let ftx = hipfire_generate::qwen::spec_forced_pending_seed_tx(host.seed_token, forced, true); - assert_eq!(ftx.commit, vec![60, 70]); // trigger + forced[..n-1] - assert_eq!(ftx.pending_seed, 71); // last forced pending once - assert!(!ftx.commit.contains(&71)); - assert_eq!(ftx.position_delta, 2); - - let position = host.position + ftx.position_delta; - let generated = host.generated + forced.len(); - // host.position already counts the force-trigger write slot after prefill first. - assert_eq!(position, prompt.len() + 1 + ftx.position_delta); - assert_eq!(generated, 3); - - // Safe terminal: flush final pending seed exactly once. - let term = hipfire_generate::qwen::spec_terminal_pending_seed_tx(ftx.pending_seed); - assert_eq!(term.commit, vec![71]); - assert_eq!(term.position_delta, 1); - let final_pos = position + term.position_delta; - // Full history: prompt + first_token + trigger + forced (generated). - assert_eq!(final_pos, prompt.len() + 1 + generated); - - // Realign plan after force path still keeps last raw as unwritten seed. - let mut raw = vec![60u32]; - raw.extend_from_slice(forced); - let plan = hipfire_generate::qwen::spec_prefix_realign_plan(&prompt, first, &raw); - assert_eq!(plan.seed_token, 71); - assert_ne!(plan.replay.last().copied(), Some(plan.seed_token)); - assert!(hipfire_generate::qwen::spec_prefix_realign_admit(&plan, 1024, 1024, 0, false).is_ok()); -} - -// ── Task 4 Important vetoes (production seam pins) ───────────────────── - -/// max_tokens==0 rejects at hipfire_generate::qwen::generate_spec entry via the same writer the -/// production gate uses — before prefill/GPU/state/client mutation. -/// Wire: one correlated validation error, rolled_back=false, no done/aborted. -#[test] -fn zero_budget_max_tokens_preflight_error_only_no_done() { - set_active_attempt_id(101); - let mut sink = Vec::new(); - // Mirrors hipfire_generate::qwen::generate_spec entry gate (max_tokens == 0 → emit + return None). - hipfire_generate::dense::emit_active_attempt_error( - &mut sink, - Some("zb0"), - "max_tokens must be > 0", - "validation", - false, - false, - ); - let _ = std::io::Write::flush(&mut sink); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["id"], "zb0"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 101); - assert_eq!(lines[0]["message"], "max_tokens must be > 0"); - // No first token, no safe terminal flush, no aborted pair. - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"token""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - // Wrapper contract: hipfire_generate::qwen::generate_spec returned None → no epilogue. - assert!(!qwen_dflash_epilogue_after_spec_run(false)); - set_active_attempt_id(0); -} - -/// Cancel after rollback attestation: attested → aborted+done; unattested → -/// exactly one correlated nonretryable error with context and no done. -#[test] -fn cancel_after_rollback_attested_vs_unattested_wire() { - // Attested rollback keeps fold-compatible aborted + done pair. - set_active_attempt_id(202); - let mut sink_ok = Vec::new(); - let ep_ok = attest_epilogue(true); - hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink_ok, "c-ok", 7, &ep_ok); - let out_ok = String::from_utf8(sink_ok).unwrap(); - let lines_ok = parse_jsonl(&out_ok); - assert_eq!( - lines_ok.len(), - 2, - "attested cancel: aborted+done {lines_ok:?}" - ); - assert_eq!(lines_ok[0]["type"], "aborted"); - assert_eq!(lines_ok[0]["reason"], "client_cancelled"); - assert_eq!(lines_ok[0]["attempt_id"], 202); - assert_eq!(lines_ok[0]["id"], "c-ok"); - assert_eq!(lines_ok[1]["type"], "done"); - assert_eq!(lines_ok[1]["finish_reason"], "aborted"); - assert_eq!(lines_ok[1]["completion_tokens"], 7); - assert_eq!(lines_ok[1]["attempt_id"], 202); - assert!(!out_ok.contains(r#""type":"error""#)); - assert!(!out_ok.contains(r#""type":"tool_calls""#)); - - // Unattested rollback: one fail-closed error, no aborted/done. - set_active_attempt_id(203); - let mut sink_bad = Vec::new(); - let ep_bad = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); - assert!(!ep_bad.rolled_back); - hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink_bad, "c-bad", 3, &ep_bad); - let out_bad = String::from_utf8(sink_bad).unwrap(); - let lines_bad = parse_jsonl(&out_bad); - assert_eq!( - lines_bad.len(), - 1, - "unattested cancel: error only {lines_bad:?}" - ); - assert_eq!(lines_bad[0]["type"], "error"); - assert_eq!(lines_bad[0]["class"], "validation"); - assert_eq!(lines_bad[0]["retryable"], false); - assert_eq!(lines_bad[0]["rolled_back"], false); - assert_eq!(lines_bad[0]["attempt_id"], 203); - assert_eq!(lines_bad[0]["id"], "c-bad"); - let msg = lines_bad[0]["message"].as_str().unwrap(); - assert!( - msg.contains("client cancelled; fail-closed rollback could not be attested"), - "{msg}" - ); - assert!(msg.contains("device_synchronize failed"), "{msg}"); - assert!(!out_bad.contains(r#""type":"done""#)); - assert!(!out_bad.contains(r#""type":"aborted""#)); - assert!(!out_bad.contains(r#""type":"tool_calls""#)); - set_active_attempt_id(0); -} - -/// Failure-injection: each omitted reset class (incl. single-GPU s_ef_residual -/// and EP bind) keeps rolled_back=false; aggregate failure still models sync -/// as attempted; Qwen AR prefill/decode abort terminals are exclusive. -#[test] -fn rollback_attestation_omitted_reset_classes_and_ar_abort_xor() { - // Every required surface Ok + sync Ok → attested. - let all_ok = attest_rollback_steps( - &[ - ("s_matrices", Ok(())), - ("s_scales", Ok(())), - ("conv_states", Ok(())), - ("s_ef_residual", Ok(())), - ("host_cursors", Ok(())), - ("kv_compact", Ok(())), - ("checkpoints", Ok(())), - ("drafter", Ok(())), - ("adaptive", Ok(())), - ("graph_replay", Ok(())), - ("ep_bind_thread", Ok(())), - ], - Ok(()), - ); - assert!(all_ok.rolled_back); - assert!(all_ok.context.is_none()); - - // Single-GPU s_ef_residual omission/failure alone unattests. - let ef = attest_rollback_steps( - &[ - ("s_matrices", Ok(())), - ("s_scales", Ok(())), - ("conv_states", Ok(())), - ("s_ef_residual", Err("memset failed".into())), - ("ep_bind_thread", Ok(())), - ], - Ok(()), - ); - assert!(!ef.rolled_back); - let ctx = ef.context.as_deref().unwrap_or(""); - assert!(ctx.contains("s_ef_residual"), "{ctx}"); - assert!( - !ctx.contains("device_synchronize"), - "sync Ok must not appear: {ctx}" - ); - - // EP bind_thread failure alone unattests even when sync Ok. - let bind = attest_rollback_steps( - &[ - ("s_ef_residual", Ok(())), - ("ep_bind_thread", Err("hipErrorInvalidDevice".into())), - ], - Ok(()), - ); - assert!(!bind.rolled_back); - assert!( - bind.context - .as_deref() - .unwrap_or("") - .contains("ep_bind_thread"), - "{:?}", - bind.context - ); - - // Aggregate reset failure + sync still attempted (both in context). - let agg = attest_rollback_steps( - &[ - ("s_matrices", Err("m1".into())), - ("s_ef_residual", Err("ef".into())), - ("ep_bind_thread", Err("bind".into())), - ], - Err("hipErrorUnknown".into()), - ); - assert!(!agg.rolled_back); - let ctx = agg.context.as_deref().unwrap_or(""); - assert!(ctx.contains("s_matrices"), "{ctx}"); - assert!(ctx.contains("s_ef_residual"), "{ctx}"); - assert!(ctx.contains("ep_bind_thread"), "{ctx}"); - assert!(ctx.contains("device_synchronize failed"), "{ctx}"); - - // hipfire_generate::common::fail_closed_epilogue_after_sync: prior Err + sync Ok → unattested, sync ran. - let merged = hipfire_generate::common::fail_closed_epilogue_after_sync( - Err("hipfire_generate::common::reset_qwen35_recurrent: s_ef_residual memset: boom".into()), - hipfire_generate::common::RollbackEpilogue { - rolled_back: true, - context: None, - }, - ); - assert!(!merged.rolled_back); - assert!( - merged - .context - .as_deref() - .unwrap_or("") - .contains("s_ef_residual"), - "{:?}", - merged.context - ); - - // prior Err + sync Err → both preserved. - let both = hipfire_generate::common::fail_closed_epilogue_after_sync( - Err("ep rank0 bind_thread: bad".into()), - hipfire_generate::common::RollbackEpilogue { - rolled_back: false, - context: Some("device_synchronize failed: hipErrorUnknown".into()), - }, - ); - assert!(!both.rolled_back); - let ctx = both.context.as_deref().unwrap_or(""); - assert!(ctx.contains("bind_thread"), "{ctx}"); - assert!(ctx.contains("device_synchronize failed"), "{ctx}"); - - // Qwen AR prefill abort terminal exclusivity (attested vs unattested). - set_active_attempt_id(501); - let mut sink = Vec::new(); - hipfire_generate::common::emit_spec_cancel_after_rollback( - &mut sink, - "ar-prefill", - 0, - &attest_epilogue(true), - ); - let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0]["type"], "aborted"); - assert_eq!(lines[1]["type"], "done"); - assert_eq!(lines[1]["finish_reason"], "aborted"); - assert_eq!(lines[1]["completion_tokens"], 0); - assert!(lines.iter().all(|e| e["attempt_id"] == 501)); - - set_active_attempt_id(502); - let mut sink = Vec::new(); - hipfire_generate::common::emit_spec_cancel_after_rollback( - &mut sink, - "ar-prefill-bad", - 0, - &attest_epilogue_with_context("s_ef_residual memset: boom"), - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "prefill unattested: error only"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["rolled_back"], false); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - - // Qwen AR mid-decode abort terminal exclusivity. - set_active_attempt_id(503); - let mut sink = Vec::new(); - hipfire_generate::common::emit_spec_cancel_after_rollback( - &mut sink, - "ar-decode", - 5, - &attest_epilogue(true), - ); - let lines = parse_jsonl(&String::from_utf8(sink).unwrap()); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0]["type"], "aborted"); - assert_eq!(lines[1]["finish_reason"], "aborted"); - assert_eq!(lines[1]["completion_tokens"], 5); - - set_active_attempt_id(504); - let mut sink = Vec::new(); - hipfire_generate::common::emit_spec_cancel_after_rollback( - &mut sink, - "ar-decode-bad", - 5, - &attest_epilogue_with_context("ep rank0 bind_thread: bad; device_synchronize failed: x"), - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "decode unattested: error only"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 504); - let msg = lines[0]["message"].as_str().unwrap(); - assert!(msg.contains("bind_thread"), "{msg}"); - assert!(msg.contains("device_synchronize failed"), "{msg}"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - set_active_attempt_id(0); -} - -/// Eviction-enabled missing optional kv_cache_mut is ErrorOnly (not panic): -/// hipfire_generate::qwen::classify_evict_failure_wire → hipfire_generate::common::emit_fail_closed_error with the production -/// post-prefill / per-cycle messages; no done/aborted/calls/cache. -#[test] -fn missing_optional_kv_cache_mut_is_error_only_not_panic() { - assert_eq!( - hipfire_generate::qwen::classify_evict_failure_wire(), - hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly - ); - assert_ne!( - hipfire_generate::qwen::SpecFailClosedWire::Cancelled, - hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly, - "missing KV hook must never classify as Cancelled" - ); - - for (attempt, id, message) in [ - (301u64, "kv-pp", "kv_cache_mut missing (post-prefill)"), - (302u64, "kv-pc", "kv_cache_mut missing (per-cycle)"), - ] { - set_active_attempt_id(attempt); + + /// hipfire_generate::qwen::generate_spec resume_from: on spec.rewind_to Err, host seq_pos / + /// conversation_tokens must NOT be truncated to ckpt first. Fail-closed + /// live rollback + one correlated "rewind_to: …" error; return None skips + /// wrapper epilogue (no done / calls / cache). + #[test] + fn rewind_to_err_freezes_host_cursors_then_fail_closed() { + // Host state as if mid-conversation before resume_from rewind. + let ckpt = 4usize; + let mut seq_pos = 12usize; + let mut conversation_tokens: Vec = (0..12).map(|t| t as u32).collect(); + let seq_before = seq_pos; + let toks_before = conversation_tokens.clone(); + + // Production order on Err: message first, then live rollback (which + // zeroes host), emit, return None — never the success truncate. + let restore_err = "DeltaNetSnapshot::restore_to: synthetic restore fail"; + let msg = format!("rewind_to: {restore_err}"); + + // Success path would do: seq_pos = ckpt; conversation_tokens.truncate(ckpt). + // Error path must NOT apply that before/without fail-closed. + let rewind_ok = false; + if rewind_ok { + seq_pos = ckpt; + conversation_tokens.truncate(ckpt); + } + // Cursors still at pre-rewind values until hipfire_generate::common::production_fail_closed_rollback_live. + assert_eq!( + seq_pos, seq_before, + "must not truncate seq_pos to ckpt on Err" + ); + assert_eq!( + conversation_tokens, toks_before, + "must not truncate conversation_tokens to ckpt on Err" + ); + assert_ne!(seq_pos, ckpt); + + // Live rollback zeroes host (GPU-less stand-in for hipfire_generate::common::production_fail_closed_rollback_live). + seq_pos = 0; + conversation_tokens.clear(); + assert_eq!(seq_pos, 0); + assert!(conversation_tokens.is_empty()); + + set_active_attempt_id(601); let mut sink = Vec::new(); - // Production seam: classify first, then fail-closed writer (same as - // hipfire_generate::qwen::generate_spec match slot.kv_cache_mut() { None => ... }). - let _ = hipfire_generate::qwen::classify_evict_failure_wire(); let ep = attest_epilogue(true); - match hipfire_generate::qwen::classify_evict_failure_wire() { - hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly => { - hipfire_generate::common::emit_fail_closed_error( - &mut sink, - Some(id), - message, - "validation", - false, - &ep, - ); - } - hipfire_generate::qwen::SpecFailClosedWire::Cancelled => { - panic!("kv_cache_mut missing must not classify Cancelled") - } - } + hipfire_generate::common::emit_fail_closed_error(&mut sink, Some("rw-err"), &msg, "validation", false, &ep); let out = String::from_utf8(sink).unwrap(); let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "error XOR done for {message}: {lines:?}"); + assert_eq!(lines.len(), 1, "one correlated rewind error: {lines:?}"); assert_eq!(lines[0]["type"], "error"); + assert_eq!(lines[0]["id"], "rw-err"); assert_eq!(lines[0]["class"], "validation"); assert_eq!(lines[0]["retryable"], false); assert_eq!(lines[0]["rolled_back"], true); - assert_eq!(lines[0]["attempt_id"], attempt); - assert_eq!(lines[0]["id"], id); - assert_eq!(lines[0]["message"], message); + assert_eq!(lines[0]["attempt_id"], 601); + assert_eq!(lines[0]["message"], msg); + assert!(lines[0]["message"] + .as_str() + .unwrap() + .starts_with("rewind_to:")); assert!(!out.contains(r#""type":"done""#)); assert!(!out.contains(r#""type":"aborted""#)); assert!(!out.contains(r#""type":"tool_calls""#)); - // hipfire_generate::qwen::generate_spec returns None → wrapper skips cache store / epilogue. + // hipfire_generate::qwen::generate_spec returns None → wrapper skips epilogue/cache. assert!(!qwen_dflash_epilogue_after_spec_run(false)); + + // Unattested sync path still error-only with context suffix. + set_active_attempt_id(602); + let mut sink_ua = Vec::new(); + let ep_ua = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); + hipfire_generate::common::emit_fail_closed_error( + &mut sink_ua, + Some("rw-ua"), + &msg, + "validation", + false, + &ep_ua, + ); + let out_ua = String::from_utf8(sink_ua).unwrap(); + let lines_ua = parse_jsonl(&out_ua); + assert_eq!(lines_ua.len(), 1); + assert_eq!(lines_ua[0]["rolled_back"], false); + let m = lines_ua[0]["message"].as_str().unwrap(); + assert!(m.contains("rewind_to:"), "{m}"); + assert!(m.contains("device_synchronize failed"), "{m}"); + assert!(!out_ua.contains(r#""type":"done""#)); + set_active_attempt_id(0); } - // Unattested rollback on the same missing-hook path: rolled_back=false - // + context appended; still error-only (no panic surface). - set_active_attempt_id(303); - let mut sink = Vec::new(); - let ep = attest_epilogue_with_context("device_synchronize failed: test"); - hipfire_generate::common::emit_fail_closed_error( - &mut sink, - Some("kv-ua"), - "kv_cache_mut missing (post-prefill)", - "validation", - false, - &ep, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0]["rolled_back"], false); - let msg = lines[0]["message"].as_str().unwrap(); - assert!(msg.contains("kv_cache_mut missing (post-prefill)"), "{msg}"); - assert!(msg.contains("device_synchronize failed"), "{msg}"); - assert!(!out.contains(r#""type":"done""#)); - set_active_attempt_id(0); -} - -// ── Remaining Important Task 4 vetoes (wrapper / legacy / rewind) ── - -/// hipfire_generate::qwen::generate_dflash max_tokens==0: hipfire_generate::dense::emit_active_attempt_error then return true -/// (handled) before Jinja/render/set_sampling/gen_start. Same wire as the -/// inner hipfire_generate::qwen::generate_spec defense; wrapper must not fall through to AR. -#[test] -fn generate_dflash_zero_budget_preflight_handled_error_only() { - set_active_attempt_id(401); - let mut sink = Vec::new(); - // Mirrors hipfire_generate::qwen::generate_dflash entry (max_tokens == 0 → emit + return true). - hipfire_generate::dense::emit_active_attempt_error( - &mut sink, - Some("df-zb0"), - "max_tokens must be > 0", - "validation", - false, - false, - ); - let _ = std::io::Write::flush(&mut sink); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["id"], "df-zb0"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 401); - assert_eq!(lines[0]["message"], "max_tokens must be > 0"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"token""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - // Handled=true → caller must not fall through to AR / second envelope. - let wrapper_handled = true; - assert!(wrapper_handled); - set_active_attempt_id(0); -} - -/// hipfire_generate::dense::generate_deepseek4_spec max_tokens==0: same emit policy, plain return -/// (unit fn) before DSML render / decode-cache teardown / set_sampling. -#[test] -fn generate_deepseek4_spec_zero_budget_preflight_error_only() { - set_active_attempt_id(402); - let mut sink = Vec::new(); - // Mirrors hipfire_generate::dense::generate_deepseek4_spec entry (max_tokens == 0 → emit + return). - hipfire_generate::dense::emit_active_attempt_error( - &mut sink, - Some("ds4-zb0"), - "max_tokens must be > 0", - "validation", - false, - false, - ); - let _ = std::io::Write::flush(&mut sink); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "exactly one correlated error: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["id"], "ds4-zb0"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], false); - assert_eq!(lines[0]["attempt_id"], 402); - assert_eq!(lines[0]["message"], "max_tokens must be > 0"); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"token""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - // Unit wrapper returns (no AR fallthrough second write). - set_active_attempt_id(0); -} - -/// Legacy non-qwen hipfire_generate::qwen::generate_dflash else-branch: fail_closed_rollback.is_some() -/// || grammar_violated → hipfire_generate::common::emit_fail_closed_error only; no extract/release/ -/// cache store / done. Message classified by grammar / open_think / -/// malformed_protocol / generic. -#[test] -fn legacy_non_qwen_fail_closed_epilogue_error_only_no_extract() { - // Production message selection (qwen_semantic_v2 == false branch). - fn legacy_fail_closed_message( - grammar_violated: bool, - open_think: bool, - finish_reason: &str, - ) -> &'static str { - if grammar_violated { - "grammar violation during speculative decode" - } else if open_think || finish_reason == "open_think" { - "open think span at end of generation (validation)" - } else if finish_reason == "malformed_protocol" { - "malformed tool protocol" + // ── Task 4 definitive terminal-edge blockers ────────────────────────── + + /// Legacy non-qwen hipfire_generate::qwen::generate_dflash else-branch: length still emits + /// finish_reason=length but never releases held tool calls or stores + /// asst_turn_cache (partial/truncated turns are unsafe to prime). + #[test] + fn legacy_length_terminal_skips_assistant_cache_and_tool_release() { + // Production gates (hipfire_generate::qwen::generate_dflash qwen_semantic_v2=false branch): + // hit_length_cap = run.generated >= max_tokens + // stage_terminal_tool_calls on safe tool terminals before handshake + // asst_turn_cache.insert only when Commit && !hit_length_cap && !cached_seq.is_empty() + let generated = 8usize; + let max_tokens = 8usize; + let hit_length_cap = generated >= max_tokens; + assert!(hit_length_cap); + + let finish = summary_tool_calls(vec![ToolCall { + id: None, + name: "held".into(), + arguments: serde_json::json!({}), + rendered_body: None, + }]); + assert!(finish.tool_calls > 0); + + let release = !hit_length_cap && finish.tool_calls > 0; + assert!(!release, "length must not release held finish tool calls"); + + let cached_seq = vec![1u32, 2, 3]; + let mut sink: std::collections::HashMap> = std::collections::HashMap::new(); + if !hit_length_cap && !cached_seq.is_empty() { + let decoded_full = "partial answer"; + let stripped = hipfire_generate::common::strip_think_for_fingerprint(decoded_full); + let emit_text = + hipfire_runtime::tokenizer::maybe_normalize_prompt(&stripped).into_owned(); + let emit_tool_calls = extract_tool_calls_from_text(decoded_full); + let fp = hipfire_generate::common::asst_turn_fingerprint(&emit_text, &emit_tool_calls); + sink.insert(fp, cached_seq.clone()); + } + assert!( + sink.is_empty(), + "length terminal must not store asst_turn_cache" + ); + + let finish_reason = if hit_length_cap { + "length" + } else if finish.tool_calls > 0 { + "tool_calls" } else { - "fail-closed speculative decode" + "stop" + }; + assert_eq!(finish_reason, "length"); + + // Safe non-length control: same gates allow release + store. + let hit_safe = 3usize >= 8usize; + assert!(!hit_safe); + assert!(!hit_safe && finish.tool_calls > 0); + let mut sink_safe = std::collections::HashMap::new(); + if !hit_safe && !cached_seq.is_empty() { + let fp = hipfire_generate::common::asst_turn_fingerprint("ok", &[]); + sink_safe.insert(fp, cached_seq.clone()); } + assert_eq!(sink_safe.len(), 1, "safe stop still stores"); } - let cases = [ - ( - true, - false, - "stop", - "grammar violation during speculative decode", - ), - ( - false, - true, - "stop", - "open think span at end of generation (validation)", - ), - ( - false, - false, - "open_think", - "open think span at end of generation (validation)", - ), - ( - false, - false, - "malformed_protocol", - "malformed tool protocol", - ), - (false, false, "length", "fail-closed speculative decode"), - ]; + /// Begin-triggered forced continuation is planned with the same pure + /// pending-seed transaction as mid-window force, and is ordered before + /// any speculative step (max_tokens=1 cannot spend budget on step). + #[test] + fn begin_first_token_forced_serviced_before_spec_step() { + // After begin: generated counts first token when event-bearing. + let mut generated = 1usize; + let max_tokens = 1usize; + let seed_token = 50u32; // first_token is also the initial pending seed + let forced_begin = vec![60u32, 61, 62]; + + // Empty take_forced ⇒ Skipped (no GPU path); loop may proceed. + assert!(matches!( + // Pure stand-in for hipfire_generate::qwen::apply_spec_forced_pending_seed empty input. + { + let forced_all: &[u32] = &[]; + if forced_all.is_empty() { + hipfire_generate::qwen::SpecForcedApplyResult::Skipped + } else { + hipfire_generate::qwen::SpecForcedApplyResult::Applied + } + }, + hipfire_generate::qwen::SpecForcedApplyResult::Skipped + )); + + // Hard budget clip: generated already 1, max_tokens=1 → room 0. + let clipped = hipfire_generate::qwen::spec_forced_tokens_within_budget(generated, max_tokens, &forced_begin); + assert!( + clipped.is_empty(), + "max_tokens=1 after first token must clip all forced (no extra step budget)" + ); + // hipfire_generate::qwen::apply_spec_forced_pending_seed returns Skipped on empty clip — while + // condition `generated < max_tokens` is already false, so no spec.step. + assert!(!(!false /*first_token_is_eos*/ && generated < max_tokens)); + + // Room for forced (max_tokens=3, generated=1): same tx as mid-window. + generated = 1; + let max2 = 3usize; + let forced = hipfire_generate::qwen::spec_forced_tokens_within_budget(generated, max2, &forced_begin); + assert_eq!(forced, &[60u32, 61]); + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed_token, forced, true); + assert_eq!(tx.commit, vec![50, 60], "trigger retained; forced[..n-1]"); + assert_eq!(tx.pending_seed, 61, "last forced pending once"); + assert!(!tx.commit.contains(&61)); + assert_eq!(tx.position_delta, forced.len()); + + // Ordering contract: begin force runs before while/spec.step. + let mut phase = "begin"; + let forced_begin_nonempty = !forced_begin.is_empty(); + if forced_begin_nonempty { + phase = "begin_forced_applied"; + } + let enter_spec_step = phase == "begin_forced_applied" && generated < max2; + // After applying 2 forced, generated would be 1+2=3 → loop does not step. + let generated_after = generated + forced.len(); + assert_eq!(generated_after, 3); + assert!( + !(generated_after < max2), + "after begin force at budget, no speculative step" + ); + let _ = enter_spec_step; + assert_eq!(phase, "begin_forced_applied"); + + // hipfire_generate::qwen::classify_forced_gpu_advance still exclusive cancel vs commit. + assert!(matches!( + hipfire_generate::qwen::classify_forced_gpu_advance(true), + hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled + )); + assert!(matches!( + hipfire_generate::qwen::classify_forced_gpu_advance(false), + hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed + )); + } - for (i, (grammar, open_think, reason, expected_msg)) in cases.iter().enumerate() { + /// Qwen first seed runs user stop-sequence detection in begin exactly like + /// later observe tokens; StopSequence terminates before any speculative step. + #[test] + fn qwen_begin_first_token_stop_sequence_terminates_before_step() { + let tok = test_tokenizer(); + let ids = tok.encode("STOP"); + assert!(!ids.is_empty()); + let first = ids[0]; + let first_text = tok.decode(&[first]); + let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { + tokenizer: &tok, + eos: 9, + im_end: Some(1), + tools: None, + stop: vec![first_text.clone()], + max_think: 0, + max_tokens: 256, + assistant_prefix: AssistantPrefix::Plain, + think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, + decoded_vocab: None, + }); + let first_begin = emit.begin(first); assert_eq!( - legacy_fail_closed_message(*grammar, *open_think, reason), - *expected_msg, - "case {i} message select" + first_begin.stop, + Some(StopReason::StopSequence), + "begin must surface StopSequence for first-token stop match" + ); + // hipfire_generate::qwen::generate_spec: first_token_is_eos = first_begin.stop.is_some() + let first_token_is_eos = first_begin.stop.is_some(); + assert!(first_token_is_eos); + // while !first_token_is_eos && generated < max_tokens { spec.step ... } + let mut stepped = false; + if !first_token_is_eos { + stepped = true; + } + assert!( + !stepped, + "StopSequence begin must skip every speculative step" ); - // Gate: fail_closed_rollback.is_some() || grammar_violated. - let fail_closed_present = true; - let take_error_only = fail_closed_present || *grammar; - assert!(take_error_only, "case {i} must take error-only path"); - set_active_attempt_id(500 + i as u64); - let mut sink = Vec::new(); - let ep = attest_epilogue(true); - hipfire_generate::common::emit_fail_closed_error( - &mut sink, - Some("leg-fc"), - expected_msg, - "validation", - false, - &ep, + // Event-bearing first token still counts (Qwen always commits). + assert!( + hipfire_generate::qwen::spec_outcome_seed_committable(&first_begin), + "stop still commits the raw first token" ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "case {i}: error XOR done {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["id"], "leg-fc"); - assert_eq!(lines[0]["message"], *expected_msg); - // No held tool_calls release, no cache store, no done/aborted. - assert!(!out.contains(r#""type":"done""#), "case {i}"); - assert!(!out.contains(r#""type":"aborted""#), "case {i}"); - assert!(!out.contains(r#""type":"tool_calls""#), "case {i}"); - // Early return true from hipfire_generate::qwen::generate_dflash — no whole-output extract path. - let early_return_handled = true; - assert!(early_return_handled); - } - set_active_attempt_id(0); -} - -/// hipfire_generate::qwen::generate_spec resume_from: on spec.rewind_to Err, host seq_pos / -/// conversation_tokens must NOT be truncated to ckpt first. Fail-closed -/// live rollback + one correlated "rewind_to: …" error; return None skips -/// wrapper epilogue (no done / calls / cache). -#[test] -fn rewind_to_err_freezes_host_cursors_then_fail_closed() { - // Host state as if mid-conversation before resume_from rewind. - let ckpt = 4usize; - let mut seq_pos = 12usize; - let mut conversation_tokens: Vec = (0..12).map(|t| t as u32).collect(); - let seq_before = seq_pos; - let toks_before = conversation_tokens.clone(); - - // Production order on Err: message first, then live rollback (which - // zeroes host), emit, return None — never the success truncate. - let restore_err = "DeltaNetSnapshot::restore_to: synthetic restore fail"; - let msg = format!("rewind_to: {restore_err}"); - - // Success path would do: seq_pos = ckpt; conversation_tokens.truncate(ckpt). - // Error path must NOT apply that before/without fail-closed. - let rewind_ok = false; - if rewind_ok { - seq_pos = ckpt; - conversation_tokens.truncate(ckpt); - } - // Cursors still at pre-rewind values until hipfire_generate::common::production_fail_closed_rollback_live. - assert_eq!( - seq_pos, seq_before, - "must not truncate seq_pos to ckpt on Err" - ); - assert_eq!( - conversation_tokens, toks_before, - "must not truncate conversation_tokens to ckpt on Err" - ); - assert_ne!(seq_pos, ckpt); - - // Live rollback zeroes host (GPU-less stand-in for hipfire_generate::common::production_fail_closed_rollback_live). - seq_pos = 0; - conversation_tokens.clear(); - assert_eq!(seq_pos, 0); - assert!(conversation_tokens.is_empty()); - - set_active_attempt_id(601); - let mut sink = Vec::new(); - let ep = attest_epilogue(true); - hipfire_generate::common::emit_fail_closed_error( - &mut sink, - Some("rw-err"), - &msg, - "validation", - false, - &ep, - ); - let out = String::from_utf8(sink).unwrap(); - let lines = parse_jsonl(&out); - assert_eq!(lines.len(), 1, "one correlated rewind error: {lines:?}"); - assert_eq!(lines[0]["type"], "error"); - assert_eq!(lines[0]["id"], "rw-err"); - assert_eq!(lines[0]["class"], "validation"); - assert_eq!(lines[0]["retryable"], false); - assert_eq!(lines[0]["rolled_back"], true); - assert_eq!(lines[0]["attempt_id"], 601); - assert_eq!(lines[0]["message"], msg); - assert!(lines[0]["message"] - .as_str() - .unwrap() - .starts_with("rewind_to:")); - assert!(!out.contains(r#""type":"done""#)); - assert!(!out.contains(r#""type":"aborted""#)); - assert!(!out.contains(r#""type":"tool_calls""#)); - // hipfire_generate::qwen::generate_spec returns None → wrapper skips epilogue/cache. - assert!(!qwen_dflash_epilogue_after_spec_run(false)); - - // Unattested sync path still error-only with context suffix. - set_active_attempt_id(602); - let mut sink_ua = Vec::new(); - let ep_ua = attest_epilogue_with_context("device_synchronize failed: hipErrorUnknown"); - hipfire_generate::common::emit_fail_closed_error( - &mut sink_ua, - Some("rw-ua"), - &msg, - "validation", - false, - &ep_ua, - ); - let out_ua = String::from_utf8(sink_ua).unwrap(); - let lines_ua = parse_jsonl(&out_ua); - assert_eq!(lines_ua.len(), 1); - assert_eq!(lines_ua[0]["rolled_back"], false); - let m = lines_ua[0]["message"].as_str().unwrap(); - assert!(m.contains("rewind_to:"), "{m}"); - assert!(m.contains("device_synchronize failed"), "{m}"); - assert!(!out_ua.contains(r#""type":"done""#)); - set_active_attempt_id(0); -} - -// ── Task 4 definitive terminal-edge blockers ────────────────────────── - -/// Legacy non-qwen hipfire_generate::qwen::generate_dflash else-branch: length still emits -/// finish_reason=length but never releases held tool calls or stores -/// asst_turn_cache (partial/truncated turns are unsafe to prime). -#[test] -fn legacy_length_terminal_skips_assistant_cache_and_tool_release() { - // Production gates (hipfire_generate::qwen::generate_dflash qwen_semantic_v2=false branch): - // hit_length_cap = run.generated >= max_tokens - // stage_terminal_tool_calls on safe tool terminals before handshake - // asst_turn_cache.insert only when Commit && !hit_length_cap && !cached_seq.is_empty() - let generated = 8usize; - let max_tokens = 8usize; - let hit_length_cap = generated >= max_tokens; - assert!(hit_length_cap); - - let finish = summary_tool_calls(vec![ToolCall { - id: None, - name: "held".into(), - arguments: serde_json::json!({}), - rendered_body: None, - }]); - assert!(finish.tool_calls > 0); - - let release = !hit_length_cap && finish.tool_calls > 0; - assert!(!release, "length must not release held finish tool calls"); - - let cached_seq = vec![1u32, 2, 3]; - let mut sink: std::collections::HashMap> = std::collections::HashMap::new(); - if !hit_length_cap && !cached_seq.is_empty() { - let decoded_full = "partial answer"; - let stripped = hipfire_generate::common::strip_think_for_fingerprint(decoded_full); - let emit_text = hipfire_runtime::tokenizer::maybe_normalize_prompt(&stripped).into_owned(); - let emit_tool_calls = extract_tool_calls_from_text(decoded_full); - let fp = hipfire_generate::common::asst_turn_fingerprint(&emit_text, &emit_tool_calls); - sink.insert(fp, cached_seq.clone()); - } - assert!( - sink.is_empty(), - "length terminal must not store asst_turn_cache" - ); - - let finish_reason = if hit_length_cap { - "length" - } else if finish.tool_calls > 0 { - "tool_calls" - } else { - "stop" - }; - assert_eq!(finish_reason, "length"); - - // Safe non-length control: same gates allow release + store. - let hit_safe = 3usize >= 8usize; - assert!(!hit_safe); - assert!(!hit_safe && finish.tool_calls > 0); - let mut sink_safe = std::collections::HashMap::new(); - if !hit_safe && !cached_seq.is_empty() { - let fp = hipfire_generate::common::asst_turn_fingerprint("ok", &[]); - sink_safe.insert(fp, cached_seq.clone()); - } - assert_eq!(sink_safe.len(), 1, "safe stop still stores"); -} - -/// Begin-triggered forced continuation is planned with the same pure -/// pending-seed transaction as mid-window force, and is ordered before -/// any speculative step (max_tokens=1 cannot spend budget on step). -#[test] -fn begin_first_token_forced_serviced_before_spec_step() { - // After begin: generated counts first token when event-bearing. - let mut generated = 1usize; - let max_tokens = 1usize; - let seed_token = 50u32; // first_token is also the initial pending seed - let forced_begin = vec![60u32, 61, 62]; - - // Empty take_forced ⇒ Skipped (no GPU path); loop may proceed. - assert!(matches!( - // Pure stand-in for hipfire_generate::qwen::apply_spec_forced_pending_seed empty input. - { - let forced_all: &[u32] = &[]; - if forced_all.is_empty() { - hipfire_generate::qwen::SpecForcedApplyResult::Skipped - } else { - hipfire_generate::qwen::SpecForcedApplyResult::Applied + assert!(first_begin + .events + .iter() + .any(|e| matches!(e, ClientEvent::Committed { id, .. } if *id == first))); + + // Forced begin path is still consulted, but empty take_forced is Skipped. + let forced_begin = emit.take_forced(); + assert!(forced_begin.is_empty()); + } + + // --- Task 4 reviewer blockers: forced-token / terminal-cause seams --- + + /// Non-committable pending seed (DS4 empty-event EOS) must not be prepended + /// into the forced GPU commit. Forced tokens occupy that same slot; all but + /// the final kept forced token are committed, final remains pending. + #[test] + fn noncommittable_pending_seed_omitted_from_forced_tx() { + // Single forced + non-committable seed: commit is empty (seed omitted, + // forced[0] becomes pending only) — no GPU for a lone seed replace. + let seed = 7u32; // DS4-style empty-event EOS seed + let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[42], false); + assert!( + one.commit.is_empty(), + "non-committable seed + single forced must not GPU-commit: {:?}", + one.commit + ); + assert_eq!(one.position_delta, 0); + assert_eq!(one.pending_seed, 42); + assert!(!one.commit.contains(&seed)); + + // Multi forced + non-committable: commit is forced[..n-1] only. + let multi = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[10, 11, 12], false); + assert_eq!( + multi.commit, + vec![10, 11], + "seed omitted; forced prefix only" + ); + assert!(!multi.commit.contains(&seed)); + assert_eq!(multi.pending_seed, 12); + assert_eq!(multi.position_delta, multi.commit.len()); + assert!(!multi.commit.contains(&12), "last forced stays pending"); + + // Contrast: same inputs with committable seed retain the trigger. + let keep = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[10, 11, 12], true); + assert_eq!(keep.commit, vec![seed, 10, 11]); + assert_eq!(keep.pending_seed, 12); + } + + /// Forced suffix stages observe first, trims at the first non-None stop, + /// GPU-commits only that kept prefix, and renders only after successful + /// commit. Later forced tokens are never observed/committed/rendered. + #[test] + fn forced_suffix_stops_at_first_stop_sequence_prefix_only() { + let tok = test_tokenizer(); + // Build a stop string from a real token, then force a later token that + // must not be observed once stop fires. + let stop_ids = tok.encode("STOP"); + assert!(!stop_ids.is_empty()); + let stop_tok = stop_ids[0]; + let stop_text = tok.decode(&[stop_tok]); + let later = tok.encode("later"); + assert!(!later.is_empty()); + let later_tok = later[0]; + assert_ne!(stop_tok, later_tok); + + let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { + tokenizer: &tok, + eos: 9, + im_end: Some(1), + tools: None, + stop: vec![stop_text.clone()], + max_think: 0, + max_tokens: 256, + assistant_prefix: AssistantPrefix::Plain, + think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, + decoded_vocab: None, + }); + // Warm begin so observe path is active (forced uses observe). + let warm = tok.encode("hi"); + assert!(!warm.is_empty()); + let _ = emit.begin(warm[0]); + + // Production staging loop (hipfire_generate::qwen::apply_spec_forced_pending_seed): + let forced_all = [stop_tok, later_tok, later_tok.wrapping_add(1)]; + let mut staged: Vec<(u32, hipfire_runtime::spec::EmitOutcome)> = + Vec::with_capacity(forced_all.len()); + let mut stop_reason: Option = None; + for &ft in &forced_all { + let fo = emit.observe(ft); + let stop = fo.stop; + staged.push((ft, fo)); + if let Some(reason) = stop { + stop_reason = Some(reason); + break; } - }, - hipfire_generate::qwen::SpecForcedApplyResult::Skipped - )); - - // Hard budget clip: generated already 1, max_tokens=1 → room 0. - let clipped = hipfire_generate::qwen::spec_forced_tokens_within_budget( - generated, - max_tokens, - &forced_begin, - ); - assert!( - clipped.is_empty(), - "max_tokens=1 after first token must clip all forced (no extra step budget)" - ); - // hipfire_generate::qwen::apply_spec_forced_pending_seed returns Skipped on empty clip — while - // condition `generated < max_tokens` is already false, so no spec.step. - assert!(!(!false /*first_token_is_eos*/ && generated < max_tokens)); - - // Room for forced (max_tokens=3, generated=1): same tx as mid-window. - generated = 1; - let max2 = 3usize; - let forced = - hipfire_generate::qwen::spec_forced_tokens_within_budget(generated, max2, &forced_begin); - assert_eq!(forced, &[60u32, 61]); - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed_token, forced, true); - assert_eq!(tx.commit, vec![50, 60], "trigger retained; forced[..n-1]"); - assert_eq!(tx.pending_seed, 61, "last forced pending once"); - assert!(!tx.commit.contains(&61)); - assert_eq!(tx.position_delta, forced.len()); - - // Ordering contract: begin force runs before while/spec.step. - let mut phase = "begin"; - let forced_begin_nonempty = !forced_begin.is_empty(); - if forced_begin_nonempty { - phase = "begin_forced_applied"; - } - let enter_spec_step = phase == "begin_forced_applied" && generated < max2; - // After applying 2 forced, generated would be 1+2=3 → loop does not step. - let generated_after = generated + forced.len(); - assert_eq!(generated_after, 3); - assert!( - !(generated_after < max2), - "after begin force at budget, no speculative step" - ); - let _ = enter_spec_step; - assert_eq!(phase, "begin_forced_applied"); - - // hipfire_generate::qwen::classify_forced_gpu_advance still exclusive cancel vs commit. - assert!(matches!( - hipfire_generate::qwen::classify_forced_gpu_advance(true), - hipfire_generate::qwen::ForcedGpuAdvanceKind::Cancelled - )); - assert!(matches!( - hipfire_generate::qwen::classify_forced_gpu_advance(false), - hipfire_generate::qwen::ForcedGpuAdvanceKind::Committed - )); -} - -/// Qwen first seed runs user stop-sequence detection in begin exactly like -/// later observe tokens; StopSequence terminates before any speculative step. -#[test] -fn qwen_begin_first_token_stop_sequence_terminates_before_step() { - let tok = test_tokenizer(); - let ids = tok.encode("STOP"); - assert!(!ids.is_empty()); - let first = ids[0]; - let first_text = tok.decode(&[first]); - let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { - tokenizer: &tok, - eos: 9, - im_end: Some(1), - tools: None, - stop: vec![first_text.clone()], - max_think: 0, - max_tokens: 256, - assistant_prefix: AssistantPrefix::Plain, - think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, - decoded_vocab: None, - }); - let first_begin = emit.begin(first); - assert_eq!( - first_begin.stop, - Some(StopReason::StopSequence), - "begin must surface StopSequence for first-token stop match" - ); - // hipfire_generate::qwen::generate_spec: first_token_is_eos = first_begin.stop.is_some() - let first_token_is_eos = first_begin.stop.is_some(); - assert!(first_token_is_eos); - // while !first_token_is_eos && generated < max_tokens { spec.step ... } - let mut stepped = false; - if !first_token_is_eos { - stepped = true; - } - assert!( - !stepped, - "StopSequence begin must skip every speculative step" - ); - - // Event-bearing first token still counts (Qwen always commits). - assert!( - hipfire_generate::qwen::spec_outcome_seed_committable(&first_begin), - "stop still commits the raw first token" - ); - assert!(first_begin - .events - .iter() - .any(|e| matches!(e, ClientEvent::Committed { id, .. } if *id == first))); - - // Forced begin path is still consulted, but empty take_forced is Skipped. - let forced_begin = emit.take_forced(); - assert!(forced_begin.is_empty()); -} - -// --- Task 4 reviewer blockers: forced-token / terminal-cause seams --- - -/// Non-committable pending seed (DS4 empty-event EOS) must not be prepended -/// into the forced GPU commit. Forced tokens occupy that same slot; all but -/// the final kept forced token are committed, final remains pending. -#[test] -fn noncommittable_pending_seed_omitted_from_forced_tx() { - // Single forced + non-committable seed: commit is empty (seed omitted, - // forced[0] becomes pending only) — no GPU for a lone seed replace. - let seed = 7u32; // DS4-style empty-event EOS seed - let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[42], false); - assert!( - one.commit.is_empty(), - "non-committable seed + single forced must not GPU-commit: {:?}", - one.commit - ); - assert_eq!(one.position_delta, 0); - assert_eq!(one.pending_seed, 42); - assert!(!one.commit.contains(&seed)); - - // Multi forced + non-committable: commit is forced[..n-1] only. - let multi = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[10, 11, 12], false); - assert_eq!( - multi.commit, - vec![10, 11], - "seed omitted; forced prefix only" - ); - assert!(!multi.commit.contains(&seed)); - assert_eq!(multi.pending_seed, 12); - assert_eq!(multi.position_delta, multi.commit.len()); - assert!(!multi.commit.contains(&12), "last forced stays pending"); - - // Contrast: same inputs with committable seed retain the trigger. - let keep = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[10, 11, 12], true); - assert_eq!(keep.commit, vec![seed, 10, 11]); - assert_eq!(keep.pending_seed, 12); -} - -/// Forced suffix stages observe first, trims at the first non-None stop, -/// GPU-commits only that kept prefix, and renders only after successful -/// commit. Later forced tokens are never observed/committed/rendered. -#[test] -fn forced_suffix_stops_at_first_stop_sequence_prefix_only() { - let tok = test_tokenizer(); - // Build a stop string from a real token, then force a later token that - // must not be observed once stop fires. - let stop_ids = tok.encode("STOP"); - assert!(!stop_ids.is_empty()); - let stop_tok = stop_ids[0]; - let stop_text = tok.decode(&[stop_tok]); - let later = tok.encode("later"); - assert!(!later.is_empty()); - let later_tok = later[0]; - assert_ne!(stop_tok, later_tok); - - let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { - tokenizer: &tok, - eos: 9, - im_end: Some(1), - tools: None, - stop: vec![stop_text.clone()], - max_think: 0, - max_tokens: 256, - assistant_prefix: AssistantPrefix::Plain, - think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, - decoded_vocab: None, - }); - // Warm begin so observe path is active (forced uses observe). - let warm = tok.encode("hi"); - assert!(!warm.is_empty()); - let _ = emit.begin(warm[0]); - - // Production staging loop (hipfire_generate::qwen::apply_spec_forced_pending_seed): - let forced_all = [stop_tok, later_tok, later_tok.wrapping_add(1)]; - let mut staged: Vec<(u32, hipfire_runtime::spec::EmitOutcome)> = - Vec::with_capacity(forced_all.len()); - let mut stop_reason: Option = None; - for &ft in &forced_all { - let fo = emit.observe(ft); - let stop = fo.stop; - staged.push((ft, fo)); - if let Some(reason) = stop { - stop_reason = Some(reason); - break; } - } - assert_eq!( - stop_reason, - Some(StopReason::StopSequence), - "first forced token matching stop must halt the suffix" - ); - assert_eq!( - staged.len(), - 1, - "later forced tokens must not be observed after stop" - ); - assert_eq!(staged[0].0, stop_tok); - - let kept: Vec = staged.iter().map(|(t, _)| *t).collect(); - assert_eq!(kept, vec![stop_tok]); - - // Commit uses the kept prefix only (incoming seed was committable). - let incoming_seed = warm[0]; - let incoming_committable = true; - let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx( - incoming_seed, - &kept, - incoming_committable, - ); - // Single kept forced: commit = [seed], pending = stop_tok. - assert_eq!(tx.commit, vec![incoming_seed]); - assert_eq!(tx.pending_seed, stop_tok); - assert!(!tx.commit.contains(&later_tok)); - assert!(!tx.commit.contains(&stop_tok)); - - // Apply result maps to Stopped(reason) — not Applied. - let apply = match stop_reason { - Some(reason) => hipfire_generate::qwen::SpecForcedApplyResult::Stopped(reason), - None => hipfire_generate::qwen::SpecForcedApplyResult::Applied, - }; - assert_eq!( - apply, - hipfire_generate::qwen::SpecForcedApplyResult::Stopped(StopReason::StopSequence) - ); - - // Render-after-commit contract: client events from staged outcomes are - // only eligible once GPU commit of `tx.commit` succeeded. Model the - // gate explicitly so a reorder (render then commit) fails this test. - let mut gpu_committed = false; - let mut rendered: Vec = Vec::new(); - // "commit" kept prefix - gpu_committed = true; - if gpu_committed { - for (ft, fo) in &staged { - if !fo.events.is_empty() { - rendered.push(*ft); + assert_eq!( + stop_reason, + Some(StopReason::StopSequence), + "first forced token matching stop must halt the suffix" + ); + assert_eq!( + staged.len(), + 1, + "later forced tokens must not be observed after stop" + ); + assert_eq!(staged[0].0, stop_tok); + + let kept: Vec = staged.iter().map(|(t, _)| *t).collect(); + assert_eq!(kept, vec![stop_tok]); + + // Commit uses the kept prefix only (incoming seed was committable). + let incoming_seed = warm[0]; + let incoming_committable = true; + let tx = hipfire_generate::qwen::spec_forced_pending_seed_tx(incoming_seed, &kept, incoming_committable); + // Single kept forced: commit = [seed], pending = stop_tok. + assert_eq!(tx.commit, vec![incoming_seed]); + assert_eq!(tx.pending_seed, stop_tok); + assert!(!tx.commit.contains(&later_tok)); + assert!(!tx.commit.contains(&stop_tok)); + + // Apply result maps to Stopped(reason) — not Applied. + let apply = match stop_reason { + Some(reason) => hipfire_generate::qwen::SpecForcedApplyResult::Stopped(reason), + None => hipfire_generate::qwen::SpecForcedApplyResult::Applied, + }; + assert_eq!( + apply, + hipfire_generate::qwen::SpecForcedApplyResult::Stopped(StopReason::StopSequence) + ); + + // Render-after-commit contract: client events from staged outcomes are + // only eligible once GPU commit of `tx.commit` succeeded. Model the + // gate explicitly so a reorder (render then commit) fails this test. + let mut gpu_committed = false; + let mut rendered: Vec = Vec::new(); + // "commit" kept prefix + gpu_committed = true; + if gpu_committed { + for (ft, fo) in &staged { + if !fo.events.is_empty() { + rendered.push(*ft); + } } } + assert!(gpu_committed); + assert_eq!( + rendered, + vec![stop_tok], + "render only kept prefix after commit" + ); + assert!(!rendered.contains(&later_tok)); } - assert!(gpu_committed); - assert_eq!( - rendered, - vec![stop_tok], - "render only kept prefix after commit" - ); - assert!(!rendered.contains(&later_tok)); -} - -/// Begin and mid callers treat Stopped as turn-terminal: set semantic_stop, -/// force first_token_is_eos / hit_eos, and skip later force + all spec.step. -#[test] -fn begin_and_mid_stopped_skips_later_force_and_spec_step() { - // --- begin path (mirrors hipfire_generate::qwen::generate_spec after emit.begin) --- - let reason = StopReason::StopSequence; - let mut semantic_stop: Option = None; - let mut first_token_is_eos = false; - let apply = hipfire_generate::qwen::SpecForcedApplyResult::Stopped(reason); - match apply { - hipfire_generate::qwen::SpecForcedApplyResult::Terminal => panic!("not under test"), - hipfire_generate::qwen::SpecForcedApplyResult::Stopped(r) => { - if semantic_stop.is_none() && hipfire_generate::qwen::spec_stop_is_semantic(Some(r)) { - semantic_stop = Some(r); + + /// Begin and mid callers treat Stopped as turn-terminal: set semantic_stop, + /// force first_token_is_eos / hit_eos, and skip later force + all spec.step. + #[test] + fn begin_and_mid_stopped_skips_later_force_and_spec_step() { + // --- begin path (mirrors hipfire_generate::qwen::generate_spec after emit.begin) --- + let reason = StopReason::StopSequence; + let mut semantic_stop: Option = None; + let mut first_token_is_eos = false; + let apply = hipfire_generate::qwen::SpecForcedApplyResult::Stopped(reason); + match apply { + hipfire_generate::qwen::SpecForcedApplyResult::Terminal => panic!("not under test"), + hipfire_generate::qwen::SpecForcedApplyResult::Stopped(r) => { + if semantic_stop.is_none() && hipfire_generate::qwen::spec_stop_is_semantic(Some(r)) { + semantic_stop = Some(r); + } + first_token_is_eos = true; + } + hipfire_generate::qwen::SpecForcedApplyResult::Applied | hipfire_generate::qwen::SpecForcedApplyResult::Skipped => { + panic!("expected Stopped") } - first_token_is_eos = true; } - hipfire_generate::qwen::SpecForcedApplyResult::Applied - | hipfire_generate::qwen::SpecForcedApplyResult::Skipped => { - panic!("expected Stopped") + assert_eq!(semantic_stop, Some(StopReason::StopSequence)); + assert!(first_token_is_eos); + + // while !first_token_is_eos && generated < max_tokens { spec.step ... } + let generated = 0usize; + let max_tokens = 16usize; + let mut stepped = false; + let mut later_force = false; + if !first_token_is_eos && generated < max_tokens { + // would take_forced + spec.step + later_force = true; + stepped = true; } - } - assert_eq!(semantic_stop, Some(StopReason::StopSequence)); - assert!(first_token_is_eos); - - // while !first_token_is_eos && generated < max_tokens { spec.step ... } - let generated = 0usize; - let max_tokens = 16usize; - let mut stepped = false; - let mut later_force = false; - if !first_token_is_eos && generated < max_tokens { - // would take_forced + spec.step - later_force = true; - stepped = true; - } - assert!( - !stepped && !later_force, - "begin Stopped must skip every subsequent force and spec.step" - ); - - // --- mid-window path (mirrors hipfire_generate::qwen::generate_spec forced_after match) --- - let mut semantic_stop_mid: Option = None; - let mut hit_eos = false; - let mut think_cap_hit = false; - let mid = hipfire_generate::qwen::SpecForcedApplyResult::Stopped(StopReason::StopSequence); - match mid { - hipfire_generate::qwen::SpecForcedApplyResult::Terminal => panic!("not under test"), - hipfire_generate::qwen::SpecForcedApplyResult::Stopped(r) => { - if semantic_stop_mid.is_none() && hipfire_generate::qwen::spec_stop_is_semantic(Some(r)) - { - semantic_stop_mid = Some(r); - } - match r { - StopReason::ThinkCap => think_cap_hit = true, - StopReason::Eos | StopReason::StopSequence | StopReason::GrammarViolation => { - hit_eos = true + assert!( + !stepped && !later_force, + "begin Stopped must skip every subsequent force and spec.step" + ); + + // --- mid-window path (mirrors hipfire_generate::qwen::generate_spec forced_after match) --- + let mut semantic_stop_mid: Option = None; + let mut hit_eos = false; + let mut think_cap_hit = false; + let mid = hipfire_generate::qwen::SpecForcedApplyResult::Stopped(StopReason::StopSequence); + match mid { + hipfire_generate::qwen::SpecForcedApplyResult::Terminal => panic!("not under test"), + hipfire_generate::qwen::SpecForcedApplyResult::Stopped(r) => { + if semantic_stop_mid.is_none() && hipfire_generate::qwen::spec_stop_is_semantic(Some(r)) { + semantic_stop_mid = Some(r); + } + match r { + StopReason::ThinkCap => think_cap_hit = true, + StopReason::Eos | StopReason::StopSequence | StopReason::GrammarViolation => { + hit_eos = true + } } } + hipfire_generate::qwen::SpecForcedApplyResult::Applied | hipfire_generate::qwen::SpecForcedApplyResult::Skipped => { + panic!("expected Stopped") + } } - hipfire_generate::qwen::SpecForcedApplyResult::Applied - | hipfire_generate::qwen::SpecForcedApplyResult::Skipped => { - panic!("expected Stopped") + assert_eq!(semantic_stop_mid, Some(StopReason::StopSequence)); + assert!(hit_eos); + assert!(!think_cap_hit); + + // After mid Stopped the cycle must not re-enter force or continue the + // outer decode as if Applied. Model the break: no second take_forced. + let mut second_force_applied = false; + if !hit_eos && !think_cap_hit { + second_force_applied = true; } + assert!( + !second_force_applied, + "mid Stopped must not apply a later forced suffix" + ); + + // hipfire_generate::common::SpecRun carries semantic_stop into the wrapper independently of EOT. + let run_semantic = semantic_stop_mid; + assert!(run_semantic.is_some()); + assert!(hipfire_generate::qwen::spec_stop_is_semantic(run_semantic)); } - assert_eq!(semantic_stop_mid, Some(StopReason::StopSequence)); - assert!(hit_eos); - assert!(!think_cap_hit); - - // After mid Stopped the cycle must not re-enter force or continue the - // outer decode as if Applied. Model the break: no second take_forced. - let mut second_force_applied = false; - if !hit_eos && !think_cap_hit { - second_force_applied = true; - } - assert!( - !second_force_applied, - "mid Stopped must not apply a later forced suffix" - ); - - // hipfire_generate::common::SpecRun carries semantic_stop into the wrapper independently of EOT. - let run_semantic = semantic_stop_mid; - assert!(run_semantic.is_some()); - assert!(hipfire_generate::qwen::spec_stop_is_semantic(run_semantic)); -} - -/// First-token user stop at max_tokens=1 must classify as stop (not length) -/// via semantic_stop surviving independently of decoded_eot. -#[test] -fn first_token_stop_sequence_at_max_tokens_one_is_stop_not_length() { - let tok = test_tokenizer(); - let ids = tok.encode("STOP"); - assert!(!ids.is_empty()); - let first = ids[0]; - let first_text = tok.decode(&[first]); - let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { - tokenizer: &tok, - eos: 9, - im_end: Some(1), - tools: None, - stop: vec![first_text.clone()], - max_think: 0, - max_tokens: 1, - assistant_prefix: AssistantPrefix::Plain, - think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, - decoded_vocab: None, - }); - let first_begin = emit.begin(first); - assert_eq!(first_begin.stop, Some(StopReason::StopSequence)); - - // hipfire_generate::qwen::generate_spec sticky capture (begin path). - let mut semantic_stop: Option = - if hipfire_generate::qwen::spec_stop_is_semantic(first_begin.stop) { + + /// First-token user stop at max_tokens=1 must classify as stop (not length) + /// via semantic_stop surviving independently of decoded_eot. + #[test] + fn first_token_stop_sequence_at_max_tokens_one_is_stop_not_length() { + let tok = test_tokenizer(); + let ids = tok.encode("STOP"); + assert!(!ids.is_empty()); + let first = ids[0]; + let first_text = tok.decode(&[first]); + let mut emit = hipfire_arch_qwen35::spec_emit::Qwen35Emit::from_ctx(SpecEmitCtx { + tokenizer: &tok, + eos: 9, + im_end: Some(1), + tools: None, + stop: vec![first_text.clone()], + max_think: 0, + max_tokens: 1, + assistant_prefix: AssistantPrefix::Plain, + think_mode: hipfire_runtime::prompt_frame::ThinkMode::NonThink, + decoded_vocab: None, + }); + let first_begin = emit.begin(first); + assert_eq!(first_begin.stop, Some(StopReason::StopSequence)); + + // hipfire_generate::qwen::generate_spec sticky capture (begin path). + let mut semantic_stop: Option = if hipfire_generate::qwen::spec_stop_is_semantic(first_begin.stop) { first_begin.stop } else { None }; - assert_eq!(semantic_stop, Some(StopReason::StopSequence)); - assert!(hipfire_generate::qwen::spec_stop_is_semantic(semantic_stop)); - - // Budget spent on the first (and only) token; no decoded_eot required. - let generated = 1usize; - let max_tokens = 1usize; - let decoded_eot = false; // user stop may not set EOT - let hit_length = hipfire_generate::common::qwen_dflash_hit_length_cap( - generated, - max_tokens, - decoded_eot, - semantic_stop.is_some(), - ); - assert!( - !hit_length, - "semantic StopSequence at cap must not classify as length" - ); - - // Wrapper wire: stop, not length. - let fin = summary_stop(&first_text); - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( - &fin, - hit_length, - false, - &first_text, - false, - ); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - store_cache, - release_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "stop"); - assert!(*store_cache); - assert!(!*release_tool_calls); + assert_eq!(semantic_stop, Some(StopReason::StopSequence)); + assert!(hipfire_generate::qwen::spec_stop_is_semantic(semantic_stop)); + + // Budget spent on the first (and only) token; no decoded_eot required. + let generated = 1usize; + let max_tokens = 1usize; + let decoded_eot = false; // user stop may not set EOT + let hit_length = + hipfire_generate::common::qwen_dflash_hit_length_cap(generated, max_tokens, decoded_eot, semantic_stop.is_some()); + assert!( + !hit_length, + "semantic StopSequence at cap must not classify as length" + ); + + // Wrapper wire: stop, not length. + let fin = summary_stop(&first_text); + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, hit_length, false, &first_text, false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + store_cache, + release_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "stop"); + assert!(*store_cache); + assert!(!*release_tool_calls); + } + other => panic!("expected stop Done, got {other:?}"), } - other => panic!("expected stop Done, got {other:?}"), - } - - // Contrast: same numbers without semantic_stop → length. - assert!(hipfire_generate::common::qwen_dflash_hit_length_cap( - 1, 1, false, false - )); - let _ = &mut semantic_stop; -} - -/// Held tool_calls + semantic stop at the budget boundary must finish as -/// tool_calls (not length). hipfire_generate::common::finish_summary_held_tool_calls feeds the wire. -#[test] -fn held_tool_calls_with_semantic_stop_at_cap_is_tool_calls_not_length() { - let calls = vec![ToolCall { - id: None, - name: "get_weather".into(), - arguments: serde_json::json!({"city": "SF"}), - rendered_body: None, - }]; - let fin = summary_tool_calls(calls.clone()); - let held = hipfire_generate::common::finish_summary_held_tool_calls(&fin); - assert_eq!(held.len(), 1); - assert_eq!(held[0].name, "get_weather"); - - // generated == max_tokens, no decoded_eot, but semantic stop sticky. - let generated = 8usize; - let max_tokens = 8usize; - let decoded_eot = false; - let semantic_stop = Some(StopReason::StopSequence); - assert!(hipfire_generate::qwen::spec_stop_is_semantic(semantic_stop)); - let hit_length = hipfire_generate::common::qwen_dflash_hit_length_cap( - generated, - max_tokens, - decoded_eot, - semantic_stop.is_some(), - ); - assert!(!hit_length, "semantic stop must beat length at cap"); - - let term = - hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, hit_length, false, "Sure.", false); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - store_cache, - wire_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "tool_calls"); - assert!(*release_tool_calls); - assert!(*store_cache); - assert_eq!(wire_tool_calls.len(), 1); - assert_eq!(wire_tool_calls[0].name, "get_weather"); + + // Contrast: same numbers without semantic_stop → length. + assert!(hipfire_generate::common::qwen_dflash_hit_length_cap(1, 1, false, false)); + let _ = &mut semantic_stop; + } + + /// Held tool_calls + semantic stop at the budget boundary must finish as + /// tool_calls (not length). hipfire_generate::common::finish_summary_held_tool_calls feeds the wire. + #[test] + fn held_tool_calls_with_semantic_stop_at_cap_is_tool_calls_not_length() { + let calls = vec![ToolCall { + id: None, + name: "get_weather".into(), + arguments: serde_json::json!({"city": "SF"}), + rendered_body: None, + }]; + let fin = summary_tool_calls(calls.clone()); + let held = hipfire_generate::common::finish_summary_held_tool_calls(&fin); + assert_eq!(held.len(), 1); + assert_eq!(held[0].name, "get_weather"); + + // generated == max_tokens, no decoded_eot, but semantic stop sticky. + let generated = 8usize; + let max_tokens = 8usize; + let decoded_eot = false; + let semantic_stop = Some(StopReason::StopSequence); + assert!(hipfire_generate::qwen::spec_stop_is_semantic(semantic_stop)); + let hit_length = + hipfire_generate::common::qwen_dflash_hit_length_cap(generated, max_tokens, decoded_eot, semantic_stop.is_some()); + assert!(!hit_length, "semantic stop must beat length at cap"); + + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, hit_length, false, "Sure.", false); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + store_cache, + wire_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "tool_calls"); + assert!(*release_tool_calls); + assert!(*store_cache); + assert_eq!(wire_tool_calls.len(), 1); + assert_eq!(wire_tool_calls[0].name, "get_weather"); + } + other => panic!("expected tool_calls Done, got {other:?}"), } - other => panic!("expected tool_calls Done, got {other:?}"), - } - - // Without semantic_stop the same finish would be suppressed as length. - assert!(hipfire_generate::common::qwen_dflash_hit_length_cap( - 8, 8, false, false - )); - let length_term = - hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "Sure.", false); - match &length_term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { - finish_reason, - release_tool_calls, - wire_tool_calls, - .. - } => { - assert_eq!(*finish_reason, "length"); - assert!(!*release_tool_calls); - assert!(wire_tool_calls.is_empty()); + + // Without semantic_stop the same finish would be suppressed as length. + assert!(hipfire_generate::common::qwen_dflash_hit_length_cap(8, 8, false, false)); + let length_term = hipfire_generate::qwen::qwen_dflash_wire_terminal(&fin, true, false, "Sure.", false); + match &length_term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + finish_reason, + release_tool_calls, + wire_tool_calls, + .. + } => { + assert_eq!(*finish_reason, "length"); + assert!(!*release_tool_calls); + assert!(wire_tool_calls.is_empty()); + } + other => panic!("expected length Done, got {other:?}"), + } + } + + // ── Task 4 forced-continuation physical-cap admission ───────────────── + + /// Pure admission: no-eviction requires a free pending-seed write slot + /// after the commit (`post_position < physical_cap`). Exact-cap rejects. + #[test] + fn forced_commit_no_evict_exact_cap_rejects_pending_seed_slot() { + let physical_cap = 16usize; + let position = 12usize; + let commit_len = 4usize; // post_position == physical_cap + assert_eq!(position.saturating_add(commit_len), physical_cap); + assert!( + !hipfire_generate::qwen::spec_forced_commit_admits(position, commit_len, physical_cap, false), + "no-eviction exact-cap must reject: pending seed needs a legal slot" + ); + // One slot under cap still fits (post == cap-1). + assert!(hipfire_generate::qwen::spec_forced_commit_admits( + position, + commit_len.saturating_sub(1), + physical_cap, + false + )); + // Over-cap also rejects. + assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + position, + commit_len.saturating_add(1), + physical_cap, + false + )); + } + + /// Eviction path still refuses post_position > physical_cap before any GPU + /// write. Exact-cap is the only boundary that eviction may open. + #[test] + fn forced_commit_eviction_over_cap_rejects_before_gpu() { + let physical_cap = 16usize; + let position = 12usize; + let over = 5usize; // post_position = 17 > cap + assert!(position.saturating_add(over) > physical_cap); + assert!( + !hipfire_generate::qwen::spec_forced_commit_admits(position, over, physical_cap, true), + "eviction must not admit over-cap commits" + ); + + // Deterministic pre-GPU gate: reject ⇒ no GPU commit, no staged render. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Phase { + Staged, + GpuCommitted, + Rendered, + ErrorOnly, + } + let admitted = hipfire_generate::qwen::spec_forced_commit_admits(position, over, physical_cap, true); + let mut phase = Phase::Staged; + let mut rendered = 0usize; + if !admitted { + // Production: rollback + ErrorOnly terminal; discard staged events. + phase = Phase::ErrorOnly; + } else { + phase = Phase::GpuCommitted; + phase = Phase::Rendered; + rendered = 1; } - other => panic!("expected length Done, got {other:?}"), - } -} - -// ── Task 4 forced-continuation physical-cap admission ───────────────── - -/// Pure admission: no-eviction requires a free pending-seed write slot -/// after the commit (`post_position < physical_cap`). Exact-cap rejects. -#[test] -fn forced_commit_no_evict_exact_cap_rejects_pending_seed_slot() { - let physical_cap = 16usize; - let position = 12usize; - let commit_len = 4usize; // post_position == physical_cap - assert_eq!(position.saturating_add(commit_len), physical_cap); - assert!( - !hipfire_generate::qwen::spec_forced_commit_admits( + assert_eq!(phase, Phase::ErrorOnly); + assert_eq!( + rendered, 0, + "capacity reject must never render staged events" + ); + assert_ne!(phase, Phase::GpuCommitted); + assert_ne!(phase, Phase::Rendered); + // Same wire class as maybe_evict / on_evict failures. + assert_eq!(hipfire_generate::qwen::classify_evict_failure_wire(), hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly); + } + + /// Eviction exact-cap admits only because post-commit maybe_evict+on_evict + /// is mandatory before host seed/raw/render and must leave a free seed slot. + #[test] + fn forced_commit_eviction_exact_cap_admits_with_mandatory_post_commit_evict() { + let physical_cap = 16usize; + let position = 12usize; + let commit_len = 4usize; // post_position == physical_cap + assert_eq!(position.saturating_add(commit_len), physical_cap); + + assert!( + hipfire_generate::qwen::spec_forced_commit_admits(position, commit_len, physical_cap, true), + "eviction may admit exact-cap" + ); + // Contrast: same numbers without eviction reject. + assert!(!hipfire_generate::qwen::spec_forced_commit_admits( position, commit_len, physical_cap, false - ), - "no-eviction exact-cap must reject: pending seed needs a legal slot" - ); - // One slot under cap still fits (post == cap-1). - assert!(hipfire_generate::qwen::spec_forced_commit_admits( - position, - commit_len.saturating_sub(1), - physical_cap, - false - )); - // Over-cap also rejects. - assert!(!hipfire_generate::qwen::spec_forced_commit_admits( - position, - commit_len.saturating_add(1), - physical_cap, - false - )); -} - -/// Eviction path still refuses post_position > physical_cap before any GPU -/// write. Exact-cap is the only boundary that eviction may open. -#[test] -fn forced_commit_eviction_over_cap_rejects_before_gpu() { - let physical_cap = 16usize; - let position = 12usize; - let over = 5usize; // post_position = 17 > cap - assert!(position.saturating_add(over) > physical_cap); - assert!( - !hipfire_generate::qwen::spec_forced_commit_admits(position, over, physical_cap, true), - "eviction must not admit over-cap commits" - ); - - // Deterministic pre-GPU gate: reject ⇒ no GPU commit, no staged render. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - enum Phase { - Staged, - GpuCommitted, - Rendered, - ErrorOnly, - } - let admitted = - hipfire_generate::qwen::spec_forced_commit_admits(position, over, physical_cap, true); - let mut phase = Phase::Staged; - let mut rendered = 0usize; - if !admitted { - // Production: rollback + ErrorOnly terminal; discard staged events. - phase = Phase::ErrorOnly; - } else { - phase = Phase::GpuCommitted; - phase = Phase::Rendered; - rendered = 1; - } - assert_eq!(phase, Phase::ErrorOnly); - assert_eq!( - rendered, 0, - "capacity reject must never render staged events" - ); - assert_ne!(phase, Phase::GpuCommitted); - assert_ne!(phase, Phase::Rendered); - // Same wire class as maybe_evict / on_evict failures. - assert_eq!( - hipfire_generate::qwen::classify_evict_failure_wire(), - hipfire_generate::qwen::SpecFailClosedWire::ErrorOnly - ); -} - -/// Eviction exact-cap admits only because post-commit maybe_evict+on_evict -/// is mandatory before host seed/raw/render and must leave a free seed slot. -#[test] -fn forced_commit_eviction_exact_cap_admits_with_mandatory_post_commit_evict() { - let physical_cap = 16usize; - let position = 12usize; - let commit_len = 4usize; // post_position == physical_cap - assert_eq!(position.saturating_add(commit_len), physical_cap); - - assert!( - hipfire_generate::qwen::spec_forced_commit_admits(position, commit_len, physical_cap, true), - "eviction may admit exact-cap" - ); - // Contrast: same numbers without eviction reject. - assert!(!hipfire_generate::qwen::spec_forced_commit_admits( - position, - commit_len, - physical_cap, - false - )); - - // Ordering model for the admitted exact-cap path: GPU commit → mandatory - // post-commit eviction → require post_evict < physical_cap → only then - // host position/seed/raw/render. Skipping eviction must not reach render. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - enum Step { - Admit, - GpuCommit, - PostCommitEvict, - HostRender, - ErrorOnly, - } - let mut steps: Vec = Vec::new(); - let admitted = - hipfire_generate::qwen::spec_forced_commit_admits(position, commit_len, physical_cap, true); - assert!(admitted); - steps.push(Step::Admit); - steps.push(Step::GpuCommit); - - let eviction_enabled = true; - let mut post_position = position.saturating_add(commit_len); - let mut rendered = false; - if eviction_enabled { - // Mandatory: maybe_evict + on_evict before host updates. - steps.push(Step::PostCommitEvict); - // Synthetic successful compaction frees the pending-seed slot. - post_position = physical_cap.saturating_sub(1); - if post_position >= physical_cap { - steps.push(Step::ErrorOnly); + )); + + // Ordering model for the admitted exact-cap path: GPU commit → mandatory + // post-commit eviction → require post_evict < physical_cap → only then + // host position/seed/raw/render. Skipping eviction must not reach render. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Step { + Admit, + GpuCommit, + PostCommitEvict, + HostRender, + ErrorOnly, + } + let mut steps: Vec = Vec::new(); + let admitted = hipfire_generate::qwen::spec_forced_commit_admits(position, commit_len, physical_cap, true); + assert!(admitted); + steps.push(Step::Admit); + steps.push(Step::GpuCommit); + + let eviction_enabled = true; + let mut post_position = position.saturating_add(commit_len); + let mut rendered = false; + if eviction_enabled { + // Mandatory: maybe_evict + on_evict before host updates. + steps.push(Step::PostCommitEvict); + // Synthetic successful compaction frees the pending-seed slot. + post_position = physical_cap.saturating_sub(1); + if post_position >= physical_cap { + steps.push(Step::ErrorOnly); + } else { + steps.push(Step::HostRender); + rendered = true; + } } else { steps.push(Step::HostRender); rendered = true; } - } else { - steps.push(Step::HostRender); - rendered = true; - } - assert_eq!( - steps, - vec![ - Step::Admit, - Step::GpuCommit, - Step::PostCommitEvict, - Step::HostRender - ] - ); - assert!(rendered); - assert!(post_position < physical_cap); - - // If post-evict still has no seed slot → ErrorOnly, no render. - let mut bad_steps: Vec = vec![Step::Admit, Step::GpuCommit, Step::PostCommitEvict]; - let bad_post = physical_cap; // eviction failed to free a slot - let mut bad_rendered = false; - if bad_post >= physical_cap { - bad_steps.push(Step::ErrorOnly); - } else { - bad_steps.push(Step::HostRender); - bad_rendered = true; - } - assert_eq!( - bad_steps, - vec![ - Step::Admit, - Step::GpuCommit, - Step::PostCommitEvict, - Step::ErrorOnly - ] - ); - assert!(!bad_rendered); -} - -/// Comfortably under the physical cap admits with or without eviction. -#[test] -fn forced_commit_under_threshold_fits() { - let physical_cap = 64usize; - let position = 10usize; - let commit_len = 3usize; - assert!(position.saturating_add(commit_len) < physical_cap); - assert!(hipfire_generate::qwen::spec_forced_commit_admits( - position, - commit_len, - physical_cap, - false - )); - assert!(hipfire_generate::qwen::spec_forced_commit_admits( - position, - commit_len, - physical_cap, - true - )); - // Empty commit (seed-only replace) is always under threshold. - assert!(hipfire_generate::qwen::spec_forced_commit_admits( - position, - 0, - physical_cap, - false - )); - assert!(hipfire_generate::qwen::spec_forced_commit_admits( - position, - 0, - physical_cap, - true - )); -} - -/// Admission uses the actual GPU commit slice (`tx.commit.len()`), never the -/// forced token count. Non-committable seeds omit the trigger and shrink -/// the commit — that shorter length is what capacity sees. -#[test] -fn forced_commit_admission_uses_tx_commit_len_not_forced_count() { - let physical_cap = 10usize; - let position = 8usize; - let seed = 7u32; - let forced = [10u32, 11, 12]; // forced.len() == 3 - - // Committable: commit = [seed, 10, 11] → len 3; post = 11 > cap. - let keep = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &forced, true); - assert_eq!(keep.commit.len(), 3); - assert_eq!(keep.commit.len(), keep.position_delta); - assert!( - !hipfire_generate::qwen::spec_forced_commit_admits( + assert_eq!( + steps, + vec![ + Step::Admit, + Step::GpuCommit, + Step::PostCommitEvict, + Step::HostRender + ] + ); + assert!(rendered); + assert!(post_position < physical_cap); + + // If post-evict still has no seed slot → ErrorOnly, no render. + let mut bad_steps: Vec = vec![Step::Admit, Step::GpuCommit, Step::PostCommitEvict]; + let bad_post = physical_cap; // eviction failed to free a slot + let mut bad_rendered = false; + if bad_post >= physical_cap { + bad_steps.push(Step::ErrorOnly); + } else { + bad_steps.push(Step::HostRender); + bad_rendered = true; + } + assert_eq!( + bad_steps, + vec![ + Step::Admit, + Step::GpuCommit, + Step::PostCommitEvict, + Step::ErrorOnly + ] + ); + assert!(!bad_rendered); + } + + /// Comfortably under the physical cap admits with or without eviction. + #[test] + fn forced_commit_under_threshold_fits() { + let physical_cap = 64usize; + let position = 10usize; + let commit_len = 3usize; + assert!(position.saturating_add(commit_len) < physical_cap); + assert!(hipfire_generate::qwen::spec_forced_commit_admits( position, - keep.commit.len(), + commit_len, physical_cap, false - ), - "committable commit_len=3 at pos=8 must reject under no-evict" - ); - assert!( - !hipfire_generate::qwen::spec_forced_commit_admits( + )); + assert!(hipfire_generate::qwen::spec_forced_commit_admits( position, - keep.commit.len(), + commit_len, physical_cap, true - ), - "committable commit_len=3 at pos=8 is over-cap even with eviction" - ); - - // Non-committable: commit = [10, 11] → len 2 (seed omitted); post = 10. - let omit = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &forced, false); - assert_eq!(omit.commit, vec![10, 11]); - assert_eq!(omit.commit.len(), 2); - assert_eq!(omit.position_delta, omit.commit.len()); - assert_ne!( - omit.commit.len(), - forced.len(), - "must not admit against forced token count" - ); - // Using forced.len() would be wrong (post=11 over-cap); actual slice fits - // exact-cap under eviction and rejects under no-evict (needs seed slot). - assert_eq!(position.saturating_add(omit.commit.len()), physical_cap); - assert!( - !hipfire_generate::qwen::spec_forced_commit_admits( - position, + )); + // Empty commit (seed-only replace) is always under threshold. + assert!(hipfire_generate::qwen::spec_forced_commit_admits(position, 0, physical_cap, false)); + assert!(hipfire_generate::qwen::spec_forced_commit_admits(position, 0, physical_cap, true)); + } + + /// Admission uses the actual GPU commit slice (`tx.commit.len()`), never the + /// forced token count. Non-committable seeds omit the trigger and shrink + /// the commit — that shorter length is what capacity sees. + #[test] + fn forced_commit_admission_uses_tx_commit_len_not_forced_count() { + let physical_cap = 10usize; + let position = 8usize; + let seed = 7u32; + let forced = [10u32, 11, 12]; // forced.len() == 3 + + // Committable: commit = [seed, 10, 11] → len 3; post = 11 > cap. + let keep = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &forced, true); + assert_eq!(keep.commit.len(), 3); + assert_eq!(keep.commit.len(), keep.position_delta); + assert!( + !hipfire_generate::qwen::spec_forced_commit_admits(position, keep.commit.len(), physical_cap, false), + "committable commit_len=3 at pos=8 must reject under no-evict" + ); + assert!( + !hipfire_generate::qwen::spec_forced_commit_admits(position, keep.commit.len(), physical_cap, true), + "committable commit_len=3 at pos=8 is over-cap even with eviction" + ); + + // Non-committable: commit = [10, 11] → len 2 (seed omitted); post = 10. + let omit = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &forced, false); + assert_eq!(omit.commit, vec![10, 11]); + assert_eq!(omit.commit.len(), 2); + assert_eq!(omit.position_delta, omit.commit.len()); + assert_ne!( omit.commit.len(), + forced.len(), + "must not admit against forced token count" + ); + // Using forced.len() would be wrong (post=11 over-cap); actual slice fits + // exact-cap under eviction and rejects under no-evict (needs seed slot). + assert_eq!(position.saturating_add(omit.commit.len()), physical_cap); + assert!( + !hipfire_generate::qwen::spec_forced_commit_admits(position, omit.commit.len(), physical_cap, false), + "no-evict exact-cap still needs a pending-seed slot" + ); + assert!( + hipfire_generate::qwen::spec_forced_commit_admits(position, omit.commit.len(), physical_cap, true), + "eviction admits exact-cap on the actual (shorter) commit slice" + ); + // Guard: if a caller mistakenly passed forced.len(), both modes reject. + assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + position, + forced.len(), + physical_cap, + true + )); + assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + position, + forced.len(), physical_cap, false - ), - "no-evict exact-cap still needs a pending-seed slot" - ); - assert!( - hipfire_generate::qwen::spec_forced_commit_admits( + )); + + // Single forced + non-committable: empty commit — no GPU write. + // Admission still uses commit_len=0 (not forced.len()==1). + let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[42], false); + assert!(one.commit.is_empty()); + assert_ne!( + one.commit.len(), + 1, + "must not treat forced count as commit_len" + ); + assert!(hipfire_generate::qwen::spec_forced_commit_admits( position, - omit.commit.len(), + one.commit.len(), + physical_cap, + false + )); + // At physical_cap with zero-length commit: no-evict still needs a free + // pending-seed slot (post == cap rejects); eviction admits exact-cap. + assert!(!hipfire_generate::qwen::spec_forced_commit_admits( + physical_cap, + one.commit.len(), + physical_cap, + false + )); + assert!(hipfire_generate::qwen::spec_forced_commit_admits( + physical_cap, + one.commit.len(), physical_cap, true - ), - "eviction admits exact-cap on the actual (shorter) commit slice" - ); - // Guard: if a caller mistakenly passed forced.len(), both modes reject. - assert!(!hipfire_generate::qwen::spec_forced_commit_admits( - position, - forced.len(), - physical_cap, - true - )); - assert!(!hipfire_generate::qwen::spec_forced_commit_admits( - position, - forced.len(), - physical_cap, - false - )); - - // Single forced + non-committable: empty commit — no GPU write. - // Admission still uses commit_len=0 (not forced.len()==1). - let one = hipfire_generate::qwen::spec_forced_pending_seed_tx(seed, &[42], false); - assert!(one.commit.is_empty()); - assert_ne!( - one.commit.len(), - 1, - "must not treat forced count as commit_len" - ); - assert!(hipfire_generate::qwen::spec_forced_commit_admits( - position, - one.commit.len(), - physical_cap, - false - )); - // At physical_cap with zero-length commit: no-evict still needs a free - // pending-seed slot (post == cap rejects); eviction admits exact-cap. - assert!(!hipfire_generate::qwen::spec_forced_commit_admits( - physical_cap, - one.commit.len(), - physical_cap, - false - )); - assert!(hipfire_generate::qwen::spec_forced_commit_admits( - physical_cap, - one.commit.len(), - physical_cap, - true - )); -} - -#[test] -fn dflash_client_commit_preserves_release_and_store() { - let e = hipfire_generate::qwen::qwen_client_commit_effects( - ClientTerminalDecision::Commit, - true, - true, - ); - assert!(e.release_tool_calls && e.store_cache && e.emit_done); - // Successful Done classify → intended flags gate release/store. - let tc = ToolCall { - id: None, - name: "read".into(), - arguments: r#"{"path":"/x"}"#.into(), - rendered_body: None, - }; - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( - &summary_tool_calls(vec![tc.clone()]), - false, - false, - "Sure.", - false, - ); - match &term { - hipfire_generate::qwen::QwenDflashWireTerminal::Done { + )); + } + + #[test] + fn dflash_client_commit_preserves_release_and_store() { + let e = hipfire_generate::qwen::qwen_client_commit_effects(ClientTerminalDecision::Commit, true, true); + assert!(e.release_tool_calls && e.store_cache && e.emit_done); + // Successful Done classify → intended flags gate release/store. + let tc = ToolCall { + id: None, + name: "read".into(), + arguments: r#"{"path":"/x"}"#.into(), + rendered_body: None, + }; + let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( + &summary_tool_calls(vec![tc.clone()]), + false, + false, + "Sure.", + false, + ); + match &term { + hipfire_generate::qwen::QwenDflashWireTerminal::Done { + release_tool_calls, + store_cache, + wire_tool_calls, + .. + } => { + let effects = hipfire_generate::qwen::qwen_client_commit_effects( + ClientTerminalDecision::Commit, + *release_tool_calls && !wire_tool_calls.is_empty(), + *store_cache, + ); + assert!(effects.release_tool_calls); + assert!(effects.store_cache); + assert!(effects.emit_done); + let mut action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + action.store = effects.store_cache && action.store; + assert!(action.store); + } + other => panic!("expected Done, got {other:?}"), + } + } + + #[test] + fn dflash_client_abort_suppresses_release_store_done() { + set_active_attempt_id(33); + let tc = ToolCall { + id: None, + name: "read".into(), + arguments: r#"{"path":"/x"}"#.into(), + rendered_body: None, + }; + let term = + hipfire_generate::qwen::qwen_dflash_wire_terminal(&summary_tool_calls(vec![tc]), false, false, "Sure.", false); + let hipfire_generate::qwen::QwenDflashWireTerminal::Done { release_tool_calls, store_cache, wire_tool_calls, .. - } => { - let effects = hipfire_generate::qwen::qwen_client_commit_effects( - ClientTerminalDecision::Commit, - *release_tool_calls && !wire_tool_calls.is_empty(), - *store_cache, - ); - assert!(effects.release_tool_calls); - assert!(effects.store_cache); - assert!(effects.emit_done); - let mut action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - action.store = effects.store_cache && action.store; - assert!(action.store); - } - other => panic!("expected Done, got {other:?}"), - } -} - -#[test] -fn dflash_client_abort_suppresses_release_store_done() { - set_active_attempt_id(33); - let tc = ToolCall { - id: None, - name: "read".into(), - arguments: r#"{"path":"/x"}"#.into(), - rendered_body: None, - }; - let term = hipfire_generate::qwen::qwen_dflash_wire_terminal( - &summary_tool_calls(vec![tc]), - false, - false, - "Sure.", - false, - ); - let hipfire_generate::qwen::QwenDflashWireTerminal::Done { - release_tool_calls, - store_cache, - wire_tool_calls, - .. - } = &term - else { - panic!("expected Done"); - }; - let effects = hipfire_generate::qwen::qwen_client_commit_effects( - ClientTerminalDecision::Abort, - *release_tool_calls && !wire_tool_calls.is_empty(), - *store_cache, - ); - assert!(!effects.release_tool_calls); - assert!(!effects.store_cache); - assert!(!effects.emit_done); - - let mut sink = Vec::new(); - // No tool release on Abort. - let mut action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); - action.store = effects.store_cache && action.store; - let mut stored = false; - let _ = hipfire_generate::qwen::qwen_dflash_apply_cache_action( - |_fp, _seq| stored = true, - &action, - vec![1, 2, 3], - ); - assert!(!stored); - - let ep = hipfire_generate::common::RollbackEpilogue { - rolled_back: true, - context: None, - }; - hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink, "df-abort", 7, &ep); - let out = String::from_utf8_lossy(&sink); - assert!(!out.contains("\"type\":\"tool_calls\"")); - assert!(out.contains("\"type\":\"aborted\"")); - assert!(out.contains("\"finish_reason\":\"aborted\"")); - assert!(!out.contains("\"finish_reason\":\"tool_calls\"")); - assert!(out.contains("\"attempt_id\":33")); -} + } = &term + else { + panic!("expected Done"); + }; + let effects = hipfire_generate::qwen::qwen_client_commit_effects( + ClientTerminalDecision::Abort, + *release_tool_calls && !wire_tool_calls.is_empty(), + *store_cache, + ); + assert!(!effects.release_tool_calls); + assert!(!effects.store_cache); + assert!(!effects.emit_done); + + let mut sink = Vec::new(); + // No tool release on Abort. + let mut action = hipfire_generate::qwen::qwen_dflash_cache_action(&term); + action.store = effects.store_cache && action.store; + let mut stored = false; + let _ = hipfire_generate::qwen::qwen_dflash_apply_cache_action(|_fp, _seq| stored = true, &action, vec![1, 2, 3]); + assert!(!stored); + + let ep = hipfire_generate::common::RollbackEpilogue { + rolled_back: true, + context: None, + }; + hipfire_generate::common::emit_spec_cancel_after_rollback(&mut sink, "df-abort", 7, &ep); + let out = String::from_utf8_lossy(&sink); + assert!(!out.contains("\"type\":\"tool_calls\"")); + assert!(out.contains("\"type\":\"aborted\"")); + assert!(out.contains("\"finish_reason\":\"aborted\"")); + assert!(!out.contains("\"finish_reason\":\"tool_calls\"")); + assert!(out.contains("\"attempt_id\":33")); + } From 3babc969827c853efefbedc37eb6e1df6db9b089 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:56:26 +0000 Subject: [PATCH 16/34] chore: refresh crate maps (check-crate-maps --write) --- crates/hipfire-generate/map.md | 10 +++++----- crates/hipfire-runtime/map.md | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/hipfire-generate/map.md b/crates/hipfire-generate/map.md index f59f344a3..93a9ddf5b 100644 --- a/crates/hipfire-generate/map.md +++ b/crates/hipfire-generate/map.md @@ -24,10 +24,10 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside |---|---:|---:|---:| | [`src/ar.rs`](src/ar.rs) | 4,623 | 49 | 0 | | [`src/batch.rs`](src/batch.rs) | 3,465 | 8 | 0 | -| [`src/common.rs`](src/common.rs) | 1,569 | 48 | 1 | -| [`src/dense.rs`](src/dense.rs) | 8,455 | 91 | 4 | +| [`src/common.rs`](src/common.rs) | 1,619 | 49 | 2 | +| [`src/dense.rs`](src/dense.rs) | 8,458 | 91 | 4 | | [`src/lib.rs`](src/lib.rs) | 58 | 7 | 0 | -| [`src/qwen.rs`](src/qwen.rs) | 6,312 | 60 | 1 | +| [`src/qwen.rs`](src/qwen.rs) | 6,340 | 60 | 1 | | [`src/redline.rs`](src/redline.rs) | 4,361 | 49 | 1 | | [`src/vision.rs`](src/vision.rs) | 2,942 | 9 | 8 | @@ -35,7 +35,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside - [`src/ar.rs`](src/ar.rs): `arm_fault_after_prefill`, `qwen_ar_route_think_events`, `qwen_ar_route_filter_text`, `QwenArRouteFinish`, `QwenArTerminalCause`, `resolve`, `qwen_ar_finish_route`, `qwen_ar_eos_filter_config`, `qwen_ar_observe_and_route`, `qwen_ar_drain_pending_into_router`, `QwenArRawCommitDisposition`, `qwen_ar_raw_commit_token`, +37 more - [`src/batch.rs`](src/batch.rs): `lfm_prefill_cancellable_or_fallback`, `is_batch_request_eligible`, `drive_qwen_continuous_batch`, `drive_lfm_continuous_batch`, `attach_qwen_ep_batch_receipt_evidence`, `is_qwen_ep_batch_request_eligible`, `drive_qwen35_ep_continuous_batch`, `emit_uncorrelated_error` -- [`src/common.rs`](src/common.rs): `asst_turn_fingerprint`, `strip_think_for_fingerprint`, `normalize_asst_turn_for_fingerprint`, `emit_spec_cancel_after_rollback`, `RollbackEpilogue`, `production_fail_closed_rollback`, `production_fail_closed_rollback_live`, `emit_fail_closed_error`, `ds4_gen_start_contract_version`, `gen_start_contract_version_for_arch`, `Ds4MalformedTerminalAction`, `ds4_malformed_terminal_action`, +36 more +- [`src/common.rs`](src/common.rs): `asst_turn_fingerprint`, `strip_think_for_fingerprint`, `normalize_asst_turn_for_fingerprint`, `emit_spec_cancel_after_rollback`, `RollbackEpilogue`, `production_fail_closed_rollback`, `production_fail_closed_rollback_live`, `emit_fail_closed_error`, `ds4_gen_start_contract_version`, `gen_start_contract_version_for_arch`, `Ds4MalformedTerminalAction`, `ds4_malformed_terminal_action`, +37 more - [`src/dense.rs`](src/dense.rs): `glimmer_turn_key`, `emit_active_attempt_error`, `Ds4SpecWireTerminal`, `ds4_spec_wire_terminal`, `ds4_cache_action`, `ds4_ar_client_abort`, `GlimmerSpecMode`, `glimmer_spec_admission`, `write_error_envelope`, `generate_deepseek4_spec`, `generate_deepseek4`, `ds4_heterogeneous_client_abort`, +79 more - [`src/lib.rs`](src/lib.rs): `common`, `ar`, `qwen`, `dense`, `vision`, `redline`, `batch` - [`src/qwen.rs`](src/qwen.rs): `EpSampling`, `generate_ep`, `ep_emit_token`, `ep_serve_qwen35_dense_tp`, `ep_emit_done`, `ep_reset_after_abort`, `ep_emit_abort`, `ep_serve_ds4`, `ep_serve_minimax`, `qwen_history_tool_render`, `plan_prompt_cache`, `plan_from_rendered`, +48 more @@ -55,6 +55,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 8 modules · 31,785 lines · 321 public items · 224 tests · 0 examples +- 8 modules · 31,866 lines · 322 public items · 226 tests · 0 examples diff --git a/crates/hipfire-runtime/map.md b/crates/hipfire-runtime/map.md index 1995fd991..56ace59ae 100644 --- a/crates/hipfire-runtime/map.md +++ b/crates/hipfire-runtime/map.md @@ -38,8 +38,8 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | [`src/cpu_router.rs`](src/cpu_router.rs) | 200 | 4 | 3 | | [`src/ddtree.rs`](src/ddtree.rs) | 2,046 | 17 | 24 | | [`src/device_mesh.rs`](src/device_mesh.rs) | 582 | 21 | 8 | -| [`src/dflash.rs`](src/dflash.rs) | 3,460 | 44 | 4 | -| [`src/dflash_generic.rs`](src/dflash_generic.rs) | 1,365 | 3 | 13 | +| [`src/dflash.rs`](src/dflash.rs) | 3,655 | 44 | 4 | +| [`src/dflash_generic.rs`](src/dflash_generic.rs) | 1,382 | 3 | 13 | | [`src/dspark_block_controller.rs`](src/dspark_block_controller.rs) | 442 | 0 | 10 | | [`src/dspark_core.rs`](src/dspark_core.rs) | 1,773 | 11 | 0 | | [`src/emit_text.rs`](src/emit_text.rs) | 1,702 | 20 | 39 | @@ -156,6 +156,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 58 modules · 53,079 lines · 893 public items · 617 tests · 132 examples +- 58 modules · 53,291 lines · 893 public items · 617 tests · 132 examples From 4e360fb2a8308e7522c3ea4e28b251b5b35da253 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Fri, 4 Sep 2026 17:24:32 +0000 Subject: [PATCH 17/34] fix(runtime): llama never admits MQ-V2 to batched prefill; forward_prefill_chunk has no V2 arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hw-gate Fable seat on #690 (run 33895641944), source trace verified: the shared MQ-V2 admit rule made llama::is_batchable_la admit plain Llama/Qwen3-dense qt44/47-50 artifacts to WMMA prefill on gfx11 and gfx12, but llama.rs::forward_prefill_chunk's per-layer matchers (qkv_is_mq ~:2570, wo_is_mq ~:3025, ffn_is_mq ~:3117, w_down_is_mq ~:3248) list only MQ4G256|MQ6G256|MQ3G256|MFP4G32 — an admitted V2 model skips the FWHT rotate and runs the V1 hfq4g256 launchers on V2 blobs: silently incoherent prefill. master's pre-existing mq4_v2_gfx12 arm had the same hole on gfx12; no gfx12 Llama-V2 artifact has ever tripped it. llama::is_batchable_la now refuses every *G256V2 dtype and MQ4CG256 on every arch, with the reason at the site. qwen35::is_batchable_la keeps the shared mqv2_wmma_batchable rule (its chunk path has the V2 arms; gfx11 kill-switch intact). llama_spec::batched_verify_eligible routes all seven weights through is_batchable_la, so it is covered without an edit. Lockstep test now asserts the true contract: agreement on every non-V2 dtype across 5 arches; for V2, qwen35 admits on gfx11/gfx12 and llama refuses everywhere. Spec §9 row and crate maps corrected. hipfire-runtime is_batchable_la: 7 passed; qwen35 is_batchable + lockstep: 9 passed. --- crates/hipfire-arch-qwen35/map.md | 4 +- .../hipfire-arch-qwen35/src/qwen35/prefill.rs | 90 ++++++++++++--- crates/hipfire-runtime/map.md | 4 +- crates/hipfire-runtime/src/llama.rs | 109 ++++++++---------- docs/quant-formats/mq4-v2.md | 2 +- 5 files changed, 129 insertions(+), 80 deletions(-) diff --git a/crates/hipfire-arch-qwen35/map.md b/crates/hipfire-arch-qwen35/map.md index f27912845..ea268919d 100644 --- a/crates/hipfire-arch-qwen35/map.md +++ b/crates/hipfire-arch-qwen35/map.md @@ -42,7 +42,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | [`src/qwen35/ep_batch.rs`](src/qwen35/ep_batch.rs) | 4,800 | 20 | 7 | | [`src/qwen35/forward.rs`](src/qwen35/forward.rs) | 6,255 | 31 | 12 | | [`src/qwen35/load.rs`](src/qwen35/load.rs) | 4,906 | 10 | 0 | -| [`src/qwen35/prefill.rs`](src/qwen35/prefill.rs) | 9,325 | 11 | 49 | +| [`src/qwen35/prefill.rs`](src/qwen35/prefill.rs) | 9,381 | 11 | 49 | | [`src/qwen35/weights.rs`](src/qwen35/weights.rs) | 1,971 | 43 | 10 | | [`src/qwen35.rs`](src/qwen35.rs) | 63 | 7 | 0 | | [`src/scheduler.rs`](src/scheduler.rs) | 142 | 3 | 4 | @@ -97,6 +97,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 29 modules · 57,294 lines · 436 public items · 190 tests · 4 examples +- 29 modules · 57,350 lines · 436 public items · 190 tests · 4 examples diff --git a/crates/hipfire-arch-qwen35/src/qwen35/prefill.rs b/crates/hipfire-arch-qwen35/src/qwen35/prefill.rs index 01489eac9..9c65fb3b1 100644 --- a/crates/hipfire-arch-qwen35/src/qwen35/prefill.rs +++ b/crates/hipfire-arch-qwen35/src/qwen35/prefill.rs @@ -1544,8 +1544,10 @@ pub(crate) fn is_batchable_la(dt: DType, arch: &str) -> bool { // behind HIPFIRE_MQV2_GFX11_WMMA != "0" — setting // HIPFIRE_MQV2_GFX11_WMMA=0 restores the per-token fallback ONLY on // gfx11, leaving gfx12 untouched. Delegates to the shared - // `hipfire_runtime::llama::mqv2_wmma_batchable` rule so this stays in - // lockstep with `llama::is_batchable_la` structurally. Lockstep with + // `hipfire_runtime::llama::mqv2_wmma_batchable` rule (shared home for + // the dtype/arch/kill-switch set). NOTE: `llama::is_batchable_la` does + // NOT delegate to it — the llama chunk path has no V2 arms, so llama + // refuses V2 everywhere; only this qwen35 caller admits V2. Lockstep with // the HasWmma predicate on GemmMq*G256V2* keys and with // gemm_mq*g256v2's has_wmma() guard. // MQ4CG256 (qt45) remains gfx12-only until its gfx11 sibling lands. @@ -8222,15 +8224,39 @@ mod tests { #[test] fn mqv2_admit_llama_qwen35_lockstep() { - // Audit 2026-09-02 Broken 1: `llama::is_batchable_la` admitted MQ-V2 - // only on gfx12 while this module admitted gfx11+gfx12, despite both - // doc-comments claiming an exact match. Both now delegate to the - // shared `llama::mqv2_wmma_batchable` rule; this test iterates the - // MQ-V2 dtypes over gfx11, gfx12, and pre-WMMA arches and asserts the - // two gates agree. Both read `HIPFIRE_MQV2_GFX11_WMMA` from the - // environment identically, so equality holds in any env state - // without mutating globals. - let dts = [ + // True contract (PR #690 hw-gate regression): llama and qwen35 agree + // on every NON-V2 dtype, but for the V2 family they deliberately + // diverge — qwen35's `forward_prefill_chunk` has V2 dispatch arms + // (206 hits) so it admits V2 via the shared + // `llama::mqv2_wmma_batchable` rule, while llama's chunk path has no + // V2 arms (`qkv_is_mq`/`wo_is_mq`/`ffn_is_mq`/`w_down_is_mq` list + // only V1 dtypes) so `llama::is_batchable_la` refuses V2 everywhere + // and stays on per-token decode. Admitting V2 to the llama path + // would skip the FWHT rotate and run V1 `hfq4g256` launchers on V2 + // blobs — silently incoherent prefill. + // Non-V2 agreement across the 5-arch sample. + let non_v2 = [ + DType::MQ4G256, + DType::HFQ4G256, + DType::MQ6G256, + DType::MQ3G256, + DType::MFP4G32, + DType::Q8_0, + ]; + for dt in non_v2 { + for arch in ["gfx1100", "gfx1151", "gfx1201", "gfx1030", "gfx1010"] { + assert_eq!( + llama::is_batchable_la(dt, arch), + is_batchable_la(dt, arch), + "lockstep drift for {dt:?} on {arch}" + ); + } + } + // V2 divergence: qwen35 admits on gfx11/gfx12 (kill-switch at its + // default ON here — both gates read `HIPFIRE_MQV2_GFX11_WMMA` + // identically, so with the var unset gfx11 admits), refuses + // pre-WMMA; llama refuses on all 5 arches. + let v2 = [ DType::MQ4G256V2, DType::MQ6G256V2, DType::MQ5G256V2, @@ -8238,12 +8264,41 @@ mod tests { DType::MQ2G256V2, DType::MQ4CG256, ]; - for dt in dts { - for arch in ["gfx1100", "gfx1151", "gfx1201", "gfx1030", "gfx1010"] { - assert_eq!( - llama::is_batchable_la(dt, arch), - is_batchable_la(dt, arch), - "lockstep drift for {dt:?} on {arch}" + for dt in v2 { + for arch in ["gfx1100", "gfx1151"] { + // MQ4CG256 is gfx12-only by intent in BOTH callers. + if dt == DType::MQ4CG256 { + assert!( + !is_batchable_la(dt, arch), + "qwen35 must refuse {dt:?} on {arch}" + ); + } else { + assert!( + is_batchable_la(dt, arch), + "qwen35 should admit {dt:?} on {arch}" + ); + } + assert!( + !llama::is_batchable_la(dt, arch), + "llama must refuse {dt:?} on {arch}" + ); + } + assert!( + is_batchable_la(dt, "gfx1201"), + "qwen35 should admit {dt:?} on gfx1201" + ); + assert!( + !llama::is_batchable_la(dt, "gfx1201"), + "llama must refuse {dt:?} on gfx1201" + ); + for arch in ["gfx1030", "gfx1010"] { + assert!( + !is_batchable_la(dt, arch), + "qwen35 must refuse {dt:?} on {arch}" + ); + assert!( + !llama::is_batchable_la(dt, arch), + "llama must refuse {dt:?} on {arch}" ); } } @@ -8252,6 +8307,7 @@ mod tests { assert!(is_batchable_la(DType::MQ4G256V2, "gfx1201")); assert!(!is_batchable_la(DType::MQ4G256V2, "gfx1030")); assert!(!is_batchable_la(DType::MQ4CG256, "gfx1100")); + assert!(!llama::is_batchable_la(DType::MQ4G256V2, "gfx1201")); } #[test] diff --git a/crates/hipfire-runtime/map.md b/crates/hipfire-runtime/map.md index 1c8b741fe..e7ad6225b 100644 --- a/crates/hipfire-runtime/map.md +++ b/crates/hipfire-runtime/map.md @@ -53,7 +53,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | [`src/kv_backend.rs`](src/kv_backend.rs) | 129 | 1 | 7 | | [`src/kv_mode.rs`](src/kv_mode.rs) | 298 | 10 | 7 | | [`src/lib.rs`](src/lib.rs) | 80 | 55 | 0 | -| [`src/llama.rs`](src/llama.rs) | 8,793 | 85 | 42 | +| [`src/llama.rs`](src/llama.rs) | 8,786 | 85 | 42 | | [`src/llama_spec.rs`](src/llama_spec.rs) | 617 | 6 | 1 | | [`src/loader_api.rs`](src/loader_api.rs) | 256 | 10 | 4 | | [`src/loop_guard.rs`](src/loop_guard.rs) | 194 | 8 | 4 | @@ -156,6 +156,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 58 modules · 53,134 lines · 895 public items · 617 tests · 132 examples +- 58 modules · 53,127 lines · 895 public items · 617 tests · 132 examples diff --git a/crates/hipfire-runtime/src/llama.rs b/crates/hipfire-runtime/src/llama.rs index b16edb8e9..ece87326f 100644 --- a/crates/hipfire-runtime/src/llama.rs +++ b/crates/hipfire-runtime/src/llama.rs @@ -1841,9 +1841,9 @@ pub const PREFILL_MAX_BATCH: usize = 256; /// Kill-switch for the MQ-V2 (qt44 + neutral qt47-50) gfx11 WMMA prefill path: /// gfx12 (`gfx1200`/`gfx1201`) is always admitted, gfx11 /// (`gfx1100`/`gfx1101`/`gfx1102`/`gfx1150`/`gfx1151`) is admitted unless -/// `HIPFIRE_MQV2_GFX11_WMMA=0`, anything else is rejected. Single definition -/// shared by `llama::is_batchable_la` and `qwen35::is_batchable_la` so the two -/// stay in lockstep structurally instead of by matching comments. +/// `HIPFIRE_MQV2_GFX11_WMMA=0`, anything else is rejected. Defined here as +/// the shared home for the qwen35 caller (`qwen35::is_batchable_la`); +/// `llama::is_batchable_la` does NOT delegate to it (see below). /// `value` is the raw env var (None = unset → default ON); only `Some("0")` /// disables the gfx11 path. Gfx12 is unaffected by the env var. pub fn mqv2_gfx11_wmma_enabled_from_env(value: Option<&str>, arch: &str) -> bool { @@ -1862,9 +1862,11 @@ pub fn mqv2_gfx11_wmma_enabled_from_env(value: Option<&str>, arch: &str) -> bool /// Admit rule for the MQ-V2 family (`MQ4G256V2` + neutral `MQ6/5/3/2G256V2`) /// in batched WMMA prefill: dtype set × arch set × the -/// `HIPFIRE_MQV2_GFX11_WMMA` kill-switch in one function. Both -/// `llama::is_batchable_la` and `qwen35::is_batchable_la` delegate here; -/// `MQ4CG256` (qt45) stays gfx12-only in both callers and is intentionally +/// `HIPFIRE_MQV2_GFX11_WMMA` kill-switch in one function. Only +/// `qwen35::is_batchable_la` delegates here — qwen35's +/// `forward_prefill_chunk` has V2 dispatch arms, while the llama chunk path +/// does not (see `llama::is_batchable_la`). +/// `MQ4CG256` (qt45) stays gfx12-only in its caller and is intentionally /// NOT part of this rule. pub fn mqv2_wmma_batchable(dt: DType, mqv2_gfx11_wmma: Option<&str>, arch: &str) -> bool { matches!( @@ -1878,9 +1880,8 @@ pub fn mqv2_wmma_batchable(dt: DType, mqv2_gfx11_wmma: Option<&str>, arch: &str) } /// Is this dtype/arch combination eligible for the batched WMMA prefill -/// kernels? Shares the MQ-V2 admit rule with `qwen35::is_batchable_la` via -/// `mqv2_wmma_batchable`, so plain Qwen3 and hybrid Qwen3.5 stay in lockstep -/// structurally when new dtypes or arches gain WMMA support. +/// kernels? NOTE: unlike `qwen35::is_batchable_la`, this does NOT admit the +/// MQ-V2 family — see the `never_v2` refusal below. pub fn is_batchable_la(dt: DType, arch: &str) -> bool { let always_ok = matches!( dt, @@ -1913,24 +1914,30 @@ pub fn is_batchable_la(dt: DType, arch: &str) -> bool { arch, "gfx1010" | "gfx1011" | "gfx1012" | "gfx1013" | "gfx1030" | "gfx1031" | "gfx1032" ); - // MQ-V2 family (qt44 + neutral qt47-50) batched prefill + batched lm_head - // GEMM: gfx11 + gfx12 via the shared rule, with the gfx11 half behind the - // `HIPFIRE_MQV2_GFX11_WMMA=0` kill-switch. Delegates to - // `mqv2_wmma_batchable` so this stays in lockstep with - // `qwen35::is_batchable_la` structurally, not by matching comments. - // Outside the admitted arches, fall back to per-token decode rather than - // dispatching a foreign-arch WMMA kernel. - let mq4_v2 = mqv2_wmma_batchable( + // MQ-V2 family (`MQ4G256V2` + neutral `MQ6/5/3/2G256V2`, qt44/qt47-50) + // plus `MQ4CG256` (qt45): REFUSED on every arch. `forward_prefill_chunk` + // has no V2 arms — its per-layer dtype matchers (`qkv_is_mq` ~:2570, + // `wo_is_mq` ~:3025, `ffn_is_mq` ~:3117, `w_down_is_mq` ~:3248) list only + // `MQ4G256|MQ6G256|MQ3G256|MFP4G32`, so an admitted V2 model would skip + // the FWHT rotate and run the V1 `hfq4g256` launchers + // (`gemm_qkv_hfq4g256`, `gemm_hfq4g256_residual`, `gemm_gate_up_hfq4g256`) + // on V2 blobs — silently incoherent prefill. Per-token decode is the + // only correct llama path for V2 until those arms exist. qwen35's chunk + // path DOES have the V2 arms, so `qwen35::is_batchable_la` keeps + // admitting V2 via the shared `mqv2_wmma_batchable` rule above. + let never_v2 = matches!( dt, - hipfire_config::developer_var("HIPFIRE_MQV2_GFX11_WMMA") - .ok() - .as_deref(), - arch, + DType::MQ4G256V2 + | DType::MQ6G256V2 + | DType::MQ5G256V2 + | DType::MQ3G256V2 + | DType::MQ2G256V2 + | DType::MQ4CG256 ); - // MQ4CG256 (qt45) remains gfx12-only until its gfx11 sibling lands — - // intentionally not part of the shared rule, in both callers. - let mq4cg256_gfx12 = matches!(dt, DType::MQ4CG256) && matches!(arch, "gfx1200" | "gfx1201"); - wmma_only || mq3_gfx10_scalar || mq4_v2 || mq4cg256_gfx12 + if never_v2 { + return false; + } + wmma_only || mq3_gfx10_scalar } /// Per-call scratch for `forward_prefill_batch`. Holds [N × ...] working @@ -8289,32 +8296,22 @@ mod tests { } #[test] - fn is_batchable_la_mq4_v2_gfx11_and_gfx12() { - // MQ4G256V2 batched prefill admits gfx11 + gfx12 through the shared - // `mqv2_wmma_batchable` rule (gfx11 behind HIPFIRE_MQV2_GFX11_WMMA); - // MQ4CG256 stays gfx12-only. Lockstep with - // `qwen35::is_batchable_la` by construction. + fn is_batchable_la_mq4_v2_refused_everywhere() { + // `forward_prefill_chunk` has no V2 arms (its `qkv_is_mq` ~:2570, + // `wo_is_mq` ~:3025, `ffn_is_mq` ~:3117, `w_down_is_mq` ~:3248 + // matchers list only V1 dtypes), so llama must refuse MQ4G256V2 and + // MQ4CG256 on EVERY arch — including gfx11/gfx12 — and stay on + // per-token decode. qwen35's chunk path has the arms and keeps the + // shared `mqv2_wmma_batchable` rule; see + // `qwen35_is_batchable_la_mq4_v2_gfx11_and_gfx12` for the admit side. for arch in [ "gfx1100", "gfx1101", "gfx1102", "gfx1150", "gfx1151", "gfx1200", "gfx1201", + "gfx1010", "gfx1030", "gfx942", ] { - assert!( - is_batchable_la(DType::MQ4G256V2, arch), - "MQ4G256V2 should batch on {arch}" - ); - } - for arch in ["gfx1200", "gfx1201"] { - assert!( - is_batchable_la(DType::MQ4CG256, arch), - "MQ4CG256 should batch on {arch}" - ); - } - for arch in ["gfx1010", "gfx1030", "gfx942"] { assert!( !is_batchable_la(DType::MQ4G256V2, arch), "MQ4G256V2 must fall back on {arch}" ); - } - for arch in ["gfx1010", "gfx1100", "gfx1151", "gfx942"] { assert!( !is_batchable_la(DType::MQ4CG256, arch), "MQ4CG256 must fall back on {arch}" @@ -8323,23 +8320,19 @@ mod tests { } #[test] - fn is_batchable_la_v2_family_gfx11_and_gfx12() { - // Neutral V2 family (qt47-50) admits gfx11 + gfx12 through the shared - // `mqv2_wmma_batchable` rule, mirroring - // `qwen35_is_batchable_la_v2_family_gfx11_and_gfx12`. + fn is_batchable_la_v2_family_refused_everywhere() { + // Neutral V2 family (qt47-50): same refusal as MQ4G256V2 — no V2 arms + // in the llama chunk path, so refuse on every arch (including + // gfx11/gfx12). Mirrors `qwen35_is_batchable_la_v2_family_gfx11_and_gfx12` + // on the admit side. for arch in [ "gfx1100", "gfx1101", "gfx1102", "gfx1150", "gfx1151", "gfx1200", "gfx1201", + "gfx1010", "gfx1030", "gfx942", ] { - assert!(is_batchable_la(DType::MQ6G256V2, arch), "MQ6V2 on {arch}"); - assert!(is_batchable_la(DType::MQ5G256V2, arch), "MQ5V2 on {arch}"); - assert!(is_batchable_la(DType::MQ3G256V2, arch), "MQ3V2 on {arch}"); - assert!(is_batchable_la(DType::MQ2G256V2, arch), "MQ2V2 on {arch}"); - } - for arch in ["gfx1010", "gfx1030", "gfx942"] { - assert!(!is_batchable_la(DType::MQ6G256V2, arch), "MQ6V2 fallback"); - assert!(!is_batchable_la(DType::MQ5G256V2, arch), "MQ5V2 fallback"); - assert!(!is_batchable_la(DType::MQ3G256V2, arch), "MQ3V2 fallback"); - assert!(!is_batchable_la(DType::MQ2G256V2, arch), "MQ2V2 fallback"); + assert!(!is_batchable_la(DType::MQ6G256V2, arch), "MQ6V2 fallback on {arch}"); + assert!(!is_batchable_la(DType::MQ5G256V2, arch), "MQ5V2 fallback on {arch}"); + assert!(!is_batchable_la(DType::MQ3G256V2, arch), "MQ3V2 fallback on {arch}"); + assert!(!is_batchable_la(DType::MQ2G256V2, arch), "MQ2V2 fallback on {arch}"); } assert_ne!(DType::MQ6G256, DType::MQ6G256V2); assert_ne!(DType::MQ3G256, DType::MQ3G256V2); diff --git a/docs/quant-formats/mq4-v2.md b/docs/quant-formats/mq4-v2.md index 9374d3e0b..3943cc21e 100644 --- a/docs/quant-formats/mq4-v2.md +++ b/docs/quant-formats/mq4-v2.md @@ -421,7 +421,7 @@ relatively less of the damage. "Codebooks are for the sub-4-bit tier" is defensi | **qt=45** batched prefill WMMA GEMM + batched lm_head GEMM | **gfx12-only** (`HasWmmaGfx12`); **not** admitted on gfx11 | | Unsupported batched prefill | **per-token decode fallback** (does not dispatch a foreign-arch WMMA kernel) | | FusedQkv / FusedGateUp decode registrations | remain **cross-arch** (not narrowed to gfx12) | -| Prefill LA admission (qt=44) | gfx1100/1101/1102/1150/1151 + gfx1200/1201; gfx11 opt-out `HIPFIRE_MQV2_GFX11_WMMA=0` | +| Prefill LA admission (qt=44) | **qwen35 only**: gfx1100/1101/1102/1150/1151 + gfx1200/1201; gfx11 opt-out `HIPFIRE_MQV2_GFX11_WMMA=0`. Plain-Llama dense (`llama::is_batchable_la`) **refuses V2 on every arch** (per-token decode) until `forward_prefill_chunk` grows V2 arms | | Exact parity examples | `mq4v2_parity`, `mq4v2_gemm_parity`, `mq4v2_fused_parity`, `mq4v2_residual_parity`, `mq4c_parity`; BT screens `test_mq4v2_*_bt_gfx{1100,1151,1201}.rs` | | Qwen3.8 fixture-bound KLD | qt=44 `ctl` WT2 0.039033 / v6 0.544517; `ctl2` WT2 0.032495; `attn` WT2 0.025437 (§ 5) | | gfx1010 | scalar fused decode TUs compile; batched prefill falls back as above | From 136e3f06ff748dff56ca083c26adee4ab7124ded Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Fri, 4 Sep 2026 17:50:13 +0000 Subject: [PATCH 18/34] fix(dflash): new_windowed parks each allocation in the scratch so a mid-ladder failure frees all of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hw-gate Fable seat on #691 (run 33900101473): alloc_or_free! freed the base scratch on failure but the four already-allocated tensors were still locals with no Drop — a failure on the 2nd..5th alloc leaked k_full / v_full / k_cat / v_cat. They are now assigned into s as each succeeds, so the error arm's s.free_gpu covers the whole ladder. Same shape as new_with_mq's at!/live list. --- crates/hipfire-runtime/src/dflash.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/hipfire-runtime/src/dflash.rs b/crates/hipfire-runtime/src/dflash.rs index d0f38807c..3d6db4ba0 100644 --- a/crates/hipfire-runtime/src/dflash.rs +++ b/crates/hipfire-runtime/src/dflash.rs @@ -1477,6 +1477,12 @@ impl DflashScratch { // it before returning Err — a bare `?` would leak it (no `Drop` on // the GPU-owning types), including when the failure follows the pop // above. Same class as the `or_free!` sites in `load_dflash_state`. + // + // Each allocation is parked in `s` the moment it succeeds, so the + // error arm's `s.free_gpu` also covers every earlier allocation of + // this ladder: with the tensors held as locals until the end, a + // failure on the 2nd..5th alloc freed `s` but leaked the locals + // (hw-gate Fable seat on #691, run 33900101473). macro_rules! alloc_or_free { ($e:expr) => { match $e { @@ -1488,19 +1494,15 @@ impl DflashScratch { } }; } - let k_full = alloc_or_free!(gpu.alloc_tensor(&[w_full * kvd], DType::F32)); - let v_full = alloc_or_free!(gpu.alloc_tensor(&[w_full * kvd], DType::F32)); - let k_cat = alloc_or_free!(gpu.alloc_tensor(&[(w_full + b) * kvd], DType::F32)); - let v_cat = alloc_or_free!(gpu.alloc_tensor(&[(w_full + b) * kvd], DType::F32)); + s.k_full_cached = Some(alloc_or_free!(gpu.alloc_tensor(&[w_full * kvd], DType::F32))); + s.v_full_cached = Some(alloc_or_free!(gpu.alloc_tensor(&[w_full * kvd], DType::F32))); + s.k_cat_full = Some(alloc_or_free!(gpu.alloc_tensor(&[(w_full + b) * kvd], DType::F32))); + s.v_cat_full = Some(alloc_or_free!(gpu.alloc_tensor(&[(w_full + b) * kvd], DType::F32))); // positions_k holds the last w_full context rows + the B noise rows // (the forward uploads only that suffix; every layer's span is one). // Allocate before freeing the old buffer so a failure still leaves // `s` intact for the error arm above. let new_positions_k = alloc_or_free!(gpu.alloc_tensor(&[w_full + b], DType::F32)); - s.k_full_cached = Some(k_full); - s.v_full_cached = Some(v_full); - s.k_cat_full = Some(k_cat); - s.v_cat_full = Some(v_cat); let _ = gpu.free_tensor(std::mem::replace(&mut s.positions_k, new_positions_k)); // The ctx bound is the target's physical capacity, not the window — // l may cross w_full (the last layer's span just slides). From 31dd5e306c609bb91e5c4e8eac6df70c397395f6 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Fri, 4 Sep 2026 18:20:26 +0000 Subject: [PATCH 19/34] chore: refresh hipfire-runtime crate map (check-crate-maps --write) --- crates/hipfire-runtime/map.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/hipfire-runtime/map.md b/crates/hipfire-runtime/map.md index e7ad6225b..c38dac46a 100644 --- a/crates/hipfire-runtime/map.md +++ b/crates/hipfire-runtime/map.md @@ -38,8 +38,8 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | [`src/cpu_router.rs`](src/cpu_router.rs) | 200 | 4 | 3 | | [`src/ddtree.rs`](src/ddtree.rs) | 2,046 | 17 | 24 | | [`src/device_mesh.rs`](src/device_mesh.rs) | 582 | 21 | 8 | -| [`src/dflash.rs`](src/dflash.rs) | 3,460 | 44 | 4 | -| [`src/dflash_generic.rs`](src/dflash_generic.rs) | 1,365 | 3 | 13 | +| [`src/dflash.rs`](src/dflash.rs) | 3,657 | 44 | 4 | +| [`src/dflash_generic.rs`](src/dflash_generic.rs) | 1,382 | 3 | 13 | | [`src/dspark_block_controller.rs`](src/dspark_block_controller.rs) | 442 | 0 | 10 | | [`src/dspark_core.rs`](src/dspark_core.rs) | 1,773 | 11 | 0 | | [`src/emit_text.rs`](src/emit_text.rs) | 1,702 | 20 | 39 | @@ -156,6 +156,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 58 modules · 53,127 lines · 895 public items · 617 tests · 132 examples +- 58 modules · 53,341 lines · 895 public items · 617 tests · 132 examples From 86789291ba65d70293b2e5a8b4176dd6dd7ca31b Mon Sep 17 00:00:00 2001 From: Kaden Schutt <151092359+Kaden-Schutt@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:06:49 +0000 Subject: [PATCH 20/34] feat(registry): pair DFlash drafts as dflash sidecars (audit-Dflash Broken 1) ModelEntry gains #[serde(default)] dflash: Option alongside mtp/dspark; every dense qwen3.5:9b/27b, qwen3.6:27b and qwen3.8:27b quant-variant entry declares the draft file its -draft alias resolves to (same-bit draft where one exists, else the mq4 controller). hipfire pull/rm now fetch/remove the dflash sidecar like MTP/DSpark. registry_gen annotates dflash with sha256/size_bytes; v1.json was mirrored by hand (offline) to exactly what the generator would emit. --- crates/hipfire-cli/src/main.rs | 3 +- crates/hipfire-registry/src/lib.rs | 28 +++++++- registry/models.json | 70 ++++++++++++++++++-- registry/v1.json | 100 +++++++++++++++++++++++++++++ scripts/registry_gen.py | 6 +- 5 files changed, 197 insertions(+), 10 deletions(-) diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index 86764ac22..a59175f49 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -1618,6 +1618,7 @@ pub(crate) fn pull_command(paths: &Paths, args: PullArgs) -> Result<()> { ("TriAttention", entry.triattn.as_ref()), ("MTP", entry.mtp.as_ref()), ("DSpark", entry.dspark.as_ref()), + ("DFlash", entry.dflash.as_ref()), ] { let Some(sidecar) = sidecar else { continue; @@ -1779,7 +1780,7 @@ fn rm_command(paths: &Paths, args: RmArgs) -> Result<()> { let mut targets = BTreeSet::from([path.clone()]); if let Some((_, entry)) = resolved { targets.extend( - [&entry.triattn, &entry.mtp, &entry.dspark] + [&entry.triattn, &entry.mtp, &entry.dspark, &entry.dflash] .into_iter() .flatten() .map(|sidecar| paths.models.join(&sidecar.file)) diff --git a/crates/hipfire-registry/src/lib.rs b/crates/hipfire-registry/src/lib.rs index bed694249..3f8608a2a 100644 --- a/crates/hipfire-registry/src/lib.rs +++ b/crates/hipfire-registry/src/lib.rs @@ -188,6 +188,8 @@ pub struct ModelEntry { #[serde(default)] pub dspark: Option, #[serde(default)] + pub dflash: Option, + #[serde(default)] pub default_tool_format: Option, #[serde(default)] pub default_kv_mode: Option, @@ -364,7 +366,7 @@ impl RegistryV1 { return Err(fail(format!("model '{tag}' has invalid size metadata"))); } validate_digest(entry.sha256.as_deref(), tag).map_err(fail)?; - for sidecar in [&entry.triattn, &entry.mtp, &entry.dspark] + for sidecar in [&entry.triattn, &entry.mtp, &entry.dspark, &entry.dflash] .into_iter() .flatten() { @@ -1028,6 +1030,30 @@ mod tests { "effort-native: absence means uncapped" ); } + #[test] + fn bundled_dflash_sidecars_name_pullable_files() { + // Every `dflash.file` must name a file that some registry entry's + // `file` also names, so `hipfire pull ` fetching the sidecar + // always lands a file that `hipfire pull -draft` could fetch too. + let registry = bundled().unwrap(); + let files: std::collections::BTreeSet<&str> = registry + .models + .values() + .map(|entry| entry.file.as_str()) + .collect(); + let mut paired = 0; + for (tag, entry) in ®istry.models { + if let Some(sidecar) = entry.dflash.as_ref() { + assert!( + files.contains(sidecar.file.as_str()), + "model '{tag}' declares dflash sidecar '{}' with no matching entry file", + sidecar.file + ); + paired += 1; + } + } + assert!(paired > 0, "bundled registry should pair at least one dflash sidecar"); + } #[test] fn aliases_and_filenames_resolve_to_canonical_tags() { diff --git a/registry/models.json b/registry/models.json index d18e5332e..41e186411 100644 --- a/registry/models.json +++ b/registry/models.json @@ -102,6 +102,9 @@ "presence_penalty": 1.5, "repeat_penalty": 1.0 } + }, + "dflash": { + "file": "qwen35-9b-dflash-mq4.hfq" } }, "qwen3.5:27b": { @@ -144,6 +147,9 @@ "presence_penalty": 1.5, "repeat_penalty": 1.0 } + }, + "dflash": { + "file": "qwen35-27b-dflash-mq4.hfq" } }, "qwen3.5:35b-a3b": { @@ -697,6 +703,9 @@ "desc": "44 tok/s AR / 185 tok/s w/ draft on code", "triattn": { "file": "qwen3.6-27b.mq4.triattn.blended_v3.bin" + }, + "dflash": { + "file": "qwen36-27b-dflash-mq4.hfq" } }, "qwen3.8:27b-mq3-xt": { @@ -725,6 +734,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq3.hfq" } }, "qwen3.8:27b-mq3": { @@ -753,6 +765,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq3.hfq" } }, "qwen3.8:27b-mq3-pro": { @@ -781,6 +796,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq3.hfq" } }, "qwen3.8:27b-mq4-xt": { @@ -809,6 +827,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq4.hfq" } }, "qwen3.8:27b": { @@ -837,6 +858,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq4.hfq" } }, "qwen3.8:27b-mq4-pro": { @@ -865,6 +889,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq4.hfq" } }, "qwen3.8:27b-mq5-xt": { @@ -893,6 +920,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq5.hfq" } }, "qwen3.8:27b-mq5": { @@ -921,6 +951,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq5.hfq" } }, "qwen3.8:27b-mq5-pro": { @@ -949,6 +982,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq5.hfq" } }, "qwen3.8:27b-mq6-xt": { @@ -977,6 +1013,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq6.hfq" } }, "qwen3.8:27b-mq6": { @@ -1005,6 +1044,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq6.hfq" } }, "qwen3.8:27b-mq6-pro": { @@ -1033,6 +1075,9 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } + }, + "dflash": { + "file": "qwen38-27b-dflash-mq6.hfq" } }, "qwen3.5:0.8b-mq6": { @@ -1060,14 +1105,20 @@ "min_vram_gb": 8.8, "desc": "MQ6, higher quality", "default_kv_mode": "q8", - "quant_recipe": "v3-awq-f1" + "quant_recipe": "v3-awq-f1", + "dflash": { + "file": "qwen35-9b-dflash-mq4.hfq" + } }, "qwen3.5:27b-mq6": { "repo": "hipfire-models/qwen3.5-27b", "file": "qwen3.5-27b.mq6", "size_gb": 21.4, "min_vram_gb": 24, - "desc": "MQ6, higher quality" + "desc": "MQ6, higher quality", + "dflash": { + "file": "qwen35-27b-dflash-mq4.hfq" + } }, "qwen3.5:9b-mq3": { "repo": "hipfire-models/qwen3.5-9b", @@ -1076,14 +1127,20 @@ "min_vram_gb": 6.1, "desc": "MQ3 alpha (3.25 bpw, gfx11/gfx12). Smaller than MQ4, comparable decode. Quality eval pending — see issue #113. Sub-9B MQ3 not shipped — see #114.", "default_kv_mode": "q8", - "quant_recipe": "v3-awq-f1" + "quant_recipe": "v3-awq-f1", + "dflash": { + "file": "qwen35-9b-dflash-mq4.hfq" + } }, "qwen3.5:27b-mq3": { "repo": "hipfire-models/qwen3.5-27b", "file": "qwen3.5-27b.mq3", "size_gb": 10.7, "min_vram_gb": 12, - "desc": "MQ3 alpha — fits 128K asym3 ctx on 24 GB (MQ4 OOMs at ~98K). gfx11/gfx12 only." + "desc": "MQ3 alpha — fits 128K asym3 ctx on 24 GB (MQ4 OOMs at ~98K). gfx11/gfx12 only.", + "dflash": { + "file": "qwen35-27b-dflash-mq3.hfq" + } }, "qwen3.6:27b-mq3": { "recommended_settings": { @@ -1096,7 +1153,10 @@ "file": "qwen3.6-27b.mq3", "size_gb": 10.7, "min_vram_gb": 12, - "desc": "MQ3 alpha — fits 128K asym3 ctx on 24 GB. Pairs well with mq4 DFlash draft (126 tok/s τ=7.0)." + "desc": "MQ3 alpha — fits 128K asym3 ctx on 24 GB. Pairs well with mq4 DFlash draft (126 tok/s τ=7.0).", + "dflash": { + "file": "qwen36-27b-dflash-mq3.hfq" + } }, "qwen3.5:27b-draft-mq3": { "repo": "hipfire-models/qwen3.5-27b", diff --git a/registry/v1.json b/registry/v1.json index 65aa9982a..495e6743f 100644 --- a/registry/v1.json +++ b/registry/v1.json @@ -113,6 +113,11 @@ "repeat_penalty": 1.0 } }, + "dflash": { + "file": "qwen35-9b-dflash-mq4.hfq", + "sha256": "97457f684b8f0b195e631025a63da0a92286d1c77751e58caf0cd470457a6b25", + "size_bytes": 557265920 + }, "sha256": "ba83acf5bfd5d4e334b0afc26d779734e31623bb7f74e807c3581dfecb3128ad", "size_bytes": 5313750016, "arch_id": 5, @@ -159,6 +164,11 @@ "repeat_penalty": 1.0 } }, + "dflash": { + "file": "qwen35-27b-dflash-mq4.hfq", + "sha256": "3d428b97c1911a9ad815cc52fbee080306852c1dafad6b1b17bb70bd68010301", + "size_bytes": 919401472 + }, "sha256": "ea615949ddf6a180eee03ff6fde39f7e51148f153b1b05f82258b9953088576e", "size_bytes": 14984158208, "arch_id": 5, @@ -792,6 +802,11 @@ "sha256": "d6cb8026841830cfeb82d2709453aa753f65b5596bfb9cc9c085c808fda6ad22", "size_bytes": 2359324 }, + "dflash": { + "file": "qwen36-27b-dflash-mq4.hfq", + "sha256": "bd8c4f07ae80fe1385bf2606af9a7ba0daa18ca8daec50916f2a489054c44e70", + "size_bytes": 919401472 + }, "sha256": "86a5f80fd29d545abb1093dead242725ced6d68b8607c6d566d897b1a82442dc", "size_bytes": 14984158208, "arch_id": 5, @@ -824,6 +839,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq3.hfq", + "sha256": "dd335fb2634c371d120077c2fe754643c91b27f32d0c32668278fe4b8f1ca319", + "size_bytes": 984978432 + }, "sha256": "3e04fc8db80bda557b965ec60ac876cf2500fced7f340624f3fcbeae134af5c5", "size_bytes": 11777616896, "arch_id": 5, @@ -856,6 +876,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq3.hfq", + "sha256": "dd335fb2634c371d120077c2fe754643c91b27f32d0c32668278fe4b8f1ca319", + "size_bytes": 984978432 + }, "sha256": "09c3544690aceca29e1822d79adab6ffcc8fd9e4b58359fe8dfb185ef49811c9", "size_bytes": 12618796032, "arch_id": 5, @@ -888,6 +913,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq3.hfq", + "sha256": "dd335fb2634c371d120077c2fe754643c91b27f32d0c32668278fe4b8f1ca319", + "size_bytes": 984978432 + }, "sha256": "394c50966bf4f68172df8eb34cd7ded8f9d0576c9ef24ea6ba639a88c184f795", "size_bytes": 13184433152, "arch_id": 5, @@ -920,6 +950,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq4.hfq", + "sha256": "d0a74a232a0e2166d889f823e91e0fbf778d21dd9668d7de055cdecb065401bc", + "size_bytes": 1209603072 + }, "sha256": "9f91556f7e0431a077d03756a7102d0154108757289e6e5fe9a2d204c0c9eeb7", "size_bytes": 14980361216, "arch_id": 5, @@ -952,6 +987,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq4.hfq", + "sha256": "d0a74a232a0e2166d889f823e91e0fbf778d21dd9668d7de055cdecb065401bc", + "size_bytes": 1209603072 + }, "sha256": "5bb556a6cc84035234995c017c9791aa3951ad1eae4cf8c8172b0eaef399e507", "size_bytes": 15662615552, "arch_id": 5, @@ -984,6 +1024,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq4.hfq", + "sha256": "d0a74a232a0e2166d889f823e91e0fbf778d21dd9668d7de055cdecb065401bc", + "size_bytes": 1209603072 + }, "sha256": "e6f2ac87042b9e314c323f00bc499a6304cd5624021ae501ce90b12a3a7ea3fa", "size_bytes": 16464182272, "arch_id": 5, @@ -1016,6 +1061,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq5.hfq", + "sha256": "8a8d3daeaa3788743ef9aedfa1a6cd9961395ecbd88e9b5e2eb981e0506a861f", + "size_bytes": 1434227712 + }, "sha256": "f4760c159f80d5d3ca237593a191dd553790cba0f89de59827f430b23109b39b", "size_bytes": 18183105536, "arch_id": 5, @@ -1048,6 +1098,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq5.hfq", + "sha256": "8a8d3daeaa3788743ef9aedfa1a6cd9961395ecbd88e9b5e2eb981e0506a861f", + "size_bytes": 1434227712 + }, "sha256": "c018a0d7510bffbb3788844d5d8f72694464244e16f128b7e34804045324cf25", "size_bytes": 18706435072, "arch_id": 5, @@ -1080,6 +1135,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq5.hfq", + "sha256": "8a8d3daeaa3788743ef9aedfa1a6cd9961395ecbd88e9b5e2eb981e0506a861f", + "size_bytes": 1434227712 + }, "sha256": "7a46204f5ce16b260ebb028359a945c8c02f5abe87c040df063db782a99ee7cd", "size_bytes": 19319258112, "arch_id": 5, @@ -1112,6 +1172,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq6.hfq", + "sha256": "d190ef2faa953252ac40e9706cf2c6763d095f7817c2c7a0466792fd6dfa9015", + "size_bytes": 1658852352 + }, "sha256": "9d472ddc5b4e11a1986bfc83c54c0dac979a8e1d7186613dd7c7436f69ec8b2f", "size_bytes": 21385849856, "arch_id": 5, @@ -1144,6 +1209,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq6.hfq", + "sha256": "d190ef2faa953252ac40e9706cf2c6763d095f7817c2c7a0466792fd6dfa9015", + "size_bytes": 1658852352 + }, "sha256": "b798ea1166fc03a568f6daf8090b20b7d6314af7429951c03d86540385568db8", "size_bytes": 21750254592, "arch_id": 5, @@ -1176,6 +1246,11 @@ "reasoning_effort": "xhigh" } }, + "dflash": { + "file": "qwen38-27b-dflash-mq6.hfq", + "sha256": "d190ef2faa953252ac40e9706cf2c6763d095f7817c2c7a0466792fd6dfa9015", + "size_bytes": 1658852352 + }, "sha256": "58ac3ee645ede2bad2f6f833db1d4abbfdc1850047fdadef036d35a023bb9401", "size_bytes": 22174333952, "arch_id": 5, @@ -1215,6 +1290,11 @@ "desc": "MQ6, higher quality", "default_kv_mode": "q8", "quant_recipe": "v3-awq-f1", + "dflash": { + "file": "qwen35-9b-dflash-mq4.hfq", + "sha256": "97457f684b8f0b195e631025a63da0a92286d1c77751e58caf0cd470457a6b25", + "size_bytes": 557265920 + }, "sha256": "69b0e3b2be99a7fcab17f82bae2a2f1342ac32ee96d421347726815a69e78ce4", "size_bytes": 7296132096, "arch_id": 5, @@ -1226,6 +1306,11 @@ "size_gb": 21.4, "min_vram_gb": 24, "desc": "MQ6, higher quality", + "dflash": { + "file": "qwen35-27b-dflash-mq4.hfq", + "sha256": "3d428b97c1911a9ad815cc52fbee080306852c1dafad6b1b17bb70bd68010301", + "size_bytes": 919401472 + }, "sha256": "08215e22b04dbe19706d9783c35e54ea92154090c0eca84aeaa71e2041eedd5a", "size_bytes": 21385292800, "arch_id": 5, @@ -1239,6 +1324,11 @@ "desc": "MQ3 alpha (3.25 bpw, gfx11/gfx12). Smaller than MQ4, comparable decode. Quality eval pending \u2014 see issue #113. Sub-9B MQ3 not shipped \u2014 see #114.", "default_kv_mode": "q8", "quant_recipe": "v3-awq-f1", + "dflash": { + "file": "qwen35-9b-dflash-mq4.hfq", + "sha256": "97457f684b8f0b195e631025a63da0a92286d1c77751e58caf0cd470457a6b25", + "size_bytes": 557265920 + }, "sha256": "c379dbbc90d7faf5e7281f01310b4e3f3e76587a6e951a0c7b6a809eeae5550b", "size_bytes": 4569785344, "arch_id": 5, @@ -1250,6 +1340,11 @@ "size_gb": 10.7, "min_vram_gb": 12, "desc": "MQ3 alpha \u2014 fits 128K asym3 ctx on 24 GB (MQ4 OOMs at ~98K). gfx11/gfx12 only.", + "dflash": { + "file": "qwen35-27b-dflash-mq3.hfq", + "sha256": "b3526f2f8ffce5a2483f6d74fd1e78af2b8eebe9249e5b55177ed0e0c58f53fc", + "size_bytes": 703132672 + }, "sha256": "58fdaf54ec3c4be9372bd44b4754e198e7ff6d7129c6abdaec76c1ac70fb092b", "size_bytes": 11784735744, "arch_id": 5, @@ -1267,6 +1362,11 @@ "size_gb": 10.7, "min_vram_gb": 12, "desc": "MQ3 alpha \u2014 fits 128K asym3 ctx on 24 GB. Pairs well with mq4 DFlash draft (126 tok/s \u03c4=7.0).", + "dflash": { + "file": "qwen36-27b-dflash-mq3.hfq", + "sha256": "f5fc4bef4c15229f940f8ead6c04723b04308321c4a9584c3318d364cadf1728", + "size_bytes": 703132672 + }, "sha256": "6b650a7d6cb4e2447dd67da7a87cb5e57956e98eff676a498a7586e3b3cc9298", "size_bytes": 11776322560, "arch_id": 5, diff --git a/scripts/registry_gen.py b/scripts/registry_gen.py index eef423544..069f795a3 100644 --- a/scripts/registry_gen.py +++ b/scripts/registry_gen.py @@ -12,7 +12,7 @@ purely additive: top-level : schema_version, generated_at per-entry : sha256 (HF LFS oid), size_bytes, arch_id, quant - sidecars : triattn/mtp gain sha256/size_bytes next to their `file` + sidecars : triattn/mtp/dflash gain sha256/size_bytes next to their `file` Fail-closed: ANY problem — repo unreachable, file missing from the repo tree, file not LFS (no sha256), size_bytes disagreeing with curated @@ -360,7 +360,7 @@ def is_strict_superset(old: object, new: object, path: str, errors: list[str]) - def annotate_sidecar( sidecar: dict, tree: dict[str, dict], tag: str, kind: str, errors: list[str] ) -> dict: - """triattn/mtp sub-object: require existence, add sha256/size_bytes if LFS.""" + """triattn/mtp/dflash sub-object: require existence, add sha256/size_bytes if LFS.""" out = dict(sidecar) fname = sidecar.get("file", "") item = tree.get(fname) @@ -482,7 +482,7 @@ def build_registry(curated: dict, token: str | None) -> tuple[dict | None, list[ f"HF {size_bytes / 1e9:.2f} GB ({drift:.0%} drift); " f"update registry/models.json" ) - for kind in ("triattn", "mtp"): + for kind in ("triattn", "mtp", "dflash"): if isinstance(entry.get(kind), dict): new_entry[kind] = annotate_sidecar(entry[kind], tree, tag, kind, errors) # repo probe already failed → error recorded above; entry still gets From 351c326e00cc9288d9a29f260b6415752888b6fe Mon Sep 17 00:00:00 2001 From: Kaden Schutt <151092359+Kaden-Schutt@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:13:37 +0000 Subject: [PATCH 21/34] feat(load): resolve registry dflash sidecar; on fails closed (audit-Dflash Broken 1) load_params wires entry.dflash into params[draft] when dflash_mode is auto/on and no explicit draft was given (--model-draft, developer.dflash_draft, params[draft] all win): on without the pulled file bails with a 'hipfire pull ' hint, auto logs one line and runs AR. run/bench re-resolve after the final CLI selector so config-off + --spec dflash still pairs; serve inherits it via load_params. Final off still strips the draft. The daemon lowers dflash_mode into SpecLoadCfg.dflash and the Qwen35 draft-load failure arm returns a load error (with bundle rollback, mirroring MTP) instead of AR fallback when on. --- crates/hipfire-cli/src/main.rs | 299 ++++++++++++++++++++++- crates/hipfire-cli/src/serve/mod.rs | 2 + crates/hipfire-daemon/src/main.rs | 9 + crates/hipfire-loader/src/lib.rs | 10 + crates/hipfire-runtime/src/loader_api.rs | 5 + 5 files changed, 316 insertions(+), 9 deletions(-) diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index a59175f49..dd1b67ba3 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -1949,6 +1949,8 @@ fn run_command(paths: &Paths, args: RunArgs) -> Result<()> { max_tokens, args.kv_mode.as_deref(), args.kv_backend.as_deref(), + canonical.as_deref(), + args.model_draft.is_some(), )?; let selector = args .speculation @@ -1964,6 +1966,10 @@ fn run_command(paths: &Paths, args: RunArgs) -> Result<()> { apply_speculation_selector(&mut params, "dflash")?; } } + // Registry sidecar for a final auto/on selector the config-time + // load_params could not see (config-off + `run --spec dflash`); a + // no-op when load_params already resolved or an explicit draft won. + resolve_dflash_sidecar(&mut params, entry, &model_path, canonical.as_deref())?; if let Some(window) = args.draft_max { if !(1..=32).contains(&window) { bail!("--draft-max must be between 1 and 32"); @@ -2493,6 +2499,8 @@ pub(crate) fn load_params( max_tokens: u64, kv_override: Option<&str>, kv_backend_override: Option<&str>, + tag: Option<&str>, + explicit_draft: bool, ) -> Result { let configured_max_seq = config_u64(resolved, "memory.max_seq")?; let max_seq = configured_max_seq.max(max_tokens.saturating_add(1024)); @@ -2574,9 +2582,64 @@ pub(crate) fn load_params( let selector = config_string(resolved, "speculation.mode")?; apply_speculation_selector(&mut params, &selector)?; project_dflash_draft(&mut params, developer_dflash_draft(resolved)); + if !explicit_draft { + // A CLI `--model-draft` (projected by the caller after this returns) + // always wins, so skip sidecar resolution — and its `on` fail-closed + // bail — when one was given. + resolve_dflash_sidecar(&mut params, entry, model_path, tag)?; + } Ok(params) } +/// Resolve a registry-declared DFlash sidecar into `params["draft"]`. +/// +/// Call only once the final `dflash_mode` is known. When the mode is `auto` +/// or `on`, no explicit draft is set (`params["draft"]`, e.g. from +/// `developer.dflash_draft`), and `entry.dflash` names a file next to the +/// target, wire it: `on` without the file fails closed, `auto` logs one +/// line and runs AR. An explicit draft always wins; a final `off` never +/// carries a draft (`project_dflash_draft` strips it) and returns early. +fn resolve_dflash_sidecar( + params: &mut serde_json::Value, + entry: Option<&ModelEntry>, + model_path: &Path, + tag: Option<&str>, +) -> Result<()> { + if !matches!(params["dflash_mode"].as_str(), Some("auto" | "on")) { + return Ok(()); + } + if params + .get("draft") + .and_then(serde_json::Value::as_str) + .is_some_and(|draft| !draft.is_empty()) + { + return Ok(()); + } + let Some(sidecar) = entry.and_then(|entry| entry.dflash.as_ref()) else { + return Ok(()); + }; + let candidate = model_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(&sidecar.file); + if candidate.is_file() { + params["draft"] = serde_json::json!(candidate.display().to_string()); + return Ok(()); + } + let tag = tag.unwrap_or(""); + if params["dflash_mode"].as_str() == Some("on") { + bail!( + "DFlash draft {} is not pulled; run `hipfire pull {tag}` or set developer.dflash_draft", + sidecar.file + ); + } + eprintln!( + "[hipfire] DFlash draft {} not pulled; running AR — `hipfire pull {tag}`", + sidecar.file + ); + Ok(()) +} + /// Project snapshotted `developer.dflash_draft` after the effective speculation selector. /// /// Call only once final `dflash_mode` is known. Config-off must not carry a draft; @@ -3996,10 +4059,15 @@ fn open_bench_engine( max_tokens, args.kv_mode.as_deref(), args.kv_backend.as_deref(), + tag.as_deref(), + false, )?; if let Some(selector) = args.speculation.as_deref() { apply_speculation_selector(&mut params, selector)?; } + // Registry sidecar for a final auto/on selector the config-time + // load_params could not see (config-off + `bench --spec dflash`). + resolve_dflash_sidecar(&mut params, entry.as_ref(), &path, tag.as_deref())?; if args.matrix || args.redline { let requested = longest_prefill.max(longest_decode).saturating_add(32); let configured = params["max_seq"].as_u64().unwrap_or(0); @@ -6255,7 +6323,7 @@ mod tests { fs::write(&sidecar_path, b"sidecar").unwrap(); let defaults = resolve(Vec::::new()).unwrap(); - let params = load_params(&defaults, Some(entry), &model_path, 64, None, None).unwrap(); + let params = load_params(&defaults, Some(entry), &model_path, 64, None, None, None, false).unwrap(); assert_eq!(params["cask"], false); assert_eq!(params["cask_handoff_tokens"], 0); assert_eq!(params["cask_sidecar"], ""); @@ -6270,7 +6338,7 @@ mod tests { layer: explicit, }]) .unwrap(); - let params = load_params(&enabled, Some(entry), &model_path, 64, None, None).unwrap(); + let params = load_params(&enabled, Some(entry), &model_path, 64, None, None, None, false).unwrap(); assert_eq!(params["cask"], false); assert_eq!(params["cask_sidecar"], sidecar_path.display().to_string()); assert_eq!(params["prefill_compression"], "off"); @@ -6282,7 +6350,7 @@ mod tests { let defaults = resolve(Vec::::new()).unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); let params = - load_params(&defaults, None, &model_path, 64, Some("q8"), Some("vmm")).unwrap(); + load_params(&defaults, None, &model_path, 64, Some("q8"), Some("vmm"), None, false).unwrap(); assert_eq!(params["kv_backend"], "vmm"); } @@ -6290,7 +6358,7 @@ mod tests { pub(crate) fn load_params_defaults_to_schema_contiguous_backend() { let defaults = resolve(Vec::::new()).unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params(&defaults, None, &model_path, 64, Some("q8"), None).unwrap(); + let params = load_params(&defaults, None, &model_path, 64, Some("q8"), None, None, false).unwrap(); assert_eq!(params["kv_backend"], "contiguous"); assert_eq!(params["max_seq"], 32768); } @@ -6592,12 +6660,14 @@ mod tests { 64, Some("q8"), Some("contiguous"), + None, + false, ) .unwrap(); assert_eq!(params["kv_backend"], "contiguous"); // Without explicit override, load_params uses the resolved vmm. let params2 = - load_params(&resolved, Some(entry), &model_path, 64, Some("q8"), None).unwrap(); + load_params(&resolved, Some(entry), &model_path, 64, Some("q8"), None, None, false).unwrap(); assert_eq!(params2["kv_backend"], "vmm"); assert_eq!(params2["max_seq"], 262144); @@ -6696,7 +6766,7 @@ mod tests { pub(crate) fn load_params_only_forwards_explicit_deepseek4_expert_fanout() { let model_path = PathBuf::from("/tmp/test-model.mq2r"); let defaults = resolve(Vec::::new()).unwrap(); - let params = load_params(&defaults, None, &model_path, 64, Some("q8"), None).unwrap(); + let params = load_params(&defaults, None, &model_path, 64, Some("q8"), None, None, false).unwrap(); assert_eq!(params["deepseek4_compute_placement"], "single"); assert!(params.get("deepseek4_experts_per_token").is_none()); @@ -6711,7 +6781,7 @@ mod tests { layer: explicit, }]) .unwrap(); - let params = load_params(&resolved, None, &model_path, 64, Some("q8"), None).unwrap(); + let params = load_params(&resolved, None, &model_path, 64, Some("q8"), None, None, false).unwrap(); assert_eq!(params["deepseek4_experts_per_token"], 4); } @@ -6736,6 +6806,8 @@ mod tests { 64, Some("q8"), None, + None, + false, ) .unwrap(); assert_eq!(params["deepseek4_compute_placement"], raw); @@ -6757,10 +6829,219 @@ mod tests { .unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params(&resolved, None, &model_path, 64, Some("q8"), None).unwrap(); + let params = load_params(&resolved, None, &model_path, 64, Some("q8"), None, None, false).unwrap(); assert_eq!(params["draft"], draft); } + fn dflash_sidecar_entry(draft_file: &str) -> ModelEntry { + ModelEntry { + repo: "hipfire-models/qwen3.5-9b".into(), + file: "qwen3.5-9b.mq4".into(), + size_gb: 5.31, + min_vram_gb: 6.8, + desc: "test target".into(), + dflash: Some(hipfire_registry::Sidecar { + file: draft_file.into(), + sha256: None, + size_bytes: None, + }), + ..Default::default() + } + } + + fn resolved_with_dflash_mode(mode: &str, draft: Option<&str>) -> hipfire_config::ResolvedConfig { + let mut explicit = ConfigLayer::default(); + explicit.set_cli("speculation.dflash", mode).unwrap(); + if let Some(draft) = draft { + explicit.set_cli("developer.dflash_draft", draft).unwrap(); + } + resolve([NamedLayer { + source: ConfigSource::OneShot { + argument: format!("speculation.dflash={mode}"), + }, + layer: explicit, + }]) + .unwrap() + } + + #[test] + pub(crate) fn load_params_resolves_registry_dflash_sidecar_when_present() { + // (b) auto + pulled draft file → params["draft"] points at it. + let paths = test_paths("dflash-sidecar-present"); + fs::create_dir_all(&paths.models).unwrap(); + let model_path = paths.models.join("qwen3.5-9b.mq4"); + fs::write(&model_path, b"model").unwrap(); + let draft_path = paths.models.join("qwen35-9b-dflash-mq4.hfq"); + fs::write(&draft_path, b"draft").unwrap(); + let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); + let resolved = resolved_with_dflash_mode("auto", None); + let params = load_params( + &resolved, + Some(&entry), + &model_path, + 64, + Some("q8"), + None, + Some("qwen3.5:9b"), + false, + ) + .unwrap(); + assert_eq!(params["dflash_mode"], "auto"); + assert_eq!(params["draft"], draft_path.display().to_string()); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] + pub(crate) fn load_params_dflash_on_fails_closed_when_sidecar_missing() { + // (c) on + missing file errors with a pull hint naming the tag. + let paths = test_paths("dflash-sidecar-on-missing"); + fs::create_dir_all(&paths.models).unwrap(); + let model_path = paths.models.join("qwen3.5-9b.mq4"); + fs::write(&model_path, b"model").unwrap(); + let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); + let resolved = resolved_with_dflash_mode("on", None); + let error = load_params( + &resolved, + Some(&entry), + &model_path, + 64, + Some("q8"), + None, + Some("qwen3.5:9b"), + false, + ) + .expect_err("on without a pulled draft must fail closed"); + let message = format!("{error:#}"); + assert!(message.contains("qwen35-9b-dflash-mq4.hfq"), "{message}"); + assert!(message.contains("hipfire pull qwen3.5:9b"), "{message}"); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] + pub(crate) fn load_params_dflash_auto_runs_ar_when_sidecar_missing() { + // (d) auto + missing file yields no draft and no error. + let paths = test_paths("dflash-sidecar-auto-missing"); + fs::create_dir_all(&paths.models).unwrap(); + let model_path = paths.models.join("qwen3.5-9b.mq4"); + fs::write(&model_path, b"model").unwrap(); + let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); + let resolved = resolved_with_dflash_mode("auto", None); + let params = load_params( + &resolved, + Some(&entry), + &model_path, + 64, + Some("q8"), + None, + Some("qwen3.5:9b"), + false, + ) + .unwrap(); + assert_eq!(params["dflash_mode"], "auto"); + assert!(params.get("draft").is_none(), "auto without a pulled draft runs AR"); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] + pub(crate) fn load_params_explicit_draft_wins_over_dflash_sidecar() { + // (e) developer.dflash_draft beats the sidecar even when pulled. + let paths = test_paths("dflash-sidecar-explicit-wins"); + fs::create_dir_all(&paths.models).unwrap(); + let model_path = paths.models.join("qwen3.5-9b.mq4"); + fs::write(&model_path, b"model").unwrap(); + let draft_path = paths.models.join("qwen35-9b-dflash-mq4.hfq"); + fs::write(&draft_path, b"draft").unwrap(); + let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); + let explicit = "/tmp/custom-draft.hfq"; + let resolved = resolved_with_dflash_mode("auto", Some(explicit)); + let params = load_params( + &resolved, + Some(&entry), + &model_path, + 64, + Some("q8"), + None, + Some("qwen3.5:9b"), + false, + ) + .unwrap(); + assert_eq!(params["draft"], explicit); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] + pub(crate) fn load_params_final_off_drops_dflash_sidecar() { + // (f) off never carries the sidecar, and a final off selector drops + // a previously resolved one. + let paths = test_paths("dflash-sidecar-off-drops"); + fs::create_dir_all(&paths.models).unwrap(); + let model_path = paths.models.join("qwen3.5-9b.mq4"); + fs::write(&model_path, b"model").unwrap(); + let draft_path = paths.models.join("qwen35-9b-dflash-mq4.hfq"); + fs::write(&draft_path, b"draft").unwrap(); + let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); + let resolved = resolved_with_dflash_mode("off", None); + let params = load_params( + &resolved, + Some(&entry), + &model_path, + 64, + Some("q8"), + None, + Some("qwen3.5:9b"), + false, + ) + .unwrap(); + assert_eq!(params["dflash_mode"], "off"); + assert!(params.get("draft").is_none(), "off must not resolve the sidecar"); + + // Resolve under auto, then a final off selector drops it. + let resolved = resolved_with_dflash_mode("auto", None); + let mut params = load_params( + &resolved, + Some(&entry), + &model_path, + 64, + Some("q8"), + None, + Some("qwen3.5:9b"), + false, + ) + .unwrap(); + assert_eq!(params["draft"], draft_path.display().to_string()); + apply_speculation_selector(&mut params, "off").unwrap(); + project_dflash_draft(&mut params, developer_dflash_draft(&resolved)); + assert_eq!(params["dflash_mode"], "off"); + assert!(params.get("draft").is_none(), "final off must drop the sidecar draft"); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] + pub(crate) fn load_params_skips_sidecar_for_explicit_cli_draft() { + // `run --model-draft` (projected by the caller after load_params) + // always wins: even `on` must not fail closed on a missing sidecar. + let paths = test_paths("dflash-sidecar-cli-explicit"); + fs::create_dir_all(&paths.models).unwrap(); + let model_path = paths.models.join("qwen3.5-9b.mq4"); + fs::write(&model_path, b"model").unwrap(); + let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); + let resolved = resolved_with_dflash_mode("on", None); + let params = load_params( + &resolved, + Some(&entry), + &model_path, + 64, + Some("q8"), + None, + Some("qwen3.5:9b"), + true, + ) + .unwrap(); + assert!(params.get("draft").is_none()); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] fn run_spec_dflash_projects_inherited_draft_after_config_off() { // Reviewer case: resolved config leaves DFlash off, but an inherited @@ -6781,7 +7062,7 @@ mod tests { let model_path = PathBuf::from("/tmp/test-model.mq4"); // load_params alone must not carry the draft while config mode is off. - let mut params = load_params(&resolved, None, &model_path, 64, Some("q8"), None).unwrap(); + let mut params = load_params(&resolved, None, &model_path, 64, Some("q8"), None, None, false).unwrap(); assert_eq!(params["dflash_mode"], "off"); assert!( params.get("draft").is_none(), diff --git a/crates/hipfire-cli/src/serve/mod.rs b/crates/hipfire-cli/src/serve/mod.rs index 70d1d8cc6..fad7fcaad 100644 --- a/crates/hipfire-cli/src/serve/mod.rs +++ b/crates/hipfire-cli/src/serve/mod.rs @@ -1147,6 +1147,8 @@ impl ServeRuntime { max_tokens, self.kv_override.as_deref(), self.kv_backend_override.as_deref(), + tag.as_deref(), + false, )?; if let Some(tp) = self.tp { params["tp"] = serde_json::json!(tp); diff --git a/crates/hipfire-daemon/src/main.rs b/crates/hipfire-daemon/src/main.rs index 23d1dc386..b703000fe 100644 --- a/crates/hipfire-daemon/src/main.rs +++ b/crates/hipfire-daemon/src/main.rs @@ -1338,6 +1338,15 @@ fn main() { .and_then(|p| p.get("dspark_conf_threshold")) .and_then(|v| v.as_f64()) .map(|t| t as f32), + // DFlash draft: the CLI lowers `speculation` into a + // `dflash_mode` string. off→Some(false) (skip load), + // on→Some(true) (fail closed on missing/unloadable draft), + // auto/absent→None (load-if-present, log-and-AR fallback). + dflash: match dflash_mode { + "on" => Some(true), + "off" => Some(false), + _ => None, // "auto" → loader default + }, mtp: match mtp_mode.as_str() { "on" => Some(true), "off" => Some(false), diff --git a/crates/hipfire-loader/src/lib.rs b/crates/hipfire-loader/src/lib.rs index c099ef281..762ecf43f 100644 --- a/crates/hipfire-loader/src/lib.rs +++ b/crates/hipfire-loader/src/lib.rs @@ -1965,6 +1965,16 @@ fn finish_qwen35_load( Some(s) } Err(e) => { + if ctx.spec.dflash == Some(true) { + return Err(rollback_unfinished_qwen35( + format!( + "DFlash draft required (dflash_mode=on) but failed to load ({dp}): {e}" + ), + bundle, + vision_weights, + ctx.gpu, + )); + } eprintln!( " DFlash draft load failed ({}): {} — falling back to AR only", dp, e diff --git a/crates/hipfire-runtime/src/loader_api.rs b/crates/hipfire-runtime/src/loader_api.rs index cb65f2cc9..5934b3200 100644 --- a/crates/hipfire-runtime/src/loader_api.rs +++ b/crates/hipfire-runtime/src/loader_api.rs @@ -132,6 +132,11 @@ pub struct SpecLoadCfg { pub mtp: Option, /// MTP draft window K. `None` = runtime default (`HIPFIRE_MTP_K`). pub mtp_k: Option, + /// Qwen DFlash draft enable, lowered from `dflash_mode`: `Some(true)` = + /// `on` (fail the load when the draft is missing or unloadable), + /// `Some(false)` = `off` (skip), `None` = `auto` (load when present, + /// log-and-AR fallback otherwise). + pub dflash: Option, } /// CASK/TriAttention params forwarded by the CLI at load time. From 4eb52095d7c3bb515e3deca9184eb7e4516082ed Mon Sep 17 00:00:00 2001 From: Kaden Schutt <151092359+Kaden-Schutt@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:15:24 +0000 Subject: [PATCH 22/34] docs: describe registry-declared dflash sidecars (audit-Dflash Broken 1) Replace the filename auto-match story (no such matcher exists) with the new behavior: pull fetches the draft sidecar, auto uses it when present, on requires it, developer.dflash_draft/--model-draft override. Point at the real 'DFlash draft loaded:' loader line instead of the two log lines that were never emitted. --- AGENTS.md | 43 ++++++++++++++++++++++--------------------- docs/MODELS.md | 2 +- docs/env-vars.md | 2 +- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e0288765..8e5d980c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,27 +220,27 @@ works, what to measure, what counts as pass/fail. ### Pull the model + draft you want to test -Targets and drafts are independent pulls — drafts auto-discover their -target by filename when the daemon loads: +`hipfire pull ` fetches the target plus its registry-declared +DFlash draft sidecar (same mechanism as the MTP/DSpark sidecars): ```bash # 27B Qwen 3.5 (the canonical perf-test target): -hipfire pull qwen3.5:27b # 15 GB target -hipfire pull qwen3.5:27b-draft # 0.92 GB DFlash draft +hipfire pull qwen3.5:27b # 15 GB target + 0.92 GB DFlash draft sidecar # 27B Qwen 3.6 (refresh): -hipfire pull qwen3.6:27b # 15 GB target -hipfire pull qwen3.6:27b-draft # 0.92 GB DFlash draft +hipfire pull qwen3.6:27b # 15 GB target + 0.92 GB DFlash draft sidecar # 9B Qwen 3.5 (smaller, faster sanity-check): -hipfire pull qwen3.5:9b # 5.3 GB target -hipfire pull qwen3.5:9b-draft # 0.55 GB DFlash draft +hipfire pull qwen3.5:9b # 5.3 GB target + 0.55 GB DFlash draft sidecar ``` -Files land at `~/.hipfire/models/` matching the -daemon's auto-discovery pattern (`qwen3{ver}-{size}-dflash-{quant}.hfq`). -**Do not rename.** Renaming breaks the auto-discovery and DFlash falls -back to AR silently. +Standalone `*-draft` tags (`hipfire pull qwen3.5:27b-draft`) still work — +they address the same file for anyone who wants the draft alone. + +Files land at `~/.hipfire/models/`. +**Do not rename.** Load resolves the draft by its registry-declared +filename; renaming breaks the pairing — `dflash_mode auto` then runs AR +(one warning line), `on` fails the load. ### Verify md5s after pull (paranoid mode) @@ -330,7 +330,8 @@ Standalone: `cargo run --release -p hipfire-runtime --example encode_prompt -- M - `hipfire-models/qwen3.6-27b/qwen36-27b-dflash-mq4.hfq` (+ the 3.6 27B target `hipfire-models/qwen3.6-27b/qwen3.6-27b.mq4`) -Pullable via `hipfire pull qwen3.{5,6}:{9b,27b}-draft` and `hipfire pull qwen3.6:27b`. +`hipfire pull ` fetches the target plus its draft sidecar; +standalone drafts stay pullable via `hipfire pull qwen3.{5,6}:{9b,27b}-draft`. --- @@ -429,17 +430,17 @@ reassurance. If you're testing an actual user UX flow: ```bash -hipfire pull qwen3.5:9b -hipfire pull qwen3.5:9b-draft +hipfire pull qwen3.5:9b # target + draft sidecar in one pull hipfire config set dflash_mode auto # opt in (default since 2026-04-26: off) hipfire run qwen3.5:9b "Write a Python function to find the longest substring without repeating characters" -# expected: daemon logs '[hipfire] DFlash draft detected: ...' +# expected: loader logs 'DFlash draft loaded: ...' # response generates at ≥250 tok/s on a 9B target with a paired draft ``` Without the `dflash_mode auto` config, `hipfire run` runs pure AR -even when a paired draft is on disk — the daemon explicitly logs -`[hipfire] DFlash disabled (dflash_mode=off).` This is the "I pulled +even when a paired draft is on disk. `dflash_mode on` instead requires +the sidecar and fails the load when it is missing; `developer.dflash_draft` +or `run --model-draft` overrides the sidecar. This is the "I pulled the draft but DFlash isn't firing" pitfall. --- @@ -584,8 +585,8 @@ against the A3B MoE DFlash perfmaxx line. | 3.6-A3B DFlash 68.6 tok/s vs AR 135 tok/s (50% loss) | 3.6 draft trained on 3.5 traces; target distribution mismatch on code. τ=1.22 on hard code. | Use AR mode for 3.6-A3B. Draft mismatch is expected and no 3.6 retrain is planned — Path C (`feat/mtp-dflash-training`) is dead/out-of-scope, not a forthcoming fix. 3.5-A3B DFlash works (τ=4.91). | | `hipMalloc out of memory` at hidden_rb | Long ctx (≥16K real tokens) + 27B + asym3 = tight on 24 GB | Reduce ctx, use a smaller target, or wait for the bounded-rolling-buffer trick (roadmap) | | `tok/s` below expected on long-ctx | KV cache growth — prefill is fine but decode slows past ~2K | Test at small ctx first, then scale | -| daemon doesn't auto-find draft | Filename doesn't match `qwen3{ver}-{size}-dflash-{quant}.hfq` | Don't rename the file after pull | -| `[hipfire] DFlash disabled (dflash_mode=off)` | Default flipped to `off` in 35265c6 (post-2026-04-26). Pulling a draft does NOT auto-enable DFlash anymore. | `hipfire config set dflash_mode auto` (or `on`); or per-model `hipfire config qwen3.5:9b set dflash_mode on` | +| daemon doesn't pair a pulled draft | Renamed draft file, or pulled before the sidecar existed | Don't rename files after pull; re-run `hipfire pull ` to fetch the registry-declared sidecar | +| `[hipfire-daemon] dflash_mode=off — skipping draft load` | Default flipped to `off` in 35265c6 (post-2026-04-26). Pulling a draft does NOT auto-enable DFlash anymore. | `hipfire config set dflash_mode auto` (or `on`); or per-model `hipfire config qwen3.5:9b set dflash_mode on` | | "Numbers don't match the README" | Forgot `HIPFIRE_NORMALIZE_PROMPT=1` (pre-2026-04-26) | Now default ON. Pull latest. If you opted out via `prompt_normalize=false`, that overrides the default — flip back. | | "27B DFlash regressed 30-40% suddenly" | PR #32 (cleanup-dead-wmma-kernels) on master removed `gemm_hfq4g256_residual_wmma{,2,_k4}.hip` thinking dead. Dispatch fell back to slower variants. | Verify against canonical 199 tok/s @ max=120 with default flags. If kernel files missing in `kernels/src/`, `git checkout` from a known-good commit (see commit 9a2c667 for the full recovery context). | | `HIPFIRE_GRAPH=1` reports plausible tok/s but output is garbage | Dangling stack-pointer kernargs from raw `self.hip.launch_kernel(...)` calls in `forward_scratch_layers` (kv_cache_write_*, attention_flash_*, fused_qkv_hfq4g256, rmsnorm_batched, rope_partial_interleaved_f32, gated_delta_net_q8, etc.) — captured pointers dangle past `end_graph_capture` | Bench tok/s alone never proves graph correctness. Always coherence-gate or eyeball under `HIPFIRE_GRAPH=1`. Fix: migrate every raw-launch helper used in forward_scratch_layers to `launch_maybe_blob` (model after `conv1d_silu_split_f32_n`). | @@ -602,7 +603,7 @@ against the A3B MoE DFlash perfmaxx line. | `HIPFIRE_PROMPT_HEAT_LIMIT` | Max rows in heat dump | 64 | | `HIPFIRE_KV_MODE` | Override kv_cache config | (config) | | `HIPFIRE_ATTN_FLASH` | Override flash_mode config | (config) | -|`HIPFIRE_DFLASH_DRAFT`|Force a specific draft path. Empty string = explicit opt-out|(filename auto-match alongside target)| +|`HIPFIRE_DFLASH_DRAFT`|Force a specific draft path, overriding the registry sidecar. Empty string = explicit opt-out|(unset: registry sidecar when `dflash_mode` is `auto`/`on`)| |`HIPFIRE_DFLASH_CTX_CAP`|Max rows for draft context-indexed structures (target_hidden, draft K/V caches, hidden ring). Bounds draft-side VRAM on large-`max_seq` serve loads; over-cap requests fall back to AR (identical output, slower). `0` = uncapped legacy.|8192| |`HIPFIRE_DFLASH_WINDOW`|Windowed draft context (NInfer pattern): SWA over the last W rows on draft layers 0..n-2 + full-attention last layer reaching min(physical_cap, 4W). Draft VRAM pins at W regardless of `max_seq`; past-W requests degrade τ instead of falling back to AR. Refused with CASK eviction. `0`/unset = Legacy (cap + AR fallback).|0 (off)| | `HIPFIRE_LM_HEAD_F16` | `auto`/`native` keeps qt=1 lm_head as F16; `f32`/`legacy` expands to F32 | auto/native | diff --git a/docs/MODELS.md b/docs/MODELS.md index b8efd4f1e..b46d3bc46 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -113,7 +113,7 @@ MQ2V2 is not registered. Explicit `qwen3.8:27b-mq4` aliases to `qwen3.8:27b`. Le | `qwen3.8:27b-draft-mq6` | `qwen38-27b-dflash-mq6.hfq` | 1.66 | 16 | `qwen3.8:27b*` (same-bit alt) | | `muse-glimmer:draft` | `muse-glimmer-30b-dflash.mq4` | 1.36 | 26 | `muse-glimmer` / `muse-glimmer:fast` | -Draft **loading** is controlled by `dflash_mode` / `speculation` / `HIPFIRE_DFLASH_DRAFT` ([`CONFIG.md`](CONFIG.md), [`env-vars.md`](env-vars.md)). Default `dflash_mode` is **off**. Filename auto-match may wire a sibling draft when present; that is discovery, not an admission that DFlash wins on every prompt. +Draft **loading** is registry-driven: `hipfire pull ` fetches the draft sidecar alongside the target, and `dflash_mode` / `speculation` ([`CONFIG.md`](CONFIG.md), [`env-vars.md`](env-vars.md)) decides what happens next — `auto` uses the sidecar when present (AR otherwise), `on` requires it (load fails without it), `off` never drafts. Default `dflash_mode` is **off**. `developer.dflash_draft` / `run --model-draft` override the sidecar. Watch for `DFlash draft loaded:` in load output. ### Qwen3 (non-3.5) dense HF4 diff --git a/docs/env-vars.md b/docs/env-vars.md index c2da45af4..fdfad68a1 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -105,7 +105,7 @@ Values and defaults below match `hipfire-config`, the native CLI, and/or `Runtim | Variable | Default / sense | Notes | |---|---|---| | `HIPFIRE_SPECULATION` | `off`/`auto`/`ngram`/`dflash`/`mtp`/`dspark` | Canonical selector | -| `HIPFIRE_DFLASH_DRAFT` | retired engine read | Still appears in legacy gate scripts; product draft discovery uses typed speculation/load policy and registry/filename matching. | +| `HIPFIRE_DFLASH_DRAFT` | explicit draft path (overrides the registry sidecar); empty opts out | Legacy `developer.dflash_draft` read; still appears in legacy gate scripts. | | `HIPFIRE_DFLASH_CTX_CAP` | **8192**; `0` restores uncapped legacy behavior | Caps draft-side context storage; over-cap requests fall back to AR | | `HIPFIRE_DFLASH_WINDOW` | **0 / unset** (legacy), unless declared by draft metadata | Enables bounded draft SWA; refused with CASK eviction | | `HIPFIRE_DFLASH_MODE` | RuntimeConfig default **`off`** | Distinct from config `dflash_mode` apply path — product CLI also uses load params | From 9c6dbd2d775154350d0b3e5a0aa22db33db7c9e5 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:16:19 +0000 Subject: [PATCH 23/34] style: rustfmt changed files --- crates/hipfire-cli/src/main.rs | 131 +++++++++++++++++++++++++---- crates/hipfire-registry/src/lib.rs | 5 +- 2 files changed, 119 insertions(+), 17 deletions(-) diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index dd1b67ba3..859be5a63 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -6323,7 +6323,17 @@ mod tests { fs::write(&sidecar_path, b"sidecar").unwrap(); let defaults = resolve(Vec::::new()).unwrap(); - let params = load_params(&defaults, Some(entry), &model_path, 64, None, None, None, false).unwrap(); + let params = load_params( + &defaults, + Some(entry), + &model_path, + 64, + None, + None, + None, + false, + ) + .unwrap(); assert_eq!(params["cask"], false); assert_eq!(params["cask_handoff_tokens"], 0); assert_eq!(params["cask_sidecar"], ""); @@ -6338,7 +6348,17 @@ mod tests { layer: explicit, }]) .unwrap(); - let params = load_params(&enabled, Some(entry), &model_path, 64, None, None, None, false).unwrap(); + let params = load_params( + &enabled, + Some(entry), + &model_path, + 64, + None, + None, + None, + false, + ) + .unwrap(); assert_eq!(params["cask"], false); assert_eq!(params["cask_sidecar"], sidecar_path.display().to_string()); assert_eq!(params["prefill_compression"], "off"); @@ -6349,8 +6369,17 @@ mod tests { pub(crate) fn load_params_forwards_explicit_vmm_backend() { let defaults = resolve(Vec::::new()).unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = - load_params(&defaults, None, &model_path, 64, Some("q8"), Some("vmm"), None, false).unwrap(); + let params = load_params( + &defaults, + None, + &model_path, + 64, + Some("q8"), + Some("vmm"), + None, + false, + ) + .unwrap(); assert_eq!(params["kv_backend"], "vmm"); } @@ -6358,7 +6387,17 @@ mod tests { pub(crate) fn load_params_defaults_to_schema_contiguous_backend() { let defaults = resolve(Vec::::new()).unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params(&defaults, None, &model_path, 64, Some("q8"), None, None, false).unwrap(); + let params = load_params( + &defaults, + None, + &model_path, + 64, + Some("q8"), + None, + None, + false, + ) + .unwrap(); assert_eq!(params["kv_backend"], "contiguous"); assert_eq!(params["max_seq"], 32768); } @@ -6666,8 +6705,17 @@ mod tests { .unwrap(); assert_eq!(params["kv_backend"], "contiguous"); // Without explicit override, load_params uses the resolved vmm. - let params2 = - load_params(&resolved, Some(entry), &model_path, 64, Some("q8"), None, None, false).unwrap(); + let params2 = load_params( + &resolved, + Some(entry), + &model_path, + 64, + Some("q8"), + None, + None, + false, + ) + .unwrap(); assert_eq!(params2["kv_backend"], "vmm"); assert_eq!(params2["max_seq"], 262144); @@ -6766,7 +6814,17 @@ mod tests { pub(crate) fn load_params_only_forwards_explicit_deepseek4_expert_fanout() { let model_path = PathBuf::from("/tmp/test-model.mq2r"); let defaults = resolve(Vec::::new()).unwrap(); - let params = load_params(&defaults, None, &model_path, 64, Some("q8"), None, None, false).unwrap(); + let params = load_params( + &defaults, + None, + &model_path, + 64, + Some("q8"), + None, + None, + false, + ) + .unwrap(); assert_eq!(params["deepseek4_compute_placement"], "single"); assert!(params.get("deepseek4_experts_per_token").is_none()); @@ -6781,7 +6839,17 @@ mod tests { layer: explicit, }]) .unwrap(); - let params = load_params(&resolved, None, &model_path, 64, Some("q8"), None, None, false).unwrap(); + let params = load_params( + &resolved, + None, + &model_path, + 64, + Some("q8"), + None, + None, + false, + ) + .unwrap(); assert_eq!(params["deepseek4_experts_per_token"], 4); } @@ -6829,7 +6897,17 @@ mod tests { .unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params(&resolved, None, &model_path, 64, Some("q8"), None, None, false).unwrap(); + let params = load_params( + &resolved, + None, + &model_path, + 64, + Some("q8"), + None, + None, + false, + ) + .unwrap(); assert_eq!(params["draft"], draft); } @@ -6849,7 +6927,10 @@ mod tests { } } - fn resolved_with_dflash_mode(mode: &str, draft: Option<&str>) -> hipfire_config::ResolvedConfig { + fn resolved_with_dflash_mode( + mode: &str, + draft: Option<&str>, + ) -> hipfire_config::ResolvedConfig { let mut explicit = ConfigLayer::default(); explicit.set_cli("speculation.dflash", mode).unwrap(); if let Some(draft) = draft { @@ -6938,7 +7019,10 @@ mod tests { ) .unwrap(); assert_eq!(params["dflash_mode"], "auto"); - assert!(params.get("draft").is_none(), "auto without a pulled draft runs AR"); + assert!( + params.get("draft").is_none(), + "auto without a pulled draft runs AR" + ); fs::remove_dir_all(&paths.root).unwrap(); } @@ -6993,7 +7077,10 @@ mod tests { ) .unwrap(); assert_eq!(params["dflash_mode"], "off"); - assert!(params.get("draft").is_none(), "off must not resolve the sidecar"); + assert!( + params.get("draft").is_none(), + "off must not resolve the sidecar" + ); // Resolve under auto, then a final off selector drops it. let resolved = resolved_with_dflash_mode("auto", None); @@ -7012,7 +7099,10 @@ mod tests { apply_speculation_selector(&mut params, "off").unwrap(); project_dflash_draft(&mut params, developer_dflash_draft(&resolved)); assert_eq!(params["dflash_mode"], "off"); - assert!(params.get("draft").is_none(), "final off must drop the sidecar draft"); + assert!( + params.get("draft").is_none(), + "final off must drop the sidecar draft" + ); fs::remove_dir_all(&paths.root).unwrap(); } @@ -7041,7 +7131,6 @@ mod tests { fs::remove_dir_all(&paths.root).unwrap(); } - #[test] fn run_spec_dflash_projects_inherited_draft_after_config_off() { // Reviewer case: resolved config leaves DFlash off, but an inherited @@ -7062,7 +7151,17 @@ mod tests { let model_path = PathBuf::from("/tmp/test-model.mq4"); // load_params alone must not carry the draft while config mode is off. - let mut params = load_params(&resolved, None, &model_path, 64, Some("q8"), None, None, false).unwrap(); + let mut params = load_params( + &resolved, + None, + &model_path, + 64, + Some("q8"), + None, + None, + false, + ) + .unwrap(); assert_eq!(params["dflash_mode"], "off"); assert!( params.get("draft").is_none(), diff --git a/crates/hipfire-registry/src/lib.rs b/crates/hipfire-registry/src/lib.rs index 3f8608a2a..a0eb3352b 100644 --- a/crates/hipfire-registry/src/lib.rs +++ b/crates/hipfire-registry/src/lib.rs @@ -1052,7 +1052,10 @@ mod tests { paired += 1; } } - assert!(paired > 0, "bundled registry should pair at least one dflash sidecar"); + assert!( + paired > 0, + "bundled registry should pair at least one dflash sidecar" + ); } #[test] From faf7b6a09520821530cb9f6bc70ca4de40277088 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:52:26 +0000 Subject: [PATCH 24/34] chore(daemon): keep daemon_lines under the leanup ratchet The dflash SpecLoadCfg arm mirrors the mtp arm below it; one line of comment says so. 4154 <= 4155. --- crates/hipfire-daemon/src/main.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/hipfire-daemon/src/main.rs b/crates/hipfire-daemon/src/main.rs index b703000fe..617ba2b2d 100644 --- a/crates/hipfire-daemon/src/main.rs +++ b/crates/hipfire-daemon/src/main.rs @@ -1338,10 +1338,7 @@ fn main() { .and_then(|p| p.get("dspark_conf_threshold")) .and_then(|v| v.as_f64()) .map(|t| t as f32), - // DFlash draft: the CLI lowers `speculation` into a - // `dflash_mode` string. off→Some(false) (skip load), - // on→Some(true) (fail closed on missing/unloadable draft), - // auto/absent→None (load-if-present, log-and-AR fallback). + // DFlash mirrors mtp: on = fail closed on a missing/unloadable draft. dflash: match dflash_mode { "on" => Some(true), "off" => Some(false), From 51409f1dac80f04589b6dd577093aac028f53d93 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 01:56:22 +0000 Subject: [PATCH 25/34] chore: refresh crate maps (check-crate-maps --write) --- crates/hipfire-cli/map.md | 6 +++--- crates/hipfire-daemon/map.md | 4 ++-- crates/hipfire-registry/map.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/hipfire-cli/map.md b/crates/hipfire-cli/map.md index d87967dec..a766d3a7e 100644 --- a/crates/hipfire-cli/map.md +++ b/crates/hipfire-cli/map.md @@ -23,11 +23,11 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | File | Lines | Public items | Tests | |---|---:|---:|---:| | [`src/bench_concurrency.rs`](src/bench_concurrency.rs) | 720 | 21 | 9 | -| [`src/main.rs`](src/main.rs) | 9,692 | 0 | 65 | +| [`src/main.rs`](src/main.rs) | 10,073 | 0 | 71 | | [`src/serve/complete.rs`](src/serve/complete.rs) | 6,754 | 0 | 89 | | [`src/serve/http.rs`](src/serve/http.rs) | 1,089 | 0 | 6 | | [`src/serve/metrics.rs`](src/serve/metrics.rs) | 328 | 0 | 5 | -| [`src/serve/mod.rs`](src/serve/mod.rs) | 2,116 | 2 | 16 | +| [`src/serve/mod.rs`](src/serve/mod.rs) | 2,118 | 2 | 16 | | [`src/setup.rs`](src/setup.rs) | 1,529 | 0 | 16 | ### Public API surface @@ -53,6 +53,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 7 modules · 22,228 lines · 23 public items · 206 tests · 0 examples +- 7 modules · 22,611 lines · 23 public items · 212 tests · 0 examples diff --git a/crates/hipfire-daemon/map.md b/crates/hipfire-daemon/map.md index 77509dd6c..b01eb7ccc 100644 --- a/crates/hipfire-daemon/map.md +++ b/crates/hipfire-daemon/map.md @@ -23,7 +23,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | File | Lines | Public items | Tests | |---|---:|---:|---:| -| [`src/main.rs`](src/main.rs) | 4,148 | 1 | 0 | +| [`src/main.rs`](src/main.rs) | 4,154 | 1 | 0 | | [`src/slots.rs`](src/slots.rs) | 1,526 | 22 | 14 | ### Public API surface @@ -44,6 +44,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 2 modules · 5,674 lines · 23 public items · 14 tests · 0 examples +- 2 modules · 5,680 lines · 23 public items · 14 tests · 0 examples diff --git a/crates/hipfire-registry/map.md b/crates/hipfire-registry/map.md index c101c3dfc..4a197a5de 100644 --- a/crates/hipfire-registry/map.md +++ b/crates/hipfire-registry/map.md @@ -22,7 +22,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | File | Lines | Public items | Tests | |---|---:|---:|---:| -| [`src/lib.rs`](src/lib.rs) | 1,796 | 25 | 18 | +| [`src/lib.rs`](src/lib.rs) | 1,825 | 25 | 19 | ### Public API surface @@ -41,6 +41,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 1 modules · 1,796 lines · 25 public items · 18 tests · 0 examples +- 1 modules · 1,825 lines · 25 public items · 19 tests · 0 examples From 15851ebfcc111ea1b42080e0b6e1f7b71dd30f7b Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 02:09:39 +0000 Subject: [PATCH 26/34] chore: refresh loader/runtime crate maps --- crates/hipfire-loader/map.md | 4 ++-- crates/hipfire-runtime/map.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/hipfire-loader/map.md b/crates/hipfire-loader/map.md index b563b6c87..4b8ea268f 100644 --- a/crates/hipfire-loader/map.md +++ b/crates/hipfire-loader/map.md @@ -25,7 +25,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside |---|---:|---:|---:| | [`src/batch_staging.rs`](src/batch_staging.rs) | 336 | 4 | 0 | | [`src/carriers.rs`](src/carriers.rs) | 2,516 | 11 | 3 | -| [`src/lib.rs`](src/lib.rs) | 4,838 | 95 | 22 | +| [`src/lib.rs`](src/lib.rs) | 4,848 | 95 | 22 | | [`src/spec_build.rs`](src/spec_build.rs) | 233 | 4 | 0 | ### Public API surface @@ -48,6 +48,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 4 modules · 7,923 lines · 114 public items · 25 tests · 1 examples +- 4 modules · 7,933 lines · 114 public items · 25 tests · 1 examples diff --git a/crates/hipfire-runtime/map.md b/crates/hipfire-runtime/map.md index 1995fd991..1cd7c935d 100644 --- a/crates/hipfire-runtime/map.md +++ b/crates/hipfire-runtime/map.md @@ -55,7 +55,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | [`src/lib.rs`](src/lib.rs) | 80 | 55 | 0 | | [`src/llama.rs`](src/llama.rs) | 8,738 | 83 | 42 | | [`src/llama_spec.rs`](src/llama_spec.rs) | 617 | 6 | 1 | -| [`src/loader_api.rs`](src/loader_api.rs) | 256 | 10 | 4 | +| [`src/loader_api.rs`](src/loader_api.rs) | 261 | 10 | 4 | | [`src/loop_guard.rs`](src/loop_guard.rs) | 194 | 8 | 4 | | [`src/model_load.rs`](src/model_load.rs) | 117 | 8 | 1 | | [`src/model_source.rs`](src/model_source.rs) | 92 | 4 | 0 | @@ -156,6 +156,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 58 modules · 53,079 lines · 893 public items · 617 tests · 132 examples +- 58 modules · 53,084 lines · 893 public items · 617 tests · 132 examples From 241c548f45b821ba6b3831fd6f32e05518200083 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 02:37:01 +0000 Subject: [PATCH 27/34] fix(registry): resolve a model given by path to its entry resolve_tag matched a bare file name against entry.file but not a path whose final component is that file name, so `serve --model ~/.hipfire/models/qwen3.8-27b.mq5` (what serve_harness passes) got entry=None and no dflash sidecar: measured on hipx as tau=None / 37 tok/s under --speculation dflash while the same tag resolved the draft. --- crates/hipfire-registry/map.md | 4 ++-- crates/hipfire-registry/src/lib.rs | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/hipfire-registry/map.md b/crates/hipfire-registry/map.md index 4a197a5de..13dcf2581 100644 --- a/crates/hipfire-registry/map.md +++ b/crates/hipfire-registry/map.md @@ -22,7 +22,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | File | Lines | Public items | Tests | |---|---:|---:|---:| -| [`src/lib.rs`](src/lib.rs) | 1,825 | 25 | 19 | +| [`src/lib.rs`](src/lib.rs) | 1,843 | 25 | 19 | ### Public API surface @@ -41,6 +41,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 1 modules · 1,825 lines · 25 public items · 19 tests · 0 examples +- 1 modules · 1,843 lines · 25 public items · 19 tests · 0 examples diff --git a/crates/hipfire-registry/src/lib.rs b/crates/hipfire-registry/src/lib.rs index a0eb3352b..322d5832d 100644 --- a/crates/hipfire-registry/src/lib.rs +++ b/crates/hipfire-registry/src/lib.rs @@ -424,10 +424,18 @@ impl RegistryV1 { if self.models.contains_key(&qwen) { return qwen; } + // A bare file name matches its entry; so does a path whose final + // component is that file name (`hipfire serve --model ~/.hipfire/models/x.mq4` + // must see the same sidecars as `hipfire serve --model `). + let file_name = std::path::Path::new(input) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(input); self.models .iter() .find_map(|(tag, entry)| { - (entry.file == normalized || entry.file == input).then(|| tag.clone()) + (entry.file == normalized || entry.file == input || entry.file == file_name) + .then(|| tag.clone()) }) .unwrap_or(normalized) } @@ -1072,6 +1080,16 @@ mod tests { "qwen3.8:27b-mq4-xt" ); assert_eq!(registry.resolve_tag("qwen3.8:fast"), "qwen3.8:27b-mq4-xt"); + // A path to the artifact resolves like its file name, so `serve + // --model ~/.hipfire/models/` sees the entry's sidecars. + assert_eq!( + registry.resolve_tag("/home/u/.hipfire/models/qwen3.8-27b.mq5"), + "qwen3.8:27b-mq5" + ); + assert_eq!( + registry.resolve_tag("/home/u/.hipfire/models/qwen3.8-27b.mq4"), + "qwen3.8:27b" + ); assert_eq!(registry.resolve_tag("deepseek4"), "deepseek-v4-flash"); assert_eq!(registry.resolve_tag("deepseek4:0731"), "deepseek-v4-flash"); From 2432fbf46df00abdab11ddb3837cd938f08b2d89 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 02:40:17 +0000 Subject: [PATCH 28/34] fix(cli): look the DFlash sidecar up in the models dir, not beside the canonical target find_model_path canonicalizes, so a target that is a symlink into another directory (every ladder artifact on the bench boxes) has a parent with no draft in it and the registry sidecar was never found. load_params now takes the models dir and the resolver searches it first, then beside the target. Regression test with a symlinked target. Measured: serve_harness --speculation dflash on qwen3.8-27b.mq5 ran AR (tau=None, 37 tok/s) before; the direct-tag probe resolved the draft (tau=14.2). --- crates/hipfire-cli/map.md | 6 +- crates/hipfire-cli/src/main.rs | 154 +++++++++++++--------------- crates/hipfire-cli/src/serve/mod.rs | 1 + 3 files changed, 77 insertions(+), 84 deletions(-) diff --git a/crates/hipfire-cli/map.md b/crates/hipfire-cli/map.md index a766d3a7e..10d8fd932 100644 --- a/crates/hipfire-cli/map.md +++ b/crates/hipfire-cli/map.md @@ -23,11 +23,11 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | File | Lines | Public items | Tests | |---|---:|---:|---:| | [`src/bench_concurrency.rs`](src/bench_concurrency.rs) | 720 | 21 | 9 | -| [`src/main.rs`](src/main.rs) | 10,073 | 0 | 71 | +| [`src/main.rs`](src/main.rs) | 10,065 | 0 | 72 | | [`src/serve/complete.rs`](src/serve/complete.rs) | 6,754 | 0 | 89 | | [`src/serve/http.rs`](src/serve/http.rs) | 1,089 | 0 | 6 | | [`src/serve/metrics.rs`](src/serve/metrics.rs) | 328 | 0 | 5 | -| [`src/serve/mod.rs`](src/serve/mod.rs) | 2,118 | 2 | 16 | +| [`src/serve/mod.rs`](src/serve/mod.rs) | 2,119 | 2 | 16 | | [`src/setup.rs`](src/setup.rs) | 1,529 | 0 | 16 | ### Public API surface @@ -53,6 +53,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 7 modules · 22,611 lines · 23 public items · 212 tests · 0 examples +- 7 modules · 22,604 lines · 23 public items · 213 tests · 0 examples diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index 859be5a63..d4c7a66d6 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -1945,6 +1945,7 @@ fn run_command(paths: &Paths, args: RunArgs) -> Result<()> { let mut params = load_params( &resolved, entry, + &paths.models, &model_path, max_tokens, args.kv_mode.as_deref(), @@ -1969,7 +1970,7 @@ fn run_command(paths: &Paths, args: RunArgs) -> Result<()> { // Registry sidecar for a final auto/on selector the config-time // load_params could not see (config-off + `run --spec dflash`); a // no-op when load_params already resolved or an explicit draft won. - resolve_dflash_sidecar(&mut params, entry, &model_path, canonical.as_deref())?; + resolve_dflash_sidecar(&mut params, entry, &paths.models, &model_path, canonical.as_deref())?; if let Some(window) = args.draft_max { if !(1..=32).contains(&window) { bail!("--draft-max must be between 1 and 32"); @@ -2495,6 +2496,7 @@ pub(crate) fn find_model_path( pub(crate) fn load_params( resolved: &hipfire_config::ResolvedConfig, entry: Option<&ModelEntry>, + models_dir: &Path, model_path: &Path, max_tokens: u64, kv_override: Option<&str>, @@ -2586,7 +2588,7 @@ pub(crate) fn load_params( // A CLI `--model-draft` (projected by the caller after this returns) // always wins, so skip sidecar resolution — and its `on` fail-closed // bail — when one was given. - resolve_dflash_sidecar(&mut params, entry, model_path, tag)?; + resolve_dflash_sidecar(&mut params, entry, models_dir, model_path, tag)?; } Ok(params) } @@ -2595,13 +2597,17 @@ pub(crate) fn load_params( /// /// Call only once the final `dflash_mode` is known. When the mode is `auto` /// or `on`, no explicit draft is set (`params["draft"]`, e.g. from -/// `developer.dflash_draft`), and `entry.dflash` names a file next to the -/// target, wire it: `on` without the file fails closed, `auto` logs one -/// line and runs AR. An explicit draft always wins; a final `off` never +/// `developer.dflash_draft`), and `entry.dflash` names a pulled file, wire +/// it: `on` without the file fails closed, `auto` logs one line and runs AR. +/// The sidecar is looked up in `models_dir` first — `find_model_path` +/// canonicalizes, so a symlinked target's parent is wherever the artifact +/// really lives, not the models directory the draft was pulled into — then +/// next to the target. An explicit draft always wins; a final `off` never /// carries a draft (`project_dflash_draft` strips it) and returns early. fn resolve_dflash_sidecar( params: &mut serde_json::Value, entry: Option<&ModelEntry>, + models_dir: &Path, model_path: &Path, tag: Option<&str>, ) -> Result<()> { @@ -2618,11 +2624,12 @@ fn resolve_dflash_sidecar( let Some(sidecar) = entry.and_then(|entry| entry.dflash.as_ref()) else { return Ok(()); }; - let candidate = model_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(&sidecar.file); - if candidate.is_file() { + let beside_target = model_path.parent().unwrap_or_else(|| Path::new(".")); + let candidate = [models_dir, beside_target] + .into_iter() + .map(|dir| dir.join(&sidecar.file)) + .find(|candidate| candidate.is_file()); + if let Some(candidate) = candidate { params["draft"] = serde_json::json!(candidate.display().to_string()); return Ok(()); } @@ -4055,6 +4062,7 @@ fn open_bench_engine( let mut params = load_params( &resolved, entry.as_ref(), + &paths.models, &path, max_tokens, args.kv_mode.as_deref(), @@ -4067,7 +4075,7 @@ fn open_bench_engine( } // Registry sidecar for a final auto/on selector the config-time // load_params could not see (config-off + `bench --spec dflash`). - resolve_dflash_sidecar(&mut params, entry.as_ref(), &path, tag.as_deref())?; + resolve_dflash_sidecar(&mut params, entry.as_ref(), &paths.models, &path, tag.as_deref())?; if args.matrix || args.redline { let requested = longest_prefill.max(longest_decode).saturating_add(32); let configured = params["max_seq"].as_u64().unwrap_or(0); @@ -6323,10 +6331,7 @@ mod tests { fs::write(&sidecar_path, b"sidecar").unwrap(); let defaults = resolve(Vec::::new()).unwrap(); - let params = load_params( - &defaults, - Some(entry), - &model_path, + let params = load_params(&defaults, Some(entry), &model_path.parent().unwrap(), &model_path, 64, None, None, @@ -6348,10 +6353,7 @@ mod tests { layer: explicit, }]) .unwrap(); - let params = load_params( - &enabled, - Some(entry), - &model_path, + let params = load_params(&enabled, Some(entry), &model_path.parent().unwrap(), &model_path, 64, None, None, @@ -6369,10 +6371,7 @@ mod tests { pub(crate) fn load_params_forwards_explicit_vmm_backend() { let defaults = resolve(Vec::::new()).unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params( - &defaults, - None, - &model_path, + let params = load_params(&defaults, None, &model_path.parent().unwrap(), &model_path, 64, Some("q8"), Some("vmm"), @@ -6387,10 +6386,7 @@ mod tests { pub(crate) fn load_params_defaults_to_schema_contiguous_backend() { let defaults = resolve(Vec::::new()).unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params( - &defaults, - None, - &model_path, + let params = load_params(&defaults, None, &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -6692,10 +6688,7 @@ mod tests { // Also verify load_params respects explicit kv_backend override over configured vmm. let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params( - &resolved, - Some(entry), - &model_path, + let params = load_params(&resolved, Some(entry), &model_path.parent().unwrap(), &model_path, 64, Some("q8"), Some("contiguous"), @@ -6705,10 +6698,7 @@ mod tests { .unwrap(); assert_eq!(params["kv_backend"], "contiguous"); // Without explicit override, load_params uses the resolved vmm. - let params2 = load_params( - &resolved, - Some(entry), - &model_path, + let params2 = load_params(&resolved, Some(entry), &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -6814,10 +6804,7 @@ mod tests { pub(crate) fn load_params_only_forwards_explicit_deepseek4_expert_fanout() { let model_path = PathBuf::from("/tmp/test-model.mq2r"); let defaults = resolve(Vec::::new()).unwrap(); - let params = load_params( - &defaults, - None, - &model_path, + let params = load_params(&defaults, None, &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -6839,10 +6826,7 @@ mod tests { layer: explicit, }]) .unwrap(); - let params = load_params( - &resolved, - None, - &model_path, + let params = load_params(&resolved, None, &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -6867,10 +6851,7 @@ mod tests { layer: explicit, }]) .unwrap(); - let params = load_params( - &resolved, - None, - Path::new("/tmp/test-model.mq2r"), + let params = load_params(&resolved, None, Path::new("/tmp/test-model.mq2r").parent().unwrap(), Path::new("/tmp/test-model.mq2r"), 64, Some("q8"), None, @@ -6897,10 +6878,7 @@ mod tests { .unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params( - &resolved, - None, - &model_path, + let params = load_params(&resolved, None, &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -6956,10 +6934,46 @@ mod tests { fs::write(&draft_path, b"draft").unwrap(); let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let resolved = resolved_with_dflash_mode("auto", None); + let params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, + 64, + Some("q8"), + None, + Some("qwen3.5:9b"), + false, + ) + .unwrap(); + assert_eq!(params["dflash_mode"], "auto"); + assert_eq!(params["draft"], draft_path.display().to_string()); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[cfg(unix)] + #[test] + pub(crate) fn load_params_finds_sidecar_in_models_dir_for_symlinked_target() { + // find_model_path canonicalizes, so a target symlinked out of the + // models dir has a parent with no draft in it. The sidecar must be + // looked up in the models dir, not beside the canonical file. + // Measured 2026-09-03: serve --speculation dflash ran AR (tau=None) + // on a symlinked qwen3.8-27b.mq5 while the tag form resolved. + let paths = test_paths("dflash-sidecar-symlink"); + fs::create_dir_all(&paths.models).unwrap(); + let elsewhere = paths.root.join("artifacts"); + fs::create_dir_all(&elsewhere).unwrap(); + let real_model = elsewhere.join("qwen3.5-9b.mq4v2.base.hfq"); + fs::write(&real_model, b"model").unwrap(); + std::os::unix::fs::symlink(&real_model, paths.models.join("qwen3.5-9b.mq4")).unwrap(); + let draft_path = paths.models.join("qwen35-9b-dflash-mq4.hfq"); + fs::write(&draft_path, b"draft").unwrap(); + let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); + let resolved = resolved_with_dflash_mode("on", None); + // What serve/run actually pass: the canonicalized path. + let canonical = fs::canonicalize(paths.models.join("qwen3.5-9b.mq4")).unwrap(); + assert_eq!(canonical.parent().unwrap(), elsewhere.canonicalize().unwrap()); let params = load_params( &resolved, Some(&entry), - &model_path, + &paths.models, + &canonical, 64, Some("q8"), None, @@ -6967,7 +6981,6 @@ mod tests { false, ) .unwrap(); - assert_eq!(params["dflash_mode"], "auto"); assert_eq!(params["draft"], draft_path.display().to_string()); fs::remove_dir_all(&paths.root).unwrap(); } @@ -6981,10 +6994,7 @@ mod tests { fs::write(&model_path, b"model").unwrap(); let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let resolved = resolved_with_dflash_mode("on", None); - let error = load_params( - &resolved, - Some(&entry), - &model_path, + let error = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -7007,10 +7017,7 @@ mod tests { fs::write(&model_path, b"model").unwrap(); let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let resolved = resolved_with_dflash_mode("auto", None); - let params = load_params( - &resolved, - Some(&entry), - &model_path, + let params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -7038,10 +7045,7 @@ mod tests { let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let explicit = "/tmp/custom-draft.hfq"; let resolved = resolved_with_dflash_mode("auto", Some(explicit)); - let params = load_params( - &resolved, - Some(&entry), - &model_path, + let params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -7065,10 +7069,7 @@ mod tests { fs::write(&draft_path, b"draft").unwrap(); let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let resolved = resolved_with_dflash_mode("off", None); - let params = load_params( - &resolved, - Some(&entry), - &model_path, + let params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -7084,10 +7085,7 @@ mod tests { // Resolve under auto, then a final off selector drops it. let resolved = resolved_with_dflash_mode("auto", None); - let mut params = load_params( - &resolved, - Some(&entry), - &model_path, + let mut params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -7116,10 +7114,7 @@ mod tests { fs::write(&model_path, b"model").unwrap(); let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let resolved = resolved_with_dflash_mode("on", None); - let params = load_params( - &resolved, - Some(&entry), - &model_path, + let params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, @@ -7151,10 +7146,7 @@ mod tests { let model_path = PathBuf::from("/tmp/test-model.mq4"); // load_params alone must not carry the draft while config mode is off. - let mut params = load_params( - &resolved, - None, - &model_path, + let mut params = load_params(&resolved, None, &model_path.parent().unwrap(), &model_path, 64, Some("q8"), None, diff --git a/crates/hipfire-cli/src/serve/mod.rs b/crates/hipfire-cli/src/serve/mod.rs index fad7fcaad..138883825 100644 --- a/crates/hipfire-cli/src/serve/mod.rs +++ b/crates/hipfire-cli/src/serve/mod.rs @@ -1143,6 +1143,7 @@ impl ServeRuntime { let mut params = load_params( &resolved, entry, + &self.paths.models, &path, max_tokens, self.kv_override.as_deref(), From e344d6191b99334a4961bf47bf830bdfab04d861 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 02:40:30 +0000 Subject: [PATCH 29/34] style: rustfmt changed files --- crates/hipfire-cli/src/main.rs | 129 +++++++++++++++++++++++++++------ 1 file changed, 108 insertions(+), 21 deletions(-) diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index d4c7a66d6..1e51c83f9 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -1970,7 +1970,13 @@ fn run_command(paths: &Paths, args: RunArgs) -> Result<()> { // Registry sidecar for a final auto/on selector the config-time // load_params could not see (config-off + `run --spec dflash`); a // no-op when load_params already resolved or an explicit draft won. - resolve_dflash_sidecar(&mut params, entry, &paths.models, &model_path, canonical.as_deref())?; + resolve_dflash_sidecar( + &mut params, + entry, + &paths.models, + &model_path, + canonical.as_deref(), + )?; if let Some(window) = args.draft_max { if !(1..=32).contains(&window) { bail!("--draft-max must be between 1 and 32"); @@ -4075,7 +4081,13 @@ fn open_bench_engine( } // Registry sidecar for a final auto/on selector the config-time // load_params could not see (config-off + `bench --spec dflash`). - resolve_dflash_sidecar(&mut params, entry.as_ref(), &paths.models, &path, tag.as_deref())?; + resolve_dflash_sidecar( + &mut params, + entry.as_ref(), + &paths.models, + &path, + tag.as_deref(), + )?; if args.matrix || args.redline { let requested = longest_prefill.max(longest_decode).saturating_add(32); let configured = params["max_seq"].as_u64().unwrap_or(0); @@ -6331,7 +6343,11 @@ mod tests { fs::write(&sidecar_path, b"sidecar").unwrap(); let defaults = resolve(Vec::::new()).unwrap(); - let params = load_params(&defaults, Some(entry), &model_path.parent().unwrap(), &model_path, + let params = load_params( + &defaults, + Some(entry), + &model_path.parent().unwrap(), + &model_path, 64, None, None, @@ -6353,7 +6369,11 @@ mod tests { layer: explicit, }]) .unwrap(); - let params = load_params(&enabled, Some(entry), &model_path.parent().unwrap(), &model_path, + let params = load_params( + &enabled, + Some(entry), + &model_path.parent().unwrap(), + &model_path, 64, None, None, @@ -6371,7 +6391,11 @@ mod tests { pub(crate) fn load_params_forwards_explicit_vmm_backend() { let defaults = resolve(Vec::::new()).unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params(&defaults, None, &model_path.parent().unwrap(), &model_path, + let params = load_params( + &defaults, + None, + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), Some("vmm"), @@ -6386,7 +6410,11 @@ mod tests { pub(crate) fn load_params_defaults_to_schema_contiguous_backend() { let defaults = resolve(Vec::::new()).unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params(&defaults, None, &model_path.parent().unwrap(), &model_path, + let params = load_params( + &defaults, + None, + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -6688,7 +6716,11 @@ mod tests { // Also verify load_params respects explicit kv_backend override over configured vmm. let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params(&resolved, Some(entry), &model_path.parent().unwrap(), &model_path, + let params = load_params( + &resolved, + Some(entry), + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), Some("contiguous"), @@ -6698,7 +6730,11 @@ mod tests { .unwrap(); assert_eq!(params["kv_backend"], "contiguous"); // Without explicit override, load_params uses the resolved vmm. - let params2 = load_params(&resolved, Some(entry), &model_path.parent().unwrap(), &model_path, + let params2 = load_params( + &resolved, + Some(entry), + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -6804,7 +6840,11 @@ mod tests { pub(crate) fn load_params_only_forwards_explicit_deepseek4_expert_fanout() { let model_path = PathBuf::from("/tmp/test-model.mq2r"); let defaults = resolve(Vec::::new()).unwrap(); - let params = load_params(&defaults, None, &model_path.parent().unwrap(), &model_path, + let params = load_params( + &defaults, + None, + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -6826,7 +6866,11 @@ mod tests { layer: explicit, }]) .unwrap(); - let params = load_params(&resolved, None, &model_path.parent().unwrap(), &model_path, + let params = load_params( + &resolved, + None, + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -6851,7 +6895,11 @@ mod tests { layer: explicit, }]) .unwrap(); - let params = load_params(&resolved, None, Path::new("/tmp/test-model.mq2r").parent().unwrap(), Path::new("/tmp/test-model.mq2r"), + let params = load_params( + &resolved, + None, + Path::new("/tmp/test-model.mq2r").parent().unwrap(), + Path::new("/tmp/test-model.mq2r"), 64, Some("q8"), None, @@ -6878,7 +6926,11 @@ mod tests { .unwrap(); let model_path = PathBuf::from("/tmp/test-model.mq4"); - let params = load_params(&resolved, None, &model_path.parent().unwrap(), &model_path, + let params = load_params( + &resolved, + None, + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -6934,7 +6986,11 @@ mod tests { fs::write(&draft_path, b"draft").unwrap(); let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let resolved = resolved_with_dflash_mode("auto", None); - let params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, + let params = load_params( + &resolved, + Some(&entry), + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -6968,7 +7024,10 @@ mod tests { let resolved = resolved_with_dflash_mode("on", None); // What serve/run actually pass: the canonicalized path. let canonical = fs::canonicalize(paths.models.join("qwen3.5-9b.mq4")).unwrap(); - assert_eq!(canonical.parent().unwrap(), elsewhere.canonicalize().unwrap()); + assert_eq!( + canonical.parent().unwrap(), + elsewhere.canonicalize().unwrap() + ); let params = load_params( &resolved, Some(&entry), @@ -6994,7 +7053,11 @@ mod tests { fs::write(&model_path, b"model").unwrap(); let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let resolved = resolved_with_dflash_mode("on", None); - let error = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, + let error = load_params( + &resolved, + Some(&entry), + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -7017,7 +7080,11 @@ mod tests { fs::write(&model_path, b"model").unwrap(); let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let resolved = resolved_with_dflash_mode("auto", None); - let params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, + let params = load_params( + &resolved, + Some(&entry), + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -7045,7 +7112,11 @@ mod tests { let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let explicit = "/tmp/custom-draft.hfq"; let resolved = resolved_with_dflash_mode("auto", Some(explicit)); - let params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, + let params = load_params( + &resolved, + Some(&entry), + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -7069,7 +7140,11 @@ mod tests { fs::write(&draft_path, b"draft").unwrap(); let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let resolved = resolved_with_dflash_mode("off", None); - let params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, + let params = load_params( + &resolved, + Some(&entry), + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -7085,7 +7160,11 @@ mod tests { // Resolve under auto, then a final off selector drops it. let resolved = resolved_with_dflash_mode("auto", None); - let mut params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, + let mut params = load_params( + &resolved, + Some(&entry), + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -7114,7 +7193,11 @@ mod tests { fs::write(&model_path, b"model").unwrap(); let entry = dflash_sidecar_entry("qwen35-9b-dflash-mq4.hfq"); let resolved = resolved_with_dflash_mode("on", None); - let params = load_params(&resolved, Some(&entry), &model_path.parent().unwrap(), &model_path, + let params = load_params( + &resolved, + Some(&entry), + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, @@ -7146,7 +7229,11 @@ mod tests { let model_path = PathBuf::from("/tmp/test-model.mq4"); // load_params alone must not carry the draft while config mode is off. - let mut params = load_params(&resolved, None, &model_path.parent().unwrap(), &model_path, + let mut params = load_params( + &resolved, + None, + &model_path.parent().unwrap(), + &model_path, 64, Some("q8"), None, From b9d3b1266c5c554b91844d1bd881bb3d3985deab Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 02:46:50 +0000 Subject: [PATCH 30/34] fix(serve): report a path-requested model by its path, not its tag Resolving a path-form model to its registry entry changed /health.model from the requested path to the tag. serve_harness's warm probe compares health.model to the launched path by realpath, so it never saw the serve as warm and killed/respawned it every 180 s (measured: two spawn attempts, zero turns). Keep the entry lookup for sidecars and policy; name the served model the way it was requested. --- crates/hipfire-cli/src/serve/mod.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/hipfire-cli/src/serve/mod.rs b/crates/hipfire-cli/src/serve/mod.rs index 138883825..8be843b3f 100644 --- a/crates/hipfire-cli/src/serve/mod.rs +++ b/crates/hipfire-cli/src/serve/mod.rs @@ -1210,9 +1210,19 @@ impl ServeRuntime { .and_then(serde_json::Value::as_bool) .unwrap_or(false); self.current_max_seq = loaded_max_seq; + // Report the model the way it was requested. A path-form + // request now resolves its registry entry (for sidecars and + // tag policy), but clients — serve_harness's warm probe among + // them — compare `/health.model` against the path they asked + // for; a tag only stands in when the request was a tag. + let served_name = if Path::new(model).is_absolute() || model.contains('/') { + model.to_owned() + } else { + tag.unwrap_or_else(|| model.to_owned()) + }; meta.lock() .unwrap_or_else(|error| error.into_inner()) - .current_model = Some(tag.unwrap_or_else(|| model.to_owned())); + .current_model = Some(served_name); } Ok(resolved) } From dbe8896792b3ad6df977d20c7ae4044235d179e3 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Thu, 3 Sep 2026 03:09:05 +0000 Subject: [PATCH 31/34] registry: pair DFlash drafts only where the 24 GB budget closes Measured on a 7900 XTX (serve_harness session_coding, greedy, thinking off, q8 KV): qwen3.8:27b-mq5 + its mq5 draft completes turns 1-2 (tau 3.6/3.5) then dies at turn 3, ctx ~4.9k, with spec_step hipMemCreate out of memory; every later turn is an empty response. The same session under AR passes all 8 turns (13.4k ctx, 38.1 -> 34.4 tok/s). 18.7 GB weights + ~5 GB fixed residency + 1.7 GB draft leaves no room for KV growth. Drop the sidecar from the 27B mq5/mq6 tiers (and qwen3.5:27b-mq6); mq4-tier and below keep theirs (measured 202 tok/s on qwen3.8:27b). --- registry/models.json | 23 +---------------------- registry/v1.json | 35 ----------------------------------- 2 files changed, 1 insertion(+), 57 deletions(-) diff --git a/registry/models.json b/registry/models.json index 41e186411..e7a98751c 100644 --- a/registry/models.json +++ b/registry/models.json @@ -920,9 +920,6 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } - }, - "dflash": { - "file": "qwen38-27b-dflash-mq5.hfq" } }, "qwen3.8:27b-mq5": { @@ -951,9 +948,6 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } - }, - "dflash": { - "file": "qwen38-27b-dflash-mq5.hfq" } }, "qwen3.8:27b-mq5-pro": { @@ -982,9 +976,6 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } - }, - "dflash": { - "file": "qwen38-27b-dflash-mq5.hfq" } }, "qwen3.8:27b-mq6-xt": { @@ -1013,9 +1004,6 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } - }, - "dflash": { - "file": "qwen38-27b-dflash-mq6.hfq" } }, "qwen3.8:27b-mq6": { @@ -1044,9 +1032,6 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } - }, - "dflash": { - "file": "qwen38-27b-dflash-mq6.hfq" } }, "qwen3.8:27b-mq6-pro": { @@ -1075,9 +1060,6 @@ "repeat_penalty": 1.0, "reasoning_effort": "xhigh" } - }, - "dflash": { - "file": "qwen38-27b-dflash-mq6.hfq" } }, "qwen3.5:0.8b-mq6": { @@ -1115,10 +1097,7 @@ "file": "qwen3.5-27b.mq6", "size_gb": 21.4, "min_vram_gb": 24, - "desc": "MQ6, higher quality", - "dflash": { - "file": "qwen35-27b-dflash-mq4.hfq" - } + "desc": "MQ6, higher quality" }, "qwen3.5:9b-mq3": { "repo": "hipfire-models/qwen3.5-9b", diff --git a/registry/v1.json b/registry/v1.json index 495e6743f..23cca9525 100644 --- a/registry/v1.json +++ b/registry/v1.json @@ -1061,11 +1061,6 @@ "reasoning_effort": "xhigh" } }, - "dflash": { - "file": "qwen38-27b-dflash-mq5.hfq", - "sha256": "8a8d3daeaa3788743ef9aedfa1a6cd9961395ecbd88e9b5e2eb981e0506a861f", - "size_bytes": 1434227712 - }, "sha256": "f4760c159f80d5d3ca237593a191dd553790cba0f89de59827f430b23109b39b", "size_bytes": 18183105536, "arch_id": 5, @@ -1098,11 +1093,6 @@ "reasoning_effort": "xhigh" } }, - "dflash": { - "file": "qwen38-27b-dflash-mq5.hfq", - "sha256": "8a8d3daeaa3788743ef9aedfa1a6cd9961395ecbd88e9b5e2eb981e0506a861f", - "size_bytes": 1434227712 - }, "sha256": "c018a0d7510bffbb3788844d5d8f72694464244e16f128b7e34804045324cf25", "size_bytes": 18706435072, "arch_id": 5, @@ -1135,11 +1125,6 @@ "reasoning_effort": "xhigh" } }, - "dflash": { - "file": "qwen38-27b-dflash-mq5.hfq", - "sha256": "8a8d3daeaa3788743ef9aedfa1a6cd9961395ecbd88e9b5e2eb981e0506a861f", - "size_bytes": 1434227712 - }, "sha256": "7a46204f5ce16b260ebb028359a945c8c02f5abe87c040df063db782a99ee7cd", "size_bytes": 19319258112, "arch_id": 5, @@ -1172,11 +1157,6 @@ "reasoning_effort": "xhigh" } }, - "dflash": { - "file": "qwen38-27b-dflash-mq6.hfq", - "sha256": "d190ef2faa953252ac40e9706cf2c6763d095f7817c2c7a0466792fd6dfa9015", - "size_bytes": 1658852352 - }, "sha256": "9d472ddc5b4e11a1986bfc83c54c0dac979a8e1d7186613dd7c7436f69ec8b2f", "size_bytes": 21385849856, "arch_id": 5, @@ -1209,11 +1189,6 @@ "reasoning_effort": "xhigh" } }, - "dflash": { - "file": "qwen38-27b-dflash-mq6.hfq", - "sha256": "d190ef2faa953252ac40e9706cf2c6763d095f7817c2c7a0466792fd6dfa9015", - "size_bytes": 1658852352 - }, "sha256": "b798ea1166fc03a568f6daf8090b20b7d6314af7429951c03d86540385568db8", "size_bytes": 21750254592, "arch_id": 5, @@ -1246,11 +1221,6 @@ "reasoning_effort": "xhigh" } }, - "dflash": { - "file": "qwen38-27b-dflash-mq6.hfq", - "sha256": "d190ef2faa953252ac40e9706cf2c6763d095f7817c2c7a0466792fd6dfa9015", - "size_bytes": 1658852352 - }, "sha256": "58ac3ee645ede2bad2f6f833db1d4abbfdc1850047fdadef036d35a023bb9401", "size_bytes": 22174333952, "arch_id": 5, @@ -1306,11 +1276,6 @@ "size_gb": 21.4, "min_vram_gb": 24, "desc": "MQ6, higher quality", - "dflash": { - "file": "qwen35-27b-dflash-mq4.hfq", - "sha256": "3d428b97c1911a9ad815cc52fbee080306852c1dafad6b1b17bb70bd68010301", - "size_bytes": 919401472 - }, "sha256": "08215e22b04dbe19706d9783c35e54ea92154090c0eca84aeaa71e2041eedd5a", "size_bytes": 21385292800, "arch_id": 5, From 34053f9a620b889cbe7fe6125b38075e14c58f37 Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Fri, 4 Sep 2026 10:11:43 +0000 Subject: [PATCH 32/34] fix(cli): rm keeps a DFlash sidecar that another installed target still declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hw-gate Fable seat on #686 (hardware probe): `hipfire rm qwen3.8:27b-mq4-pro` deleted qwen38-27b-dflash-mq4.hfq while qwen3.8:27b and qwen3.8:27b-mq4-xt — both declaring that sidecar — were still on disk; those siblings then ran AR under dflash_mode=auto or refused under `on`. Same shape for the mq3 draft (3 targets) and the 9B mq4 draft (3 targets). rm now removes a declared sidecar only when no OTHER registry entry declaring the same file still has its own target present in the models dir; otherwise it prints `keeping DFlash sidecar : still declared by `. rm_command is a thin wrapper over rm_with_registry(&RegistryV1) so the rule is unit-testable without env or network. Tests: rm_keeps_shared_dflash_sidecar_while_sibling_target_present, rm_removes_dflash_sidecar_with_last_declaring_target, rm_without_dflash_declaration_leaves_draft_file_alone. --- crates/hipfire-cli/src/main.rs | 207 ++++++++++++++++++++++++++++++++- 1 file changed, 203 insertions(+), 4 deletions(-) diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index 1e51c83f9..1a3157bfe 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -1771,21 +1771,60 @@ fn report_progress(downloaded: u64, total: Option, elapsed: Duration) { fn rm_command(paths: &Paths, args: RmArgs) -> Result<()> { let loaded = load_registry(&paths.registry); - let resolved = loaded.registry.model(&args.model); - let path = find_model_path(paths, &loaded.registry, &args.model) + rm_with_registry(paths, &loaded.registry, args) +} + +/// Remove one model and its sidecars. A declared DFlash draft sidecar is +/// shared: several registry entries can name the same `dflash.file` (e.g. +/// `qwen3.8:27b`, `qwen3.8:27b-mq4-pro`, and `qwen3.8:27b-mq4-xt` all declare +/// `qwen38-27b-dflash-mq4.hfq`). Deleting it while a sibling declarer is +/// still on disk leaves those siblings running AR under `dflash_mode=auto` +/// or refusing to load under `on`, so the sidecar is kept — with one stderr +/// line — whenever any OTHER entry declaring the same file still has its own +/// target file present in the models dir. +fn rm_with_registry(paths: &Paths, registry: &RegistryV1, args: RmArgs) -> Result<()> { + let resolved = registry.model(&args.model); + let path = find_model_path(paths, registry, &args.model) .unwrap_or_else(|| paths.models.join(&args.model)); if !path.is_file() { bail!("model not found: {}", path.display()); } let mut targets = BTreeSet::from([path.clone()]); - if let Some((_, entry)) = resolved { + // A shared DFlash sidecar that must survive this removal: (file, keepers). + let mut kept_sidecar: Option<(String, String)> = None; + if let Some((tag, entry)) = resolved { targets.extend( - [&entry.triattn, &entry.mtp, &entry.dspark, &entry.dflash] + [&entry.triattn, &entry.mtp, &entry.dspark] .into_iter() .flatten() .map(|sidecar| paths.models.join(&sidecar.file)) .filter(|path| path.is_file()), ); + if let Some(sidecar) = entry.dflash.as_ref() { + let sidecar_path = paths.models.join(&sidecar.file); + if sidecar_path.is_file() { + // `models` is a BTreeMap, so keepers list in sorted tag order. + let keepers: Vec<&str> = registry + .models + .iter() + .filter(|(other_tag, other)| { + other_tag.as_str() != tag + && other.file != entry.file + && other + .dflash + .as_ref() + .is_some_and(|other_sidecar| other_sidecar.file == sidecar.file) + && paths.models.join(&other.file).is_file() + }) + .map(|(other_tag, _)| other_tag.as_str()) + .collect(); + if keepers.is_empty() { + targets.insert(sidecar_path); + } else { + kept_sidecar = Some((sidecar.file.clone(), keepers.join(", "))); + } + } + } } if let (Some(parent), Some(file)) = ( path.parent(), @@ -1829,6 +1868,9 @@ fn rm_command(paths: &Paths, args: RmArgs) -> Result<()> { .with_context(|| format!("failed to remove {}", target.display()))?; println!("removed {}", target.display()); } + if let Some((file, keepers)) = kept_sidecar { + eprintln!("keeping DFlash sidecar {file}: still declared by {keepers}"); + } Ok(()) } @@ -7209,6 +7251,163 @@ mod tests { fs::remove_dir_all(&paths.root).unwrap(); } + /// Minimal in-memory registry for rm tests: (tag, target file, dflash file). + fn rm_test_registry(entries: &[(&str, &str, Option<&str>)]) -> RegistryV1 { + let mut models = BTreeMap::new(); + for (tag, file, dflash) in entries { + models.insert( + (*tag).to_owned(), + ModelEntry { + repo: "test/repo".into(), + file: (*file).to_owned(), + size_gb: 1.0, + min_vram_gb: 1.0, + desc: "rm test".into(), + dflash: dflash.map(|draft| hipfire_registry::Sidecar { + file: draft.into(), + sha256: None, + size_bytes: None, + }), + ..Default::default() + }, + ); + } + RegistryV1 { + schema_version: hipfire_registry::REGISTRY_SCHEMA_VERSION, + generated_at: "test".into(), + _comment: None, + models, + aliases: BTreeMap::new(), + } + } + + #[test] + fn rm_keeps_shared_dflash_sidecar_while_sibling_target_present() { + // The hw-gate regression on PR #686: `qwen3.8:27b`, `qwen3.8:27b-mq4-pro`, + // and `qwen3.8:27b-mq4-xt` all declare `qwen38-27b-dflash-mq4.hfq`. + // Removing one target must keep the sidecar while a sibling declarer's + // target file is still on disk. + let paths = test_paths("rm-shared-sidecar-kept"); + fs::create_dir_all(&paths.models).unwrap(); + for file in [ + "qwen3.8-27b.mq4", + "qwen3.8-27b.mq4-pro", + "qwen38-27b-dflash-mq4.hfq", + ] { + fs::write(paths.models.join(file), b"fixture").unwrap(); + } + let registry = rm_test_registry(&[ + ( + "qwen3.8:27b", + "qwen3.8-27b.mq4", + Some("qwen38-27b-dflash-mq4.hfq"), + ), + ( + "qwen3.8:27b-mq4-pro", + "qwen3.8-27b.mq4-pro", + Some("qwen38-27b-dflash-mq4.hfq"), + ), + ( + "qwen3.8:27b-mq4-xt", + "qwen3.8-27b.mq4-xt", + Some("qwen38-27b-dflash-mq4.hfq"), + ), + ]); + rm_with_registry( + &paths, + ®istry, + RmArgs { + model: "qwen3.8:27b-mq4-pro".into(), + yes: true, + }, + ) + .unwrap(); + assert!( + !paths.models.join("qwen3.8-27b.mq4-pro").exists(), + "removed target is gone" + ); + assert!( + paths.models.join("qwen3.8-27b.mq4").exists(), + "sibling target stays" + ); + assert!( + paths.models.join("qwen38-27b-dflash-mq4.hfq").exists(), + "shared sidecar is kept while a sibling declarer is on disk" + ); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] + fn rm_removes_dflash_sidecar_with_last_declaring_target() { + // `qwen3.8:27b` still declares the sidecar in the registry, but its + // target file was never downloaded — a registry row alone must not pin + // the sidecar once the last on-disk declarer is removed. + let paths = test_paths("rm-shared-sidecar-last"); + fs::create_dir_all(&paths.models).unwrap(); + for file in ["qwen3.8-27b.mq4-pro", "qwen38-27b-dflash-mq4.hfq"] { + fs::write(paths.models.join(file), b"fixture").unwrap(); + } + let registry = rm_test_registry(&[ + ( + "qwen3.8:27b", + "qwen3.8-27b.mq4", + Some("qwen38-27b-dflash-mq4.hfq"), + ), + ( + "qwen3.8:27b-mq4-pro", + "qwen3.8-27b.mq4-pro", + Some("qwen38-27b-dflash-mq4.hfq"), + ), + ]); + rm_with_registry( + &paths, + ®istry, + RmArgs { + model: "qwen3.8:27b-mq4-pro".into(), + yes: true, + }, + ) + .unwrap(); + assert!( + !paths.models.join("qwen3.8-27b.mq4-pro").exists(), + "removed target is gone" + ); + assert!( + !paths.models.join("qwen38-27b-dflash-mq4.hfq").exists(), + "sidecar goes with the last on-disk declarer" + ); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] + fn rm_without_dflash_declaration_leaves_draft_file_alone() { + // A tag with no dflash declaration keeps master behaviour: its target + // goes, and a draft file it never declared is not an rm target. + let paths = test_paths("rm-no-dflash"); + fs::create_dir_all(&paths.models).unwrap(); + fs::write(paths.models.join("qwen3.8-27b.mq4"), b"fixture").unwrap(); + fs::write(paths.models.join("qwen38-27b-dflash-mq4.hfq"), b"draft").unwrap(); + let registry = rm_test_registry(&[("qwen3.8:27b", "qwen3.8-27b.mq4", None)]); + rm_with_registry( + &paths, + ®istry, + RmArgs { + model: "qwen3.8:27b".into(), + yes: true, + }, + ) + .unwrap(); + assert!( + !paths.models.join("qwen3.8-27b.mq4").exists(), + "removed target is gone" + ); + assert!( + paths.models.join("qwen38-27b-dflash-mq4.hfq").exists(), + "an undeclared draft file is never an rm target" + ); + fs::remove_dir_all(&paths.root).unwrap(); + } + #[test] fn run_spec_dflash_projects_inherited_draft_after_config_off() { // Reviewer case: resolved config leaves DFlash off, but an inherited From 35d209a93cb0ff9199e1525c1c24eeec8f2823df Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Fri, 4 Sep 2026 11:19:37 +0000 Subject: [PATCH 33/34] fix(loader): drain the GPU pool when a qwen35 load is rolled back hw-gate Fable seat on #686: a daemon that went through one refused dflash_mode=on load held ~5.17 GB more VRAM with the next model resident than a clean daemon, compounding under serve's lazy retry. Cause: free_qwen35_bundle returns every buffer to the Gpu pool (free_tensor has no size cap, dispatch.rs:3261) and only unload_model drains it (lib.rs:3871-3872); a load that fails in finish_qwen35_load never reaches unload_model, so the whole target stayed pooled and the next load reused only the same-sized buckets. rollback_unfinished_qwen35 now mirrors unload_model: invalidate_graph_state + drain_pool after the frees. Covers all three callers: CASK eviction failure, dflash_mode=on draft failure, and the pre-existing mtp=on head failure (same leak, older than this PR). --- crates/hipfire-loader/src/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/hipfire-loader/src/lib.rs b/crates/hipfire-loader/src/lib.rs index 762ecf43f..77cdd524b 100644 --- a/crates/hipfire-loader/src/lib.rs +++ b/crates/hipfire-loader/src/lib.rs @@ -1676,6 +1676,14 @@ pub(crate) fn parse_state_quant( // ─── Core arch carrier load ───────────────────────────────────────────── /// Hard-error free for unfinished qwen35 finish path: bundle + optional VL. +/// +/// `free_qwen35_bundle` returns every buffer to the GPU pool; only a drain +/// hands the VRAM back to the driver, and `unload_model` is normally the +/// one that drains. A load that fails here never reaches `unload_model`, +/// so without the drain the whole target (~15 GB on a 27B) stayed pooled — +/// the hw-gate Fable seat measured ~5 GB retained after a refused +/// `dflash_mode=on` load on top of the next resident model, compounding on +/// every lazy serve retry. Mirror `unload_model`: invalidate graphs, drain. fn rollback_unfinished_qwen35( err: String, bundle: Qwen35Bundle, @@ -1689,6 +1697,8 @@ fn rollback_unfinished_qwen35( if let Some(vw) = vision_weights { vw.free_gpu(gpu); } + gpu.invalidate_graph_state(); + gpu.drain_pool(); if notes.is_empty() { err } else { From c86712d438199ba5d576d33c76840ecffff1064a Mon Sep 17 00:00:00 2001 From: Kaden Schutt Date: Fri, 4 Sep 2026 16:08:29 +0000 Subject: [PATCH 34/34] fix(cli): a path gets registry identity only when it is the installed artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hw-gate Fable seat on #686 (run 33889233321): resolve_tag matched any path by its final component, so `hipfire rm /elsewhere/qwen3.6-27b.mq4` — a different file sharing the basename — resolved to the qwen3.6:27b entry and deleted the installed model's triattn and DFlash sidecars while the model itself stayed; a same-basename foreign file loaded by path inherited the entry's sidecar and kv/max_seq policy. registry: resolve_tag drops the file_name() arm (a path is not a tag); bare entry.file names still resolve; new entry_for_file for exact matches. cli: registry_entry_for_path(paths, registry, input) — a path-form input resolves only when canonicalize(input) == canonicalize(models_dir/entry.file) for some entry, so the -xt symlink into ~/qcal still matches by target and a lookalike elsewhere never does. rm, run, bench, and serve (incl. the pre-warm thread) route through it. dflash_mode=on on a path with no entry and no explicit draft now fails closed: "DFlash draft required (dflash_mode=on) but is not a registry-managed artifact; pass developer.dflash_draft or use the registry tag" (auto still serves it as a bare artifact) — closes Fable's earlier note that `on` ran AR silently there. The daemon only consumes CLI-lowered params and needs no change. cargo test -p hipfire-registry: 21 passed (2 new); -p hipfire-cli: 220 passed (4 new). --- crates/hipfire-cli/src/main.rs | 216 +++++++++++++++++++++++++++- crates/hipfire-cli/src/serve/mod.rs | 4 +- crates/hipfire-registry/src/lib.rs | 82 +++++++++-- 3 files changed, 281 insertions(+), 21 deletions(-) diff --git a/crates/hipfire-cli/src/main.rs b/crates/hipfire-cli/src/main.rs index 1a3157bfe..d7f7653f2 100644 --- a/crates/hipfire-cli/src/main.rs +++ b/crates/hipfire-cli/src/main.rs @@ -1783,7 +1783,7 @@ fn rm_command(paths: &Paths, args: RmArgs) -> Result<()> { /// line — whenever any OTHER entry declaring the same file still has its own /// target file present in the models dir. fn rm_with_registry(paths: &Paths, registry: &RegistryV1, args: RmArgs) -> Result<()> { - let resolved = registry.model(&args.model); + let resolved = registry_entry_for_path(paths, registry, &args.model); let path = find_model_path(paths, registry, &args.model) .unwrap_or_else(|| paths.models.join(&args.model)); if !path.is_file() { @@ -1877,8 +1877,7 @@ fn rm_with_registry(paths: &Paths, registry: &RegistryV1, args: RmArgs) -> Resul fn run_command(paths: &Paths, args: RunArgs) -> Result<()> { let loaded_registry = load_registry(&paths.registry); let registry = &loaded_registry.registry; - let (canonical, entry) = registry - .model(&args.model) + let (canonical, entry) = registry_entry_for_path(paths, registry, &args.model) .map(|(tag, entry)| (Some(tag.to_owned()), Some(entry))) .unwrap_or((None, None)); let mut model_path = find_model_path(paths, registry, &args.model); @@ -2451,6 +2450,36 @@ fn scan_local_models(local: &[PathBuf], search: &str, mode: MatchMode) -> Vec( + paths: &Paths, + registry: &'registry RegistryV1, + input: &str, +) -> Option<(&'registry str, &'registry ModelEntry)> { + let candidate = Path::new(input); + if input.contains('/') || input.contains('\\') || candidate.is_file() { + let canonical_input = fs::canonicalize(candidate).ok()?; + return registry.models.iter().find_map(|(tag, entry)| { + let canonical_installed = fs::canonicalize(paths.models.join(&entry.file)).ok()?; + (canonical_installed == canonical_input).then(|| (tag.as_str(), entry)) + }); + } + registry.model(input) +} + pub(crate) fn find_model_path( paths: &Paths, registry: &RegistryV1, @@ -2647,6 +2676,9 @@ pub(crate) fn load_params( /// or `on`, no explicit draft is set (`params["draft"]`, e.g. from /// `developer.dflash_draft`), and `entry.dflash` names a pulled file, wire /// it: `on` without the file fails closed, `auto` logs one line and runs AR. +/// With no entry at all the artifact is not registry-managed (e.g. a path +/// that merely shares a basename with an entry file): `auto` runs AR as a +/// bare artifact, but `on` fails closed instead of silently running AR. /// The sidecar is looked up in `models_dir` first — `find_model_path` /// canonicalizes, so a symlinked target's parent is wherever the artifact /// really lives, not the models directory the draft was pulled into — then @@ -2670,6 +2702,12 @@ fn resolve_dflash_sidecar( return Ok(()); } let Some(sidecar) = entry.and_then(|entry| entry.dflash.as_ref()) else { + if params["dflash_mode"].as_str() == Some("on") && model_path.is_file() { + bail!( + "DFlash draft required (dflash_mode=on) but {} is not a registry-managed artifact; pass developer.dflash_draft or use the registry tag", + model_path.display() + ); + } return Ok(()); }; let beside_target = model_path.parent().unwrap_or_else(|| Path::new(".")); @@ -4059,8 +4097,7 @@ fn open_bench_engine( serde_json::Value, )> { let registry = load_registry(&paths.registry).registry; - let (tag, entry) = registry - .model(&args.model) + let (tag, entry) = registry_entry_for_path(paths, ®istry, &args.model) .map(|(tag, entry)| (Some(tag.to_owned()), Some(entry.clone()))) .unwrap_or((None, None)); let mut path = find_model_path(paths, ®istry, &args.model); @@ -7408,6 +7445,175 @@ mod tests { fs::remove_dir_all(&paths.root).unwrap(); } + #[test] + fn rm_foreign_same_basename_path_removes_only_that_file() { + // PR #686 hw-gate regression: `hipfire rm /elsewhere/qwen3.6-27b.mq4` + // basename-matched the `qwen3.6:27b` entry and deleted the installed + // target plus its sidecars while the installed target stayed. A path + // that merely shares a basename gets no registry identity: only that + // file goes. + let paths = test_paths("rm-foreign-basename"); + fs::create_dir_all(&paths.models).unwrap(); + for file in ["qwen3.6-27b.mq4", "qwen36-27b-dflash-mq4.hfq"] { + fs::write(paths.models.join(file), b"fixture").unwrap(); + } + let elsewhere = paths.root.join("elsewhere"); + fs::create_dir_all(&elsewhere).unwrap(); + let foreign = elsewhere.join("qwen3.6-27b.mq4"); + fs::write(&foreign, b"lookalike").unwrap(); + let registry = rm_test_registry(&[( + "qwen3.6:27b", + "qwen3.6-27b.mq4", + Some("qwen36-27b-dflash-mq4.hfq"), + )]); + rm_with_registry( + &paths, + ®istry, + RmArgs { + model: foreign.display().to_string(), + yes: true, + }, + ) + .unwrap(); + assert!(!foreign.exists(), "the named foreign file is removed"); + assert!( + paths.models.join("qwen3.6-27b.mq4").exists(), + "installed target stays" + ); + assert!( + paths.models.join("qwen36-27b-dflash-mq4.hfq").exists(), + "installed sidecars stay" + ); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] + fn rm_installed_path_removes_target_and_sidecars() { + // The same removal by installed path keeps master behaviour: target + // plus declared sidecars go. + let paths = test_paths("rm-installed-path"); + fs::create_dir_all(&paths.models).unwrap(); + for file in ["qwen3.6-27b.mq4", "qwen36-27b-dflash-mq4.hfq"] { + fs::write(paths.models.join(file), b"fixture").unwrap(); + } + let registry = rm_test_registry(&[( + "qwen3.6:27b", + "qwen3.6-27b.mq4", + Some("qwen36-27b-dflash-mq4.hfq"), + )]); + rm_with_registry( + &paths, + ®istry, + RmArgs { + model: paths.models.join("qwen3.6-27b.mq4").display().to_string(), + yes: true, + }, + ) + .unwrap(); + assert!( + !paths.models.join("qwen3.6-27b.mq4").exists(), + "installed target is gone" + ); + assert!( + !paths.models.join("qwen36-27b-dflash-mq4.hfq").exists(), + "declared sidecar goes with its target" + ); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn registry_entry_for_path_matches_symlinked_artifact() { + // `qwen3.8-27b.mq4-xt` is a symlink out of the models dir: the input + // and the installed entry file canonicalize to the same target, so + // the entry (and its sidecar) still resolve. A same-basename file + // elsewhere canonicalizes elsewhere and gets no entry. + let paths = test_paths("registry-entry-symlink"); + fs::create_dir_all(&paths.models).unwrap(); + let elsewhere = paths.root.join("qcal"); + fs::create_dir_all(&elsewhere).unwrap(); + let real = elsewhere.join("qwen3.8-27b-weights.mq4"); + fs::write(&real, b"weights").unwrap(); + std::os::unix::fs::symlink(&real, paths.models.join("qwen3.8-27b.mq4-xt")).unwrap(); + let foreign_dir = paths.root.join("foreign"); + fs::create_dir_all(&foreign_dir).unwrap(); + let foreign = foreign_dir.join("qwen3.8-27b.mq4-xt"); + fs::write(&foreign, b"lookalike").unwrap(); + let registry = rm_test_registry(&[("qwen3.8:27b-mq4-xt", "qwen3.8-27b.mq4-xt", None)]); + let installed = paths.models.join("qwen3.8-27b.mq4-xt").display().to_string(); + let (tag, _) = registry_entry_for_path(&paths, ®istry, &installed) + .expect("symlinked installed artifact must match by canonical target"); + assert_eq!(tag, "qwen3.8:27b-mq4-xt"); + assert!( + registry_entry_for_path(&paths, ®istry, &foreign.display().to_string()).is_none(), + "same-basename foreign file gets no registry entry" + ); + assert!( + registry_entry_for_path(&paths, ®istry, "qwen3.8:27b-mq4-xt").is_some(), + "tag form still resolves" + ); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] + fn load_params_dflash_on_fails_closed_for_non_registry_artifact() { + // `on` + a path with no registry entry + no explicit draft fails + // closed with the not-managed message instead of silently running AR + // (Fable's earlier note on unregistered basenames). `auto` on the + // same file still runs AR as a bare artifact. + let paths = test_paths("dflash-on-foreign-path"); + fs::create_dir_all(&paths.models).unwrap(); + let foreign_dir = paths.root.join("elsewhere"); + fs::create_dir_all(&foreign_dir).unwrap(); + let foreign = foreign_dir.join("qwen3.6-27b.mq4"); + fs::write(&foreign, b"lookalike").unwrap(); + assert!( + registry_entry_for_path( + &paths, + &rm_test_registry(&[("qwen3.6:27b", "qwen3.6-27b.mq4", None)]), + &foreign.display().to_string() + ) + .is_none(), + "precondition: foreign path has no entry" + ); + let resolved = resolved_with_dflash_mode("on", None); + let error = load_params( + &resolved, + None, + &paths.models, + &foreign, + 64, + Some("q8"), + None, + None, + false, + ) + .expect_err("on without registry identity must fail closed"); + let message = format!("{error:#}"); + assert!(message.contains("not a registry-managed artifact"), "{message}"); + assert!(message.contains("developer.dflash_draft"), "{message}"); + let resolved = resolved_with_dflash_mode("auto", None); + let params = load_params( + &resolved, + None, + &paths.models, + &foreign, + 64, + Some("q8"), + None, + None, + false, + ) + .unwrap(); + assert_eq!(params["dflash_mode"], "auto"); + assert!( + params.get("draft").is_none(), + "auto on a bare artifact runs AR" + ); + fs::remove_dir_all(&paths.root).unwrap(); + } + + #[test] fn run_spec_dflash_projects_inherited_draft_after_config_off() { // Reviewer case: resolved config leaves DFlash off, but an inherited diff --git a/crates/hipfire-cli/src/serve/mod.rs b/crates/hipfire-cli/src/serve/mod.rs index 8be843b3f..0253e5f99 100644 --- a/crates/hipfire-cli/src/serve/mod.rs +++ b/crates/hipfire-cli/src/serve/mod.rs @@ -1107,9 +1107,7 @@ impl ServeRuntime { meta: &Mutex, minimum_max_seq: Option, ) -> Result { - let (tag, entry) = self - .registry - .model(model) + let (tag, entry) = crate::registry_entry_for_path(&self.paths, &self.registry, model) .map(|(tag, entry)| (Some(tag.to_owned()), Some(entry))) .unwrap_or((None, None)); let mut path = find_model_path(&self.paths, &self.registry, model); diff --git a/crates/hipfire-registry/src/lib.rs b/crates/hipfire-registry/src/lib.rs index 322d5832d..fbce30af5 100644 --- a/crates/hipfire-registry/src/lib.rs +++ b/crates/hipfire-registry/src/lib.rs @@ -424,22 +424,31 @@ impl RegistryV1 { if self.models.contains_key(&qwen) { return qwen; } - // A bare file name matches its entry; so does a path whose final - // component is that file name (`hipfire serve --model ~/.hipfire/models/x.mq4` - // must see the same sidecars as `hipfire serve --model `). - let file_name = std::path::Path::new(input) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(input); + // A bare file name matches its entry. A path never does: matching on + // `file_name()` let a lookalike file outside the models directory + // inherit the installed artifact's identity (sidecars, kv/max_seq + // policy, rm targets). Callers that know the models directory resolve + // paths with canonical comparison instead (`registry_entry_for_path` + // in hipfire-cli); a bare file name carries no directory to confuse. self.models .iter() .find_map(|(tag, entry)| { - (entry.file == normalized || entry.file == input || entry.file == file_name) - .then(|| tag.clone()) + (entry.file == normalized || entry.file == input).then(|| tag.clone()) }) .unwrap_or(normalized) } + /// Exact `entry.file` match for callers that already established the input + /// is the installed artifact (e.g. via canonical path comparison against + /// the models directory). Unlike [`RegistryV1::model`], this never applies + /// tag/alias normalization: `file_name` must be the bare file as stored. + pub fn entry_for_file(&self, file_name: &str) -> Option<(&str, &ModelEntry)> { + self.models + .iter() + .find(|(_, entry)| entry.file == file_name) + .map(|(tag, entry)| (tag.as_str(), entry)) + } + pub fn model(&self, input: &str) -> Option<(&str, &ModelEntry)> { let tag = self.resolve_tag(input); self.models @@ -1080,16 +1089,21 @@ mod tests { "qwen3.8:27b-mq4-xt" ); assert_eq!(registry.resolve_tag("qwen3.8:fast"), "qwen3.8:27b-mq4-xt"); - // A path to the artifact resolves like its file name, so `serve - // --model ~/.hipfire/models/` sees the entry's sidecars. + // A path is not a tag: even a path into the models directory resolves + // to itself here. Callers that know the models directory establish + // identity with canonical path comparison (`registry_entry_for_path` + // in hipfire-cli), so `serve --model ~/.hipfire/models/` still + // sees the entry's sidecars without letting a same-basename lookalike + // elsewhere inherit them (PR #686 hw-gate regression). assert_eq!( registry.resolve_tag("/home/u/.hipfire/models/qwen3.8-27b.mq5"), - "qwen3.8:27b-mq5" + "/home/u/.hipfire/models/qwen3.8-27b.mq5" ); assert_eq!( registry.resolve_tag("/home/u/.hipfire/models/qwen3.8-27b.mq4"), - "qwen3.8:27b" + "/home/u/.hipfire/models/qwen3.8-27b.mq4" ); + assert!(registry.model("/home/u/.hipfire/models/qwen3.8-27b.mq4").is_none()); assert_eq!(registry.resolve_tag("deepseek4"), "deepseek-v4-flash"); assert_eq!(registry.resolve_tag("deepseek4:0731"), "deepseek-v4-flash"); @@ -1148,6 +1162,48 @@ mod tests { ); } + #[test] + fn path_inputs_never_resolve_to_registry_tags() { + // PR #686 hw-gate regression: `hipfire rm /elsewhere/qwen3.6-27b.mq4` + // basename-matched the `qwen3.6:27b` entry and deleted the installed + // sidecars. A path — anywhere, including the models directory itself — + // is not a tag and resolves to itself. + let raw = r#"{ + "schema_version":1, + "generated_at":"now", + "models":{"qwen3.6:27b":{"repo":"x","file":"qwen3.6-27b.mq4","size_gb":1,"min_vram_gb":1,"desc":"x"}}, + "aliases":{} + }"#; + let registry = RegistryV1::parse(raw, "test").unwrap(); + assert_eq!( + registry.resolve_tag("/elsewhere/qwen3.6-27b.mq4"), + "/elsewhere/qwen3.6-27b.mq4" + ); + assert!(registry.model("/elsewhere/qwen3.6-27b.mq4").is_none()); + // The bare file name still resolves: it carries no directory to confuse + // with the installed artifact. + assert_eq!(registry.resolve_tag("qwen3.6-27b.mq4"), "qwen3.6:27b"); + assert!(registry.model("qwen3.6-27b.mq4").is_some()); + } + + #[test] + fn entry_for_file_matches_exact_bare_names_only() { + let raw = r#"{ + "schema_version":1, + "generated_at":"now", + "models":{"qwen3.6:27b":{"repo":"x","file":"qwen3.6-27b.mq4","size_gb":1,"min_vram_gb":1,"desc":"x"}}, + "aliases":{"qwen36":"qwen3.6:27b"} + }"#; + let registry = RegistryV1::parse(raw, "test").unwrap(); + let (tag, _) = registry.entry_for_file("qwen3.6-27b.mq4").unwrap(); + assert_eq!(tag, "qwen3.6:27b"); + assert!(registry.entry_for_file("/elsewhere/qwen3.6-27b.mq4").is_none()); + assert!(registry.entry_for_file("qwen3.6:27b").is_none()); + assert!(registry.entry_for_file("qwen36").is_none()); + assert!(registry.entry_for_file("other.mq4").is_none()); + } + + #[test] fn recommended_settings_lower_the_full_sampling_contract_to_config() { let settings = RecommendedSettings {