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
7 changes: 5 additions & 2 deletions crates/spark-model/src/weight_loader/qwen4_exp/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ pub struct NamespaceReport {
pub stray_layer_norms: usize,
}

/// A projection is present in either the dense/NVFP4 layout (`.weight`) or the EXL3 one (`.trellis`).
/// A projection is present in the dense/NVFP4 layout (`.weight`), the compressed-tensors
/// one (`.weight_packed`), or the EXL3 one (`.trellis`).
fn has_linear(store: &WeightStore, stem: &str) -> bool {
store.contains(&format!("{stem}.weight")) || store.contains(&format!("{stem}.trellis"))
store.contains(&format!("{stem}.weight"))
|| store.contains(&format!("{stem}.weight_packed"))
|| store.contains(&format!("{stem}.trellis"))
}

/// The n-gram tables are deferred (served from NVMe), which `contains` does not see.
Expand Down
120 changes: 118 additions & 2 deletions crates/spark-model/src/weight_loader/qwen4_exp/probe_tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-only

//! `audit_namespace` must see every projection of an EXL3 checkpoint (`.trellis`
//! instead of `.weight`) and the deferred n-gram tables (never in the store map).
//! `audit_namespace`/`has_linear` must see every projection namespace: the
//! dense/NVFP4 `.weight`, the compressed-tensors `.weight_packed`, and the EXL3
//! `.trellis` — plus the deferred n-gram tables (never in the store map).
//!
//! `weight_packed_experts_resolve_the_layer` pins the reported production bug:
//! a compressed-tensors NVFP4 Flash-Next checkpoint stores its routed experts
//! as `.weight_packed`, so before the fix it probed 0 routed-expert layers and
//! `ensure_loadable` refused with "0 of 48 layers have routed experts". The
//! test fails (expert_tensors = 0, `ensure_loadable` errs) if
//! the `.weight_packed` arm is dropped from `has_linear`.

use super::*;
use spark_runtime::gpu::DevicePtr;
Expand Down Expand Up @@ -126,3 +134,111 @@ fn missing_expert_still_refuses() {
assert_eq!(r.expert_tensors, 1);
assert!(r.ensure_loadable().is_err());
}

/// The three projection namespaces are interchangeable to `audit_namespace`:
/// swapping a checkpoint's suffix must not change what the probe reports.
#[test]
fn expert_namespace_suffixes_are_equivalent() {
let c = config();
let l0 = c.layer_prefix(0);
let l1 = c.layer_prefix(1);
for suffix in [".weight", ".weight_packed", ".trellis"] {
let names = [
n(format!("{l0}.linear_attn.in_proj_qkv{suffix}")),
n(format!("{l1}.self_attn.q_proj{suffix}")),
n(format!("{l1}.self_attn.indexer.index_qk_proj{suffix}")),
n(format!("{l0}.mlp.experts.0.gate_proj{suffix}")),
n(format!("{l1}.mlp.experts.0.gate_proj{suffix}")),
n(format!("{l0}.attn_hyper_connection.hc_norm.weight")),
n(format!("{l1}.attn_hyper_connection.hc_norm.weight")),
n(format!("{l0}.mlp_hyper_connection.hc_norm.weight")),
n(format!("{l1}.mlp_hyper_connection.hc_norm.weight")),
n("model.language_model.embed_tokens.weight".into()),
n(format!("lm_head{suffix}")),
];
let r = audit_namespace(&store(&names, &[]), &c);
assert_eq!(r.gdn_layers, 1, "{suffix}");
assert_eq!(r.attn_layers, 1, "{suffix}");
assert_eq!(r.indexer_tensors, 1, "{suffix}");
assert_eq!(r.expert_tensors, 2, "{suffix}");
assert!(r.has_lm_head, "{suffix}");
assert!(r.ensure_loadable().is_ok(), "{suffix}");
}
}

/// The reported checkpoint shape: FP8 attention/GDN weights keep a plain
/// `.weight`, only the NVFP4 routed experts are `.weight_packed`. This is the
/// case that regressed to "0 of 48 layers have routed experts".
#[test]
fn weight_packed_experts_resolve_the_layer() {
let c = config();
let l0 = c.layer_prefix(0);
let l1 = c.layer_prefix(1);
let names = [
n(format!("{l0}.linear_attn.in_proj_qkv.weight")),
n(format!("{l1}.self_attn.q_proj.weight")),
n(format!("{l1}.self_attn.indexer.index_qk_proj.weight")),
n(format!("{l0}.mlp.experts.0.gate_proj.weight_packed")),
n(format!("{l1}.mlp.experts.0.gate_proj.weight_packed")),
n(format!("{l0}.attn_hyper_connection.hc_norm.weight")),
n(format!("{l1}.attn_hyper_connection.hc_norm.weight")),
n(format!("{l0}.mlp_hyper_connection.hc_norm.weight")),
n(format!("{l1}.mlp_hyper_connection.hc_norm.weight")),
n("model.language_model.embed_tokens.weight".into()),
n("lm_head.weight".into()),
];
let r = audit_namespace(&store(&names, &[]), &c);
assert_eq!(r.gdn_layers, 1);
assert_eq!(r.attn_layers, 1);
assert_eq!(r.indexer_tensors, 1);
assert_eq!(r.expert_tensors, 2);
assert!(r.has_lm_head);
assert_eq!(r.ple_shards, 0);
assert!(r.ensure_loadable().is_ok());
}

/// `has_linear` must match the three projection namespaces exactly and must not
/// mistake a quantization sidecar or a bare stem for a projection.
#[test]
fn has_linear_matches_only_projection_namespaces() {
let with = |name: &str| store(&[name.to_string()], &[]);
for stem in ["blk.self_attn.q_proj", "blk.mlp.experts.0.gate_proj"] {
assert!(has_linear(&with(&format!("{stem}.weight")), stem), "{stem}");
assert!(
has_linear(&with(&format!("{stem}.weight_packed")), stem),
"{stem}"
);
assert!(
has_linear(&with(&format!("{stem}.trellis")), stem),
"{stem}"
);
// A compressed-tensors sidecar (`weight_scale`) is not the projection.
assert!(
!has_linear(&with(&format!("{stem}.weight_scale")), stem),
"{stem}"
);
assert!(!has_linear(&with(stem), stem), "{stem}");
}
}

/// A checkpoint missing one layer's routed experts — even a `.weight_packed`
/// one — must still refuse rather than load on a bad premise.
#[test]
fn missing_weight_packed_expert_still_refuses() {
let c = config();
let l0 = c.layer_prefix(0);
let l1 = c.layer_prefix(1);
let names = [
n(format!("{l0}.linear_attn.in_proj_qkv.weight")),
n(format!("{l1}.self_attn.q_proj.weight")),
n(format!("{l0}.mlp.experts.0.gate_proj.weight_packed")),
n(format!("{l0}.attn_hyper_connection.hc_norm.weight")),
n(format!("{l0}.mlp_hyper_connection.hc_norm.weight")),
n("model.language_model.embed_tokens.weight".into()),
n("lm_head.weight".into()),
];
let r = audit_namespace(&store(&names, &[]), &c);
assert_eq!(r.expert_tensors, 1);
let err = r.ensure_loadable().unwrap_err().to_string();
assert!(err.contains("routed experts"), "{err}");
}
9 changes: 5 additions & 4 deletions crates/spark-server/src/api/chat_blocking_choice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,16 @@ pub(super) fn build_choice_message(
msg_content = msg_content.map(|c| super::strip::strip_orphan_tool_markup(&c));
}

// Refusal classifier: when the model's assistant text opens with
// a known refusal pattern AND no tool call fired, populate
// `refusal` and null out `content` per the OpenAI spec.
// Refusal classifier: when the model's assistant text opens with a
// known refusal pattern AND no tool call fired, populate `refusal`.
// The full assistant text stays in `content` alongside it — streaming
// emits the whole text plus a `Refusal` delta, and nulling `content`
// here would drop every sentence after the first.
if msg_tool_calls.is_none()
&& let Some(content_text) = msg_content.as_deref()
&& let Some(refusal_sentence) = crate::refusal::detect(content_text)
{
msg_refusal = Some(refusal_sentence);
msg_content = None;
}

// Validated wire tool calls → IR (arguments are serde-normalized
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,21 @@ fn terminal_done_keeps_existing_finish_precedence_and_tool_history() {
assert_finish_only(&deltas, expected);
}
}

#[test]
fn terminal_done_refusal_annotation_preserves_full_content() {
let _guard = crate::refusal::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let text = "I cannot help with that request. Here is a safe alternative.";
let mut s = state();
s.tag_scan_buf = text.into();
s.refusal_scan_buf = text.into();
let (deltas, reason) = finalize_done(&mut s, "stop", usage(), false, flush_sanitizer);
assert_eq!(reason, "stop");
assert!(matches!(&deltas[..], [
StreamDelta::Content { text: content, .. },
StreamDelta::Refusal { text: refusal },
StreamDelta::Finish { reason: crate::ir::FinishReason::Stop, .. }
] if content == text && refusal == "I cannot help with that request."));
}
3 changes: 3 additions & 0 deletions crates/spark-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,6 @@ pub use stubs::{
audio_stub, batch_get_stub, batch_list_stub, batches_stub, files_stub, images_stub,
moderations_stub,
};

#[cfg(test)]
mod refusal_content_tests;
Loading
Loading