diff --git a/crates/apr-cli/src/commands/parity.rs b/crates/apr-cli/src/commands/parity.rs index dc5e9787b7..677f56e98b 100644 --- a/crates/apr-cli/src/commands/parity.rs +++ b/crates/apr-cli/src/commands/parity.rs @@ -474,3 +474,5 @@ include!("parity_03.rs"); include!("parity_spc_tests.rs"); include!("parity_diagnose_tests.rs"); include!("parity_refusal_tests.rs"); +include!("parity_hybrid.rs"); +include!("parity_hybrid_tests.rs"); diff --git a/crates/apr-cli/src/commands/parity_03.rs b/crates/apr-cli/src/commands/parity_03.rs index 3d0f0b1e62..380fa4aa80 100644 --- a/crates/apr-cli/src/commands/parity_03.rs +++ b/crates/apr-cli/src/commands/parity_03.rs @@ -7,7 +7,20 @@ fn refuse_unroutable(mapped: &realizar::gguf::MappedGGUFModel, json: bool) -> Result<()> { let arch = mapped.model.architecture().unwrap_or_default().to_string(); let names: Vec<&str> = mapped.model.tensors.iter().map(|t| t.name.as_str()).collect(); - let Some(refusal) = parity_refusal_for(&arch, names) else { + // #3477: a hybrid this build CAN route may still carry a quantization its + // GPU upload has no kernel for. Both refusals are header-only, so both + // happen here, before a weight is materialized. + let refusal = parity_refusal_for(&arch, names).or_else(|| { + hybrid_quant_refusal( + &arch, + mapped + .model + .tensors + .iter() + .map(|t| (t.name.as_str(), t.qtype)), + ) + }); + let Some(refusal) = refusal else { return Ok(()); }; eprintln!(); @@ -97,6 +110,24 @@ pub fn run(file: &Path, prompt: &str, _assert: bool, verbose: bool, json: bool) let tokens = mapped.model.encode(prompt).unwrap_or_else(|| vec![1u32]); + eprintln!(); + eprintln!(" {} {}", "Model:".white().bold(), file.display()); + eprintln!(" {} {:?}", "Prompt:".white().bold(), prompt); + eprintln!( + " {} {} tokens: {:?}", + "Tokens:".white().bold(), + tokens.len(), + &tokens[..tokens.len().min(20)], + ); + + // #3477: the Qwen3.5 hybrid is no longer refused above (#3517) because it + // HAS both forwards — but it is not the dense pair. `OwnedQuantizedModel:: + // from_mapped` below would hand it straight to `dense_loader_refusal`, + // which is the FAIL this dispatch replaces with a measurement. + if parity_arm(mapped.model.architecture().unwrap_or_default()) == ParityArm::Hybrid { + return run_hybrid(file, &mapped, &tokens, verbose, json); + } + let model = OwnedQuantizedModel::from_mapped(&mapped) .map_err(|e| CliError::ValidationFailed(format!("Failed to create model: {e}")))?; @@ -107,15 +138,6 @@ pub fn run(file: &Path, prompt: &str, _assert: bool, verbose: bool, json: bool) let (head_dim, kv_dim, gqa_ratio) = head_geometry(hidden_dim, num_heads, kv_heads); let num_layers = config.num_layers; - eprintln!(); - eprintln!(" {} {}", "Model:".white().bold(), file.display()); - eprintln!(" {} {:?}", "Prompt:".white().bold(), prompt); - eprintln!( - " {} {} tokens: {:?}", - "Tokens:".white().bold(), - tokens.len(), - &tokens[..tokens.len().min(20)], - ); eprintln!( " {} hidden={} heads={} kv_heads={} head_dim={} GQA={} layers={} vocab={}", "Arch:".white().bold(), @@ -168,79 +190,18 @@ pub fn run(file: &Path, prompt: &str, _assert: bool, verbose: bool, json: bool) print_row(&m); if verbose && m.verdict().is_fail() { - eprintln!( - "{} {} mean_diff={:.6} rmse={:.6} oos={}/{} {}", - "│".dimmed(), - "".dimmed(), - m.mean_abs_diff, - m.rmse, - m.out_of_spec_count, - m.vocab_size, - "│".dimmed(), - ); + print_verbose_failure(&m); } all_metrics.push(m); } - // GH-636: JSON output path — emit structured data instead of SPC table - if json { - let metrics_json: Vec = all_metrics - .iter() - .map(|m| { - serde_json::json!({ - "position": m.position, - "token_id": m.token_id, - "cpu_argmax": m.cpu_argmax, - "gpu_argmax": m.gpu_argmax, - "max_abs_diff": m.max_abs_diff, - "mean_abs_diff": m.mean_abs_diff, - "cosine_similarity": m.cosine_similarity, - "kl_divergence": m.kl_divergence, - "sigma_level": m.sigma_level, - "cpk": m.cpk(), - "verdict": format!("{:?}", m.verdict()), - }) - }) - .collect(); - let has_failures = all_metrics.iter().any(|m| m.verdict().is_fail()); - let summary = serde_json::json!({ - "model": file.display().to_string(), - "tokens": all_metrics.len(), - "passed": all_metrics.iter().filter(|m| !m.verdict().is_fail()).count(), - "failed": all_metrics.iter().filter(|m| m.verdict().is_fail()).count(), - "parity": !has_failures, - "metrics": metrics_json, - }); - println!("{}", serde_json::to_string_pretty(&summary).unwrap_or_default()); - return if has_failures { - Err(CliError::ValidationFailed( - "PARITY DISPROVEN: GPU/CPU divergence exceeds tolerance".to_string(), - )) - } else { - Ok(()) - }; - } - - print_footer(); - - // ── Summary statistics ────────────────────────────────────────────────── - print_summary(&all_metrics); - - // ── Auto-diagnosis ────────────────────────────────────────────────────── - auto_diagnose(&all_metrics, hidden_dim, num_heads, kv_heads); - - // ── Exit code ─────────────────────────────────────────────────────────── - // GH-615: Exit non-zero when parity is disproven — exit code must match display. - // Previously required --assert flag, but display already says "PARITY DISPROVEN". - let has_failures = all_metrics.iter().any(|m| m.verdict().is_fail()); - if has_failures { - Err(CliError::ValidationFailed( - "PARITY DISPROVEN: GPU/CPU divergence exceeds tolerance".to_string(), - )) - } else { - Ok(()) - } + // ── Report ────────────────────────────────────────────────────────────── + // GH-636 (JSON shape), GH-615 (exit code matches the display) and the + // summary/auto-diagnosis, shared verbatim with the #3477 hybrid arm + // (`parity_hybrid.rs`) so the C14 row cannot depend on which pair of + // forwards produced the metrics. + emit_parity_outcome(file, &all_metrics, hidden_dim, num_heads, kv_heads, json) } #[cfg(not(feature = "cuda"))] diff --git a/crates/apr-cli/src/commands/parity_hybrid.rs b/crates/apr-cli/src/commands/parity_hybrid.rs new file mode 100644 index 0000000000..da69afeb49 --- /dev/null +++ b/crates/apr-cli/src/commands/parity_hybrid.rs @@ -0,0 +1,304 @@ + + +// ═══════════════════════════════════════════════════════════════════════════════ +// #3477: THE HYBRID ARM — Qwen3.5 (Gated DeltaNet) CPU vs CUDA +// ═══════════════════════════════════════════════════════════════════════════════ +// +// `apr parity` had ONE loop: the dense `forward_single_with_cache` against +// `forward_gpu_resident`. Everything it could not route was refused from the +// header (PMAT-1098). #3517 stopped refusing `qwen35` — the hybrid now has a GPU +// forward (`Qwen35CudaModel`, #3090) as well as a CPU one (`Qwen35Model`, #3091) +// — but the dense loop still asked `OwnedQuantizedModel::from_mapped` for the +// layers, and the dense loader refuses the hybrid. So a REFUSAL (exit 12, +// UNMEASURED-TOOL in the C14 row) turned into a plain FAIL naming the MODEL. +// The missing piece was never a refusal: it is this second pair of forwards. + +/// Which pair of forwards measures an architecture (#3477). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ParityArm { + /// `Qwen35Model::forward_single_qwen35` vs `Qwen35CudaModel::forward_single`. + Hybrid, + /// `OwnedQuantizedModel::forward_single_with_cache` vs `forward_gpu_resident`. + Dense, +} + +/// Pure: the arm `run` dispatches `architecture` to. +/// +/// Split out of `run` so the choice is falsifiable with no GPU and no model +/// file. The predicate is realizar's own dispatch predicate — the same one +/// `apr run`, `apr chat`, `apr qa`, `apr ptx-map` and the parity refusal ask — +/// so which loop parity measures cannot drift from which forward actually +/// serves the tokens. +#[cfg(feature = "inference")] +#[allow(dead_code)] // the only non-test caller is `run`, which is `cuda`-gated +pub(crate) fn parity_arm(architecture: &str) -> ParityArm { + if realizar::gguf::hybrid_forward_handles(architecture) { + ParityArm::Hybrid + } else { + ParityArm::Dense + } +} + +/// The refusal for a hybrid GGUF whose quantization the GPU weight upload has +/// no kernel for, or `None`. +/// +/// The hybrid arm exists because BOTH forwards exist — but the CPU forward +/// decodes quantizations the GPU upload cannot take (the 0.8B file on this box +/// is IQ4_XS). Discovering that INSIDE `Qwen35CudaModel::with_max_seq_len` +/// produces `FAIL … Operation 'qwen35_cuda_upload' not supported`, a red C14 row +/// naming the MODEL for what is a missing KERNEL. Discovering it from the header +/// produces the refusal (exit 12) that `scripts/check_model_parity.sh` reads as +/// UNMEASURED-TOOL — the same verdict the architecture refusal earns, for the +/// same reason: no run of this build can produce that number. +/// +/// The predicate is realizar's own header-level SSOT — `hybrid_gpu_quant_refusal` +/// delegating to `gpu_unsupported_quant_qtype` (PMAT-781/783/785), the whitelist +/// the upload itself enforces — so parity cannot refuse a file the GPU would +/// have taken, nor admit one it would have decoded as Q4_K. +#[cfg(feature = "inference")] +#[allow(dead_code)] // the only non-test caller is `run`, which is `cuda`-gated +pub(crate) fn hybrid_quant_refusal<'n>( + architecture: &str, + tensors: impl IntoIterator, +) -> Option { + let detail = realizar::gguf::hybrid_gpu_quant_refusal(architecture, tensors)?; + // realizar's message opens by naming the architecture, which the refusal + // line has already printed as `architecture=…`; drop the duplicate rather + // than say it twice. + let prefix = format!("Architecture '{architecture}': "); + let reason = detail.strip_prefix(&prefix).unwrap_or(&detail).to_string(); + Some(ParityRefusal { + architecture: architecture.to_string(), + reason, + issue: "PMAT-785", + }) +} + +/// The per-position detail a `--verbose` run owes a failing position. +/// +/// Shared by both arms: a diagnostic that exists on one loop and not the other +/// is how the two drift apart. +#[cfg(feature = "cuda")] +fn print_verbose_failure(m: &SpcMetrics) { + eprintln!( + "{} {} mean_diff={:.6} rmse={:.6} oos={}/{} {}", + "│".dimmed(), + "".dimmed(), + m.mean_abs_diff, + m.rmse, + m.out_of_spec_count, + m.vocab_size, + "│".dimmed(), + ); +} + +/// The report every arm ends with: the JSON document (GH-636) or the SPC +/// footer + summary + auto-diagnosis, and the exit status the display promises +/// (GH-615). +/// +/// ONE function, not one per arm: the JSON shape and the exit rule must be +/// identical whichever pair of forwards produced the metrics, or the C14 row +/// starts depending on the architecture it measured. +#[cfg(feature = "cuda")] +fn emit_parity_outcome( + file: &Path, + all_metrics: &[SpcMetrics], + hidden_dim: usize, + num_heads: usize, + kv_heads: usize, + json: bool, +) -> Result<()> { + let has_failures = all_metrics.iter().any(|m| m.verdict().is_fail()); + + if json { + let metrics_json: Vec = all_metrics + .iter() + .map(|m| { + serde_json::json!({ + "position": m.position, + "token_id": m.token_id, + "cpu_argmax": m.cpu_argmax, + "gpu_argmax": m.gpu_argmax, + "max_abs_diff": m.max_abs_diff, + "mean_abs_diff": m.mean_abs_diff, + "cosine_similarity": m.cosine_similarity, + "kl_divergence": m.kl_divergence, + "sigma_level": m.sigma_level, + "cpk": m.cpk(), + "verdict": format!("{:?}", m.verdict()), + }) + }) + .collect(); + let summary = serde_json::json!({ + "model": file.display().to_string(), + "tokens": all_metrics.len(), + "passed": all_metrics.iter().filter(|m| !m.verdict().is_fail()).count(), + "failed": all_metrics.iter().filter(|m| m.verdict().is_fail()).count(), + "parity": !has_failures, + "metrics": metrics_json, + }); + println!("{}", serde_json::to_string_pretty(&summary).unwrap_or_default()); + return if has_failures { + Err(CliError::ValidationFailed( + "PARITY DISPROVEN: GPU/CPU divergence exceeds tolerance".to_string(), + )) + } else { + Ok(()) + }; + } + + print_footer(); + print_summary(all_metrics); + auto_diagnose(all_metrics, hidden_dim, num_heads, kv_heads); + + if has_failures { + Err(CliError::ValidationFailed( + "PARITY DISPROVEN: GPU/CPU divergence exceeds tolerance".to_string(), + )) + } else { + Ok(()) + } +} + +/// What one hybrid measurement produced: the per-position metrics, plus the +/// attention geometry `auto_diagnose` prints. +#[cfg(feature = "cuda")] +struct HybridParity { + metrics: Vec, + hidden_dim: usize, + num_heads: usize, + kv_heads: usize, +} + +/// Measure the Qwen3.5 hybrid: its CPU forward (#3091) against its CUDA twin +/// (#3090), position by position over `tokens`, into the same `SpcMetrics` the +/// dense loop produces. +/// +/// Both backends see the SAME token at the SAME position from their own fresh +/// state, exactly as the F2 runtime guard does +/// (`forward_qwen35::f2_validate_qwen35`) — so a divergence this reports is the +/// one that would reject the GPU at inference time, not a different experiment. +/// +/// Returned rather than printed-and-dropped so the end-to-end contract +/// (≥64 positions, cosine and argmax) is assertable from a test. +/// +/// # Errors +/// A hybrid file whose layers will not load, a CUDA device that will not +/// initialize, or either forward failing at any position. +#[cfg(feature = "cuda")] +fn measure_hybrid( + mapped: &realizar::gguf::MappedGGUFModel, + tokens: &[u32], + verbose: bool, +) -> Result { + use realizar::gguf::forward_qwen35::Qwen35Model; + use realizar::gguf::Qwen35CudaModel; + + let base = Qwen35Model::create_base_model(&mapped.model, mapped.data()).map_err(|e| { + CliError::ValidationFailed(format!("Failed to load the Qwen3.5 base model: {e}")) + })?; + let cpu = Qwen35Model::from_model_and_layers(&base, &mapped.model, mapped.data()).map_err( + |e| CliError::ValidationFailed(format!("Failed to load the Qwen3.5 hybrid layers: {e}")), + )?; + + // The geometry of the FULL-ATTENTION layers, which is what `head_geometry` + // and `auto_diagnose`'s GQA analysis describe. The interleaved Gated + // DeltaNet layers have their own shape (num_k_heads / head_k_dim / + // num_v_heads / conv_kernel) that these three numbers do not describe at + // all — a DeltaNet divergence is NOT a GQA-ratio story, and the diagnosis + // must not invent one. + let config = base.config(); + let hidden_dim = config.hidden_dim; + let num_heads = config.num_heads; + let kv_heads = config.num_kv_heads; + let (head_dim, _kv_dim, gqa_ratio) = head_geometry(hidden_dim, num_heads, kv_heads); + + eprintln!( + " {} hidden={} heads={} kv_heads={} head_dim={} GQA={} layers={} vocab={}", + "Arch:".white().bold(), + hidden_dim, + num_heads, + kv_heads, + head_dim, + gqa_ratio, + config.num_layers, + config.vocab_size, + ); + eprintln!( + " {}", + "Hybrid: Gated DeltaNet interleaved with gated full attention (#3090/#3091)".dimmed() + ); + + let executor = realizar::cuda::CudaExecutor::new(0) + .map_err(|e| CliError::ValidationFailed(format!("CUDA init failed: {e}")))?; + let device_name = executor + .device_name() + .unwrap_or_else(|_| "Unknown GPU".to_string()); + let vram_mb = executor.memory_info().unwrap_or((0, 0)).1 / (1024 * 1024); + + let max_seq = tokens.len() + 1; + let mut gpu = Qwen35CudaModel::with_max_seq_len(&cpu, executor, max_seq).map_err(|e| { + CliError::ValidationFailed(format!("CUDA hybrid model build failed: {e}")) + })?; + + eprintln!( + " {} {} ({} MB VRAM)", + "GPU:".white().bold(), + device_name.green(), + vram_mb, + ); + + let mut cpu_state = cpu.new_state(max_seq); + let mut gpu_state = gpu.new_state().map_err(|e| { + CliError::ValidationFailed(format!("CUDA decode state allocation failed: {e}")) + })?; + + eprintln!(); + print_header(); + + let mut metrics = Vec::with_capacity(tokens.len()); + for (pos, &token_id) in tokens.iter().enumerate() { + let cpu_logits = cpu + .forward_single_qwen35(token_id, &mut cpu_state, pos) + .map_err(|e| { + CliError::InferenceFailed(format!("CPU forward failed at pos {pos}: {e}")) + })?; + let gpu_logits = gpu.forward_single(token_id, &mut gpu_state, pos).map_err(|e| { + CliError::InferenceFailed(format!("GPU forward failed at pos {pos}: {e}")) + })?; + + let m = compute_metrics(&cpu_logits, &gpu_logits, pos, token_id); + print_row(&m); + if verbose && m.verdict().is_fail() { + print_verbose_failure(&m); + } + metrics.push(m); + } + + Ok(HybridParity { + metrics, + hidden_dim, + num_heads, + kv_heads, + }) +} + +/// The hybrid arm of `run`: measure, then emit the shared report. +#[cfg(feature = "cuda")] +fn run_hybrid( + file: &Path, + mapped: &realizar::gguf::MappedGGUFModel, + tokens: &[u32], + verbose: bool, + json: bool, +) -> Result<()> { + let measured = measure_hybrid(mapped, tokens, verbose)?; + emit_parity_outcome( + file, + &measured.metrics, + measured.hidden_dim, + measured.num_heads, + measured.kv_heads, + json, + ) +} diff --git a/crates/apr-cli/src/commands/parity_hybrid_tests.rs b/crates/apr-cli/src/commands/parity_hybrid_tests.rs new file mode 100644 index 0000000000..4b1d282aac --- /dev/null +++ b/crates/apr-cli/src/commands/parity_hybrid_tests.rs @@ -0,0 +1,166 @@ + + +/// #3477: which pair of forwards `apr parity` measures an architecture with. +/// +/// No GPU and no model file: the dispatch is a pure function of the GGUF +/// architecture tag, so the regression that produced this ticket — `qwen35` +/// admitted by the refusal but still sent to the dense loader — is catchable on +/// any host. +#[cfg(all(test, feature = "inference"))] +mod parity_arm_tests { + use super::{parity_arm, ParityArm}; + + #[test] + fn qwen35_is_measured_by_the_hybrid_arm() { + assert_eq!( + parity_arm("qwen35"), + ParityArm::Hybrid, + "the hybrid has both forwards (#3090/#3091); the dense loader refuses its layers" + ); + } + + #[test] + fn every_other_architecture_keeps_the_dense_arm() { + for arch in ["llama", "qwen2", "qwen3", "gemma", "phi3", ""] { + assert_eq!( + parity_arm(arch), + ParityArm::Dense, + "{arch} is measured by the dense CPU/GPU loop" + ); + } + } + + /// The spellings the runtime does NOT dispatch to the hybrid forward are + /// refused by `parity_refusal_for` before any arm is chosen — so the arm + /// they would otherwise take is the dense one, not a hybrid loop that would + /// fail to load their layers. + #[test] + fn undispatched_hybrid_spellings_do_not_take_the_hybrid_arm() { + for arch in ["qwen3.5", "qwen3_5", "QWEN3_5"] { + assert_eq!(parity_arm(arch), ParityArm::Dense, "{arch}"); + } + } +} + +/// #3477: a hybrid file the GPU upload cannot take is REFUSED from the header, +/// not failed halfway through the upload. +/// +/// Header-only inputs — `(tensor name, GGML type)` pairs — so the whole rule is +/// falsifiable with no GPU, no model and no file. +#[cfg(all(test, feature = "inference"))] +mod hybrid_quant_refusal_tests { + use super::{hybrid_quant_refusal, PARITY_REFUSED_EXIT}; + + /// GGML type 23 is IQ4_XS (`gguf::ggml_type_table`) — what + /// `Qwen3.5-0.8B-IQ4_XS.gguf`, the file the C14 manifest row resolves to on + /// this box, stores `blk.0.attn_gate.weight` as. Before this refusal that + /// row read `FAIL … Operation 'qwen35_cuda_upload' not supported` — a red + /// row naming the MODEL for a missing kernel. + const IQ4_XS: u32 = 23; + /// Q4_K: a type the GPU GEMV kernels do cover. + const Q4_K: u32 = 12; + + #[test] + fn a_deltanet_projection_with_no_gpu_kernel_is_refused() { + let r = hybrid_quant_refusal("qwen35", [("blk.0.ssm_beta.weight", IQ4_XS)]) + .expect("no GPU GEMV kernel for IQ4_XS: parity cannot build the GPU half"); + assert_eq!(r.architecture, "qwen35"); + assert!( + r.reason.contains("blk.0.ssm_beta.weight") && r.reason.contains("23"), + "the refusal must name the tensor and its type: {}", + r.reason + ); + assert!( + !r.reason.starts_with("Architecture 'qwen35'"), + "the line already prints architecture=qwen35: {}", + r.reason + ); + assert_eq!(r.issue, "PMAT-785"); + assert!(r.line().starts_with("parity: REFUSED architecture=qwen35 — ")); + assert_eq!(PARITY_REFUSED_EXIT, 12, "UNMEASURED-TOOL, not FAIL"); + } + + #[test] + fn a_hybrid_the_gpu_can_take_is_not_refused() { + assert!( + hybrid_quant_refusal( + "qwen35", + [ + ("token_embd.weight", Q4_K), + ("blk.0.ssm_beta.weight", Q4_K), + ("blk.0.attn_qkv.weight", Q4_K), + ] + ) + .is_none(), + "Q4_K has a verified kernel — this file is measurable and must be measured" + ); + } + + /// The dense quant gate already speaks for every other architecture; a + /// second refusal here would refuse dense models parity measures today. + #[test] + fn a_dense_architecture_is_never_refused_by_the_hybrid_quant_gate() { + for arch in ["llama", "qwen2", "qwen3", "qwen3.5"] { + assert!( + hybrid_quant_refusal(arch, [("blk.0.attn_q.weight", IQ4_XS)]).is_none(), + "{arch}" + ); + } + } +} + +/// The end-to-end contract of the hybrid arm, measured on the real 0.8B file. +/// +/// Needs a CUDA device AND the model, so it is `cuda`-gated and skips when the +/// file is absent (the same shape every model-file test in this tree uses); the +/// authoritative run is the C14 row, which fails loudly when the model is +/// missing. What it pins is the measured #3517 contract: the CPU forward +/// quantizes its activations, so the two are NOT bit-identical — what must hold +/// is the same DISTRIBUTION (cosine) and the same CHOICE (argmax) at every +/// position, over enough positions for the I8 ≥ 64 rule. +#[cfg(all(test, feature = "cuda"))] +mod hybrid_e2e_tests { + use super::measure_hybrid; + + const MODEL: &str = "/home/noah/models/Qwen3.5-0.8B-Q4_K_M.gguf"; + /// Long enough that the tokenizer yields well over the 64 positions the I8 + /// admission rule wants, without making the test a benchmark. + const PROMPT_REPEATS: usize = 9; + + #[test] + fn qwen35_hybrid_cpu_and_cuda_agree_over_at_least_64_positions() { + if !std::path::Path::new(MODEL).exists() { + eprintln!("skipped: {MODEL} is not on this host"); + return; + } + let prompt = "The quick brown fox jumps over the lazy dog. ".repeat(PROMPT_REPEATS); + let mapped = realizar::gguf::MappedGGUFModel::from_path(MODEL).expect("map the model"); + let tokens = mapped.model.encode(&prompt).expect("tokenize the prompt"); + assert!( + tokens.len() >= 64, + "the prompt must exercise at least 64 positions, got {}", + tokens.len() + ); + + let measured = measure_hybrid(&mapped, &tokens, false).expect("measure the hybrid"); + assert_eq!(measured.metrics.len(), tokens.len()); + assert!( + measured.metrics.len() >= 64, + "the I8 admission rule wants at least 64 positions" + ); + + for m in &measured.metrics { + assert!( + m.cosine_similarity >= 0.996, + "position {}: cosine {} is below the measured #3517 floor", + m.position, + m.cosine_similarity + ); + assert_eq!( + m.cpu_argmax, m.gpu_argmax, + "position {}: the two backends chose different tokens", + m.position + ); + } + } +} diff --git a/crates/apr-cli/src/commands/parity_refusal.rs b/crates/apr-cli/src/commands/parity_refusal.rs index 7d411094b8..d6c871575b 100644 --- a/crates/apr-cli/src/commands/parity_refusal.rs +++ b/crates/apr-cli/src/commands/parity_refusal.rs @@ -70,8 +70,13 @@ pub(crate) struct ParityRefusal { /// reader to the wrong code, so the normalizer is never allowed to rename a /// refusal into an architecture that is not refused. pub(crate) architecture: String, - /// Why the dense parity loop cannot route it. - pub(crate) reason: &'static str, + /// Why parity cannot measure it. + /// + /// Owned, not `&'static str`: #3477's quant refusal names the offending + /// tensor and its GGML type, which only the file knows. A refusal that + /// could not quote the file would send the reader looking for a tensor the + /// line did not name. + pub(crate) reason: String, /// The issue that will lift the refusal. pub(crate) issue: &'static str, } @@ -150,7 +155,8 @@ pub(crate) fn parity_refusal_for<'n>( architecture.to_string() }, reason: "the dense parity loop would read the MoE placeholder tensors; \ - run/serve route this architecture through the MoE forward and parity does not yet", + run/serve route this architecture through the MoE forward and parity does not yet" + .to_string(), issue: "#3367", }); } @@ -173,7 +179,8 @@ pub(crate) fn parity_refusal_for<'n>( return Some(ParityRefusal { architecture: architecture.to_string(), reason: "the GPU forward for this architecture is not implemented; \ - GPU=CPU cannot be measured", + GPU=CPU cannot be measured" + .to_string(), issue: "#3090", }); } diff --git a/crates/aprender-serve/src/gguf/mod.rs b/crates/aprender-serve/src/gguf/mod.rs index 3a191e8590..82e87b2530 100644 --- a/crates/aprender-serve/src/gguf/mod.rs +++ b/crates/aprender-serve/src/gguf/mod.rs @@ -84,7 +84,7 @@ pub(crate) mod format_factory; pub use batch_scheduler::*; pub use config::*; #[cfg(feature = "cuda")] -pub use cuda::{BatchedDecodeState, CudaBackend, CudaInitError}; +pub use cuda::{BatchedDecodeState, CudaBackend, CudaInitError, Qwen35CudaModel, Qwen35CudaState}; #[cfg(feature = "cuda")] pub use cuda_model::*; pub use model::*; diff --git a/evidence/parity/thresholds.yaml b/evidence/parity/thresholds.yaml index 8c5fe92239..b182d28a4a 100644 --- a/evidence/parity/thresholds.yaml +++ b/evidence/parity/thresholds.yaml @@ -33,7 +33,13 @@ models: # Nothing measured on the first population bounds the second, so reusing the # number would be a threshold with a borrowed basis, which is the defect # #2971 closed. - qwen3.5-0.8b: + # KEY (#3477): this row is the CPU-vs-llama.cpp leg and is keyed as such. It + # used to sit under the bare model name, which `judge()` also reaches for the + # GPU-vs-CPU record `apr parity` now produces for the hybrid (#3517, #3527) — + # so C14 read a CPU-comparator row for a GPU comparison and failed closed on a + # number that was absent for a different reason. The GPU-vs-CPU row for the + # bare name is further down, with its own measured basis. + qwen3.5-0.8b@cpu-vs-llama.cpp: compute_class: cpu host: intel # scripts/perf-matrix.yaml:138-143 — the only cpu-class host with a runner comparator: 'llama.cpp [X], CPU build, pin 39173bcac (scripts/llama_pin.toml)' @@ -62,3 +68,53 @@ models: qwen35 GGUF for the known-bad half — #3303's open question, still with no decider. n: null # no run measured_by: null # no command has been run against this row + + # ── the GPU leg, PMAT-3477 / #3090 / #3517 / #3527: `apr parity` on the Qwen3.5 hybrid ── + # The same comparison as `default` (one implementation, CPU vs CUDA, the 78-token + # corpus prompt, min cosine over >= 64 positions) on a DIFFERENT population — a + # hybrid Gated-DeltaNet/attention model whose CPU forward quantizes activations + # (Q8_K) where the dense path does not — so it gets its own number and its own + # measured basis rather than borrowing 0.98 (the #2971 rule). 0.99 is the good + # floor (0.998971) rounded DOWN to two decimals; the known-bad half sits at 0.466 + # and below, so the number discriminates rather than decorates. + # + # basis (n=5 per pair on lambda, the runs are bit-identical so stdev 0; gx10 rows + # are the peer session's runs at the same head, apr 2bbce5be9 cuda): + # known-good qwen3.5-0.8b-q4_k_m @ lambda (RTX 4090, sm_89, x86_64) 0.998971 x5, stdev 0 + # known-good qwen3.5-9b-q4_k_m @ lambda 0.999254 x5, stdev 0 + # known-good qwen3.5-0.8b-q4_k_m @ gx10 (GB10, sm_121, aarch64) 0.999176 x3, stdev 0 + # known-good qwen3.5-9b-q4_k_m @ gx10 0.999351 x3, stdev 0 + # (argmax is NOT part of this rule: CPU and GPU argmax differ at 1-4 near-tie + # positions per record on both hosts — lambda 0.8b 1/78, 9b 3/78; gx10 0.8b 4/78, + # 9b 2/78, every one at cosine >= 0.9994 and KL <= 0.012 — exactly as the dense + # known-good records do (qwen2.5-coder-0.5b 2/78, 7b 2/78): the #2350/#2359 + # coin-flip class, which an argmax rule would turn into a host-dependent verdict) + # known-bad the DP4A GEMV routed through the DeltaNet recurrence (#3513), the + # kernel variant `GpuProfile::detect` would pick on sm_89 — measured by + # crates/aprender-serve/src/gguf/cuda/forward_qwen35_cuda_tests.rs + # `qwen35_cuda_dp4a_gemv_is_catastrophic_through_the_recurrence` + # on lambda: cosine 0.466 down to -0.246; on gx10 (peer session, same + # head, 16:03Z): +0.179 down to -0.765 with the GPU argmax stuck at one + # token at every position (the model pins the float GEMV for exactly + # this reason; the test is the standing falsifier on both hosts) + # margin: 0.998971 - 0.99 = 0.008971 under the lower good floor; 0.99 - 0.466 = 0.524 over the higher bad ceiling + qwen3.5-0.8b: + compute_class: gpu + min_cosine: 0.99 + basis: >- + measured 2026-09-19 at apr 2bbce5be9 (cuda): known-good qwen3.5-0.8b-q4_k_m @ lambda 0.998971 x5 stdev 0 + and @ gx10 0.999176 x3 stdev 0; known-good qwen3.5-9b-q4_k_m @ lambda 0.999254 x5 and @ gx10 0.999351 x3; known-bad + the DP4A GEMV through the recurrence (#3513) at cosine 0.466 to -0.246 on lambda and 0.179 to -0.765 + on gx10 (forward_qwen35_cuda_tests.rs, the standing falsifier). 0.99 separates them with 0.008971 margin + under the lower good floor and 0.524 over the higher bad ceiling. + n: 5 + measured_by: 'apr parity ./Qwen3.5-0.8B-Q4_K_M.gguf --prompt "$PROMPT" --json (scripts/check_model_parity.sh --manifest)' + qwen3.5-9b: + compute_class: gpu + min_cosine: 0.99 + basis: >- + measured 2026-09-19 at apr 2bbce5be9 (cuda): the same four known-good readings as qwen3.5-0.8b + (lambda 0.999254 x5 stdev 0, gx10 0.999351 x3 for this model) against the same known-bad + (DP4A through the recurrence, cosine 0.466 to -0.246 on lambda, 0.179 to -0.765 on gx10); margin 0.009254 under the good floor. + n: 5 + measured_by: 'apr parity ./Qwen3.5-9B-Q4_K_M.gguf --prompt "$PROMPT" --json (scripts/check_model_parity.sh --manifest)' diff --git a/scripts/check_model_parity.sh b/scripts/check_model_parity.sh index 4a878fecd7..4a75d10585 100755 --- a/scripts/check_model_parity.sh +++ b/scripts/check_model_parity.sh @@ -65,12 +65,30 @@ PY # bash-run workflow. rc=3 is NOT tolerated silently: the caller measures the file # and still fails, because a registry that disagrees with its artifact is the # defect, and tolerating it is what let this sit. +# +# Several vendor files can share the logical name (Qwen3.5-0.8B ships as IQ4_XS, +# Q4_K_M and UD-IQ2_XXS side by side). "first in sort order" picked IQ4_XS, whose +# GGML type has no GPU GEMV kernel, so C14 reported UNMEASURED-TOOL for a model +# whose Q4_K_M twin — the ladder's own rung file — it could have measured (#3477). +# The K-quant file is preferred when present; the pick is a preference among the +# files the name matches, never a widening of the match. +prefer_measurable() { + local k + k=$(grep -i -m1 'Q4_K_M' || true) + printf '%s' "$k" +} resolve_model_file() { - local dir="$1" name="$2" hit - hit=$(ls "$dir"/"$name"*.gguf 2>/dev/null | head -1 || true) - if [ -n "$hit" ]; then printf '%s' "$hit"; return 0; fi - hit=$(find "$dir" -maxdepth 1 -iname "$name*.gguf" 2>/dev/null | sort | head -1 || true) - if [ -n "$hit" ]; then printf '%s' "$hit"; return 3; fi + local dir="$1" name="$2" hits hit + hits=$(ls "$dir"/"$name"*.gguf 2>/dev/null || true) + if [ -n "$hits" ]; then + hit=$(printf '%s\n' "$hits" | prefer_measurable); [ -n "$hit" ] || hit=$(printf '%s\n' "$hits" | head -1) + printf '%s' "$hit"; return 0 + fi + hits=$(find "$dir" -maxdepth 1 -iname "$name*.gguf" 2>/dev/null | sort || true) + if [ -n "$hits" ]; then + hit=$(printf '%s\n' "$hits" | prefer_measurable); [ -n "$hit" ] || hit=$(printf '%s\n' "$hits" | head -1) + printf '%s' "$hit"; return 3 + fi return 1 } @@ -175,6 +193,17 @@ PY row 0 "exact-case model resolves (rc 0, the ordinary path)" resolve_model_file "$MD" qwen2-0.5b-instruct row 3 "MIXED-CASE model resolves and is flagged rc=3, never silent UNMEASURED (#3325)" resolve_model_file "$MD" qwen3.5-0.8b row 1 "a model genuinely absent on this host is rc=1 (UNMEASURED is correct there)" resolve_model_file "$MD" no-such-model + # #3477: three vendor files share the logical name; sort order put IQ4_XS first and + # C14 reported UNMEASURED-TOOL for a model whose Q4_K_M twin it could measure. + : > "$MD/Qwen3.5-0.8B-IQ4_XS.gguf"; : > "$MD/Qwen3.5-0.8B-UD-IQ2_XXS.gguf" + row 3 "among IQ4_XS / Q4_K_M / UD-IQ2_XXS the K-quant file is picked, not the first in sort order" resolve_model_file "$MD" qwen3.5-0.8b + picked=$(resolve_model_file "$MD" qwen3.5-0.8b || true) + case "$picked" in *Q4_K_M.gguf) row 0 "the picked file IS the Q4_K_M twin" true ;; *) row 0 "the picked file IS the Q4_K_M twin (got: $picked)" false ;; esac + # #3477: the CPU-vs-llama.cpp leg row is keyed `@cpu-vs-llama.cpp` and stays + # fail-closed (no min_cosine); the bare name is the GPU-vs-CPU row `apr parity` + # produces, judged against the shipped thresholds file, not a fixture. + row 2 "the CPU-vs-llama.cpp leg row stays fail-closed under its own key (I4)" env PARITY_THRESHOLDS="$ROOT/evidence/parity/thresholds.yaml" bash "$0" --judge "$L/qwen2.5-coder-7b-instruct-q4_k_m.json" --model qwen3.5-0.8b@cpu-vs-llama.cpp + row 0 "the shipped GPU-vs-CPU qwen3.5-0.8b row carries a measured basis and judges a good record" env PARITY_THRESHOLDS="$ROOT/evidence/parity/thresholds.yaml" bash "$0" --judge "$L/qwen2.5-coder-7b-instruct-q4_k_m.json" --model qwen3.5-0.8b # PMAT-1098: `apr parity` REFUSES architectures its dense CPU-vs-GPU loop cannot # route (MoE -> #3367, Qwen3.5 -> #3090) with exit 12 and one stderr line. That is