Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions crates/apr-cli/src/commands/chat_generate_session_02.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,14 @@ impl ChatSession {
Ok(decoded)
}

/// CPU generation for one GGUF chat turn: the Qwen3.5 hybrid forward (#3091), which the
/// dense `OwnedQuantizedModel` loader refuses, or the dense model. Returns the prompt
/// followed by the generated tokens.
/// Generation for one GGUF chat turn that the cached dense CUDA model did not serve:
/// the Qwen3.5 hybrid forward, which the dense `OwnedQuantizedModel` loader refuses,
/// or the dense CPU model. Returns the prompt followed by the generated tokens.
///
/// #3477: the hybrid branch goes through the same serve entry point `apr run` uses
/// (`run_qwen35_generate_dispatch`), so `apr chat` without `--cpu` gets the GPU
/// forward (#3090) and its fallbacks are printed by the runtime, not re-implemented
/// here. `force_cpu` is the chat spelling of `--no-gpu`.
fn generate_gguf_cpu_tokens(
mapped: &realizar::gguf::MappedGGUFModel,
prompt_tokens: &[u32],
Expand All @@ -294,18 +299,22 @@ impl ChatSession {
) -> Result<Vec<u32>, String> {
use realizar::gguf::OwnedQuantizedModel;

if mapped.model.architecture() == Some("qwen35") {
if realizar::gguf::hybrid_forward_handles(
mapped.model.architecture().unwrap_or_default(),
) {
let base = realizar::gguf::forward_qwen35::Qwen35Model::create_base_model(
&mapped.model,
mapped.data(),
)
.map_err(|e| format!("Failed to load the Qwen3.5 base model: {e}"))?;
return realizar::gguf::forward_qwen35::run_qwen35_generate(
return realizar::gguf::forward_qwen35::run_qwen35_generate_dispatch(
mapped,
&base,
prompt_tokens,
gen_config,
config.force_cpu,
)
.map(|(tokens, _used_gpu)| tokens)
.map_err(|e| format!("Qwen3.5 generate failed: {e}"));
}

Expand Down
72 changes: 45 additions & 27 deletions crates/apr-cli/src/commands/output_verification.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

/// Gate 1: Metadata Plausibility Validation (Bug 210, GH-222)
///
/// Validates that model hyperparameters (rope_theta, max_position_embeddings, rms_norm_eps)
Expand Down Expand Up @@ -85,7 +84,11 @@ fn run_metadata_plausibility_gate(path: &Path, config: &QaConfig) -> Result<Gate
/// Return plausible rope_theta range for an architecture family.
fn rope_theta_range(arch: Option<&str>) -> (f64, f64, &'static str) {
match arch {
Some("qwen2" | "qwen2.5" | "qwen") => (100_000.0, f64::MAX, "expected ~1000000.0 (100x too low, will produce garbage)"),
Some("qwen2" | "qwen2.5" | "qwen") => (
100_000.0,
f64::MAX,
"expected ~1000000.0 (100x too low, will produce garbage)",
),
Some("llama" | "llama2" | "llama3") => (1000.0, 10_000_000.0, "expected 10000-500000"),
_ => (100.0, 100_000_000.0, "outside plausible range [100, 100M]"),
}
Expand All @@ -112,7 +115,10 @@ fn check_rope_theta(
if theta_f64 >= min && theta_f64 <= max {
*checks_passed += 1;
} else {
violations.push(format!("rope_theta={theta} for {} — {msg}", arch.unwrap_or("unknown")));
violations.push(format!(
"rope_theta={theta} for {} — {msg}",
arch.unwrap_or("unknown")
));
}
}

Expand Down Expand Up @@ -477,16 +483,18 @@ fn validate_gpu_golden_output(
config: &QaConfig,
) -> Result<Option<String>> {
use realizar::gguf::{OwnedQuantizedModel, OwnedQuantizedModelCuda};
// #3432 / #3477: Qwen3.5 (Gated DeltaNet) runs on the CPU only — the runtime
// (`realizar::infer::run_inference`) routes `qwen35` around the dense loader,
// and the Capability Match gate reports the GPU capability as declined
// (#3090). Loading it through `OwnedQuantizedModel::from_mapped` here turned a
// declined GPU rung into a hard `Validation failed` that aborted every later
// gate. A declined backend is a skip with a reason, not an abort.
if mapped.model.architecture() == Some("qwen35") {
// #3432 / #3477: the Qwen3.5 hybrid never reaches this dense-loader gate —
// `golden_gate_for` routes it to `run_golden_output_gate_runtime`, which goes
// through `realizar::run_inference` and therefore through the hybrid's own
// GPU forward (#3090). Loading it through `OwnedQuantizedModel::from_mapped`
// here would turn that into a hard `Validation failed` aborting every later
// gate, so the guard stays as a fail-safe and says where the GPU output IS
// judged rather than claiming it is not.
if realizar::gguf::hybrid_forward_handles(mapped.model.architecture().unwrap_or_default()) {
note_gpu_golden_skip(
config,
"GPU golden output skipped: Gated DeltaNet runs on the CPU only (#3090)",
"GPU golden output for the Gated DeltaNet hybrid is judged by the runtime rung \
(run_inference → Qwen35CudaModel, #3090), not by the dense loader",
);
return Ok(None);
}
Expand Down Expand Up @@ -525,7 +533,7 @@ fn note_gpu_golden_skip(config: &QaConfig, message: &str) {
}

/// #3477: golden generation for a GGUF the dense loader refuses but the runtime
/// serves on the CPU (Qwen3.5 Gated DeltaNet, #3091).
/// serves (Qwen3.5 Gated DeltaNet — CPU #3091, GPU #3090).
///
/// `golden_output_gguf_cpu` builds the model with
/// `OwnedQuantizedModel::from_mapped`; for `qwen35` that call returns the
Expand All @@ -535,12 +543,18 @@ fn note_gpu_golden_skip(config: &QaConfig, message: &str) {
/// dispatches `qwen35` to `forward_qwen35` — so the gate certifies the backend
/// that actually serves the model rather than re-implementing the dispatch.
///
/// The GPU is NOT disabled here: since #3090 the runtime routes the hybrid to
/// `Qwen35CudaModel` on a cuda build with a device, so this gate judges the GPU's
/// output — which is the point of #3477. On a CPU-only build or host the same
/// call serves CPU tokens and the gate judges those. Either way the backend
/// under judgement is the backend a user gets.
///
/// The golden prompts are already ChatML, so they are tokenized here and passed
/// via `with_input_tokens` to bypass `prepare_tokens`' chat-template auto-wrap
/// (the same reason `golden_output_apr` does it). Stop tokens come from the
/// model's own EOS, which `run_gguf_inference` merges in.
#[cfg(feature = "inference")]
fn golden_output_cpu_runtime(path: &Path, prompt: &str, max_tokens: usize) -> Result<String> {
fn golden_output_runtime(path: &Path, prompt: &str, max_tokens: usize) -> Result<String> {
use realizar::gguf::MappedGGUFModel;
use realizar::{run_inference, InferenceConfig};

Expand All @@ -558,27 +572,26 @@ fn golden_output_cpu_runtime(path: &Path, prompt: &str, max_tokens: usize) -> Re
.with_input_tokens(prompt_tokens)
.with_max_tokens(max_tokens)
.with_temperature(0.0)
.with_top_k(1)
.without_gpu();
.with_top_k(1);
let result = run_inference(&infer_config)
.map_err(|e| CliError::ValidationFailed(format!("CPU generation failed: {e}")))?;
.map_err(|e| CliError::ValidationFailed(format!("Generation failed: {e}")))?;
Ok(result.text)
}

/// Gate 1 for a CPU-only architecture: the same golden cases, run on the CPU
/// forward that serves the model.
/// Gate 1 for an architecture the dense loader refuses: the same golden cases,
/// run through the runtime entry point on whichever backend serves the model.
///
/// This gate is the point of #3477: `apr qa` must be able to say PASS about the
/// backend that runs the model. The GPU half is not attempted at all — the GPU
/// declined this architecture (#3090) and its gates skip with that reason.
/// backend that runs the model — and since #3090 that backend is the GPU.
#[cfg(feature = "inference")]
fn run_golden_output_gate_cpu_only(path: &Path, config: &QaConfig) -> Result<GateResult> {
fn run_golden_output_gate_runtime(path: &Path, config: &QaConfig) -> Result<GateResult> {
let start = Instant::now();

if !config.json && config.verbose {
println!(
"{}",
"Running golden output test on the CPU forward (#3091)...".yellow()
"Running golden output test through the runtime entry point (GPU #3090 / CPU #3091)..."
.yellow()
);
}

Expand All @@ -587,10 +600,10 @@ fn run_golden_output_gate_cpu_only(path: &Path, config: &QaConfig) -> Result<Gat
let golden_max_tokens = config.max_tokens.max(512);

for (prompt, expected_patterns) in &test_cases {
let output_text = golden_output_cpu_runtime(path, prompt, golden_max_tokens)?;
let output_text = golden_output_runtime(path, prompt, golden_max_tokens)?;
let answer_text = strip_thinking_blocks(&output_text);
if let OutputVerification::Fail { reason } =
verify_output(&answer_text, "golden_output_cpu", expected_patterns)
verify_output(&answer_text, "golden_output_runtime", expected_patterns)
{
return Ok(GateResult::failed(
"golden_output",
Expand All @@ -605,8 +618,13 @@ fn run_golden_output_gate_cpu_only(path: &Path, config: &QaConfig) -> Result<Gat
Ok(GateResult::passed(
"golden_output",
&format!(
"{} golden test cases passed on the CPU forward (#3091); GPU declined (#3090)",
test_cases.len()
"{} golden test cases passed through the runtime entry point ({})",
test_cases.len(),
if cfg!(feature = "cuda") {
"GPU hybrid forward, #3090"
} else {
"CPU hybrid forward, #3091"
}
),
Some(test_cases.len() as f64),
Some(test_cases.len() as f64),
Expand All @@ -616,7 +634,7 @@ fn run_golden_output_gate_cpu_only(path: &Path, config: &QaConfig) -> Result<Gat

/// Without `inference` there is no runtime to certify.
#[cfg(not(feature = "inference"))]
fn run_golden_output_gate_cpu_only(path: &Path, config: &QaConfig) -> Result<GateResult> {
fn run_golden_output_gate_runtime(path: &Path, config: &QaConfig) -> Result<GateResult> {
let _ = (path, config);
Ok(GateResult::skipped(
"golden_output",
Expand Down
23 changes: 19 additions & 4 deletions crates/apr-cli/src/commands/parity_refusal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@
// MoE parity support is #3367 (`apr chat` had the identical defect,
// #3368).
// * For Qwen3.5 (Gated DeltaNet + gated full attention), no GPU forward
// exists at all (#3090), so GPU==CPU is unmeasurable BY CONSTRUCTION — no
// run of this command could ever produce a number.
// existed at all when this was written (#3090), so GPU==CPU was
// unmeasurable BY CONSTRUCTION — no run of this command could produce a
// number. #3477 (operator ruling 2026-09-19) built that GPU forward
// (`Qwen35CudaModel`), so the ONE spelling the runtime dispatches is now
// measured; the undispatched spellings still reach no forward and are
// still refused.
//
// A tool that cannot measure something must say so. Falling through to the
// dense loop produced a red row that named the MODEL, which is the exact class
Expand Down Expand Up @@ -119,10 +123,11 @@ impl ParityRefusal {
/// * `unsupported_architecture_reason(..)` — the predicate `apr check` and
/// `apr ptx-map` already share so they cannot drift (GH-704 / #2399).
///
/// The Qwen3.5 arm additionally matches the architecture TAG, because a
/// The hybrid arm additionally matches the architecture TAG, because a
/// header-only read must refuse even if the tensor scan is inconclusive; the
/// spelling set is the one `ArchConstraints` uses
/// (`crates/aprender-serve/src/gguf/arch_constraints_fallback.rs`).
/// (`crates/aprender-serve/src/gguf/arch_constraints_fallback.rs`) MINUS the
/// one spelling the runtime actually dispatches, which #3477 admits.
#[cfg(feature = "inference")]
#[allow(dead_code)]
pub(crate) fn parity_refusal_for<'n>(
Expand Down Expand Up @@ -150,6 +155,16 @@ pub(crate) fn parity_refusal_for<'n>(
});
}

// #3477 (operator ruling 2026-09-19): the architecture the runtime
// dispatches to the hybrid forward now HAS a GPU forward
// (`Qwen35CudaModel`, #3090) as well as the CPU one (#3091), so parity can
// compare the two and must stop refusing it. `hybrid_forward_handles` is
// realizar's own dispatch predicate — the same one `apr qa` and
// `apr ptx-map` ask — so this cannot drift from what `apr run` does.
if realizar::gguf::hybrid_forward_handles(architecture) {
return None;
}

let hybrid_tag = matches!(
architecture.to_ascii_lowercase().as_str(),
"qwen3_5" | "qwen3.5" | "qwen35"
Expand Down
54 changes: 40 additions & 14 deletions crates/apr-cli/src/commands/parity_refusal_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ mod parity_refusal_case_table {
// and Qwen3-30B-A3B ship; `qwen3_moe` is the canonical key. Every
// spelling `ArchConstraints` calls MoE must be refused, canonical or not.
for raw in ["qwen3moe", "qwen3_moe", "qwen3_5_moe", "qwen3_5moe"] {
let r = parity_refusal_for(raw, Vec::<&str>::new())
.unwrap_or_else(|| panic!("{raw} must be refused: the dense parity loop cannot route it"));
let r = parity_refusal_for(raw, Vec::<&str>::new()).unwrap_or_else(|| {
panic!("{raw} must be refused: the dense parity loop cannot route it")
});
assert_eq!(r.issue, "#3367");
assert!(
r.reason.contains("MoE placeholder"),
Expand Down Expand Up @@ -44,15 +45,32 @@ mod parity_refusal_case_table {
] {
let r = parity_refusal_for(raw, Vec::<&str>::new()).expect("refused");
assert_eq!(r.architecture, named, "arch named for raw tag {raw}");
assert_ne!(r.architecture, "llama", "never launder a refusal as a dense arch");
assert_ne!(
r.architecture, "llama",
"never launder a refusal as a dense arch"
);
}
}

/// #3477 (operator ruling 2026-09-19): `qwen35` — the ONE spelling the
/// runtime dispatches to the hybrid forward — now has a GPU forward
/// (`Qwen35CudaModel`, #3090) as well as the CPU one (#3091), so parity can
/// measure GPU-vs-CPU for it and must stop refusing it. Every undispatched
/// hybrid spelling keeps the refusal: no forward reaches them, so parity
/// would still be comparing nothing.
#[test]
fn parity_refusal_qwen35_is_refused_with_issue_3090() {
for raw in ["qwen35", "qwen3.5", "qwen3_5", "QWEN3_5"] {
fn parity_refusal_qwen35_is_admitted_and_undispatched_spellings_are_refused() {
assert!(
parity_refusal_for("qwen35", Vec::<&str>::new()).is_none(),
"the GPU forward exists (#3090) — parity must run, not refuse"
);
assert!(
parity_refusal_for("qwen35", ["blk.0.ssm_a", "blk.0.attn_qkv.weight"]).is_none(),
"the SSM tensors are exactly what the hybrid forward consumes"
);
for raw in ["qwen3.5", "qwen3_5", "QWEN3_5"] {
let r = parity_refusal_for(raw, Vec::<&str>::new())
.unwrap_or_else(|| panic!("{raw} must be refused: no GPU forward exists"));
.unwrap_or_else(|| panic!("{raw} reaches no forward: it must be refused"));
assert_eq!(r.issue, "#3090");
assert!(
r.reason.contains("GPU forward"),
Expand All @@ -62,17 +80,17 @@ mod parity_refusal_case_table {
}
}

/// `normalize_architecture("qwen35") == "qwen3"`, so naming the refusal with
/// `normalize_architecture("qwen3_5") == "qwen3"`, so naming the refusal with
/// the shared normalizer would print `architecture=qwen3` — a dense arch that
/// parity runs happily. The refusal must name the architecture that was
/// actually refused.
#[test]
fn parity_refusal_qwen35_line_does_not_launder_the_arch_as_qwen3() {
let r = parity_refusal_for("qwen35", Vec::<&str>::new()).expect("refused");
assert_eq!(r.architecture, "qwen35");
let r = parity_refusal_for("qwen3_5", Vec::<&str>::new()).expect("refused");
assert_eq!(r.architecture, "qwen3_5");
assert!(
!r.line().contains("architecture=qwen3 "),
"the refusal must not report qwen35 as plain qwen3: {}",
"the refusal must not report qwen3_5 as plain qwen3: {}",
r.line()
);
}
Expand All @@ -92,7 +110,9 @@ mod parity_refusal_case_table {
/// `apr parity` still takes the ordinary CPU-vs-GPU path.
#[test]
fn parity_refusal_predicate_is_false_for_every_dense_arch() {
for raw in ["qwen2", "qwen2.5", "qwen3", "llama", "mistral", "gemma2", "phi3"] {
for raw in [
"qwen2", "qwen2.5", "qwen3", "llama", "mistral", "gemma2", "phi3",
] {
assert!(
parity_refusal_for(raw, ["blk.0.attn_q.weight", "output.weight"]).is_none(),
"{raw} is a dense arch — parity must run it, not refuse it"
Expand Down Expand Up @@ -136,17 +156,23 @@ mod parity_refusal_case_table {
line.starts_with("parity: REFUSED architecture=qwen3_moe — "),
"C14 greps this prefix: {line}"
);
assert!(line.ends_with("(#3367)"), "the issue is the last token: {line}");
assert!(
line.ends_with("(#3367)"),
"the issue is the last token: {line}"
);
assert_eq!(line.lines().count(), 1, "exactly ONE line");
}

/// `--json` mode emits the refusal as data, with the same three fields.
#[test]
fn parity_refusal_json_carries_architecture_reason_and_issue() {
let r = parity_refusal_for("qwen35", Vec::<&str>::new()).expect("refused");
let r = parity_refusal_for("qwen3_5", Vec::<&str>::new()).expect("refused");
let v = r.json();
let refused = v.get("refused").expect("top-level `refused` key");
assert_eq!(refused.get("architecture").and_then(|a| a.as_str()), Some("qwen35"));
assert_eq!(
refused.get("architecture").and_then(|a| a.as_str()),
Some("qwen3_5")
);
assert_eq!(refused.get("issue").and_then(|a| a.as_str()), Some("#3090"));
assert!(refused
.get("reason")
Expand Down
Loading
Loading