use anyhow::{anyhow, Context, Result};
use mlx_native::gguf::{GgufFile, MetadataValue};
pub mod forward;
pub mod model;
pub mod weights;
pub use model::Qwen3VlTextModel;
pub use weights::Qwen3VlTextWeights;
pub use crate::inference::models::qwen35::{
is_qwen3_vl_arch, is_qwen3_vl_moe_arch, ARCH_QWEN3VLMOE_UPSTREAM, ARCH_QWEN3VL_UPSTREAM,
ARCH_QWEN3_VL,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Qwen3VlTextLayerKind {
Dense,
}
pub const DEFAULT_ROPE_THETA: f32 = 5_000_000.0;
pub const DEFAULT_RMS_NORM_EPS: f32 = 1.0e-6;
pub const DEFAULT_MROPE_SECTION: [u32; 4] = [24, 20, 20, 0];
pub const DEFAULT_N_DEEPSTACK_LAYERS: usize = 3;
pub const QWEN3VL_ROPE_MODE: u32 = 40;
#[derive(Debug, Clone, PartialEq)]
pub struct Qwen3VlTextConfig {
pub num_hidden_layers: u32,
pub hidden_size: u32,
pub num_attention_heads: u32,
pub num_key_value_heads: u32,
pub head_dim: u32,
pub intermediate_size: u32,
pub vocab_size: u32,
pub max_position_embeddings: u32,
pub rope_theta: f32,
pub rms_norm_eps: f32,
pub mrope_section: [u32; 4],
pub n_deepstack_layers: usize,
pub tied_word_embeddings: bool,
pub layer_types: Vec<Qwen3VlTextLayerKind>,
}
impl Qwen3VlTextConfig {
pub fn from_gguf(gguf: &GgufFile) -> Result<Self> {
let arch = gguf
.metadata_string("general.architecture")
.ok_or_else(|| anyhow!("Qwen3VlTextConfig: missing general.architecture"))?;
if !is_qwen3_vl_arch(arch) {
return Err(anyhow!(
"Qwen3VlTextConfig::from_gguf: arch {arch:?} is not a Qwen3-VL family arch \
(expected one of {:?}, {:?}, {:?})",
ARCH_QWEN3_VL,
ARCH_QWEN3VL_UPSTREAM,
ARCH_QWEN3VLMOE_UPSTREAM,
));
}
if is_qwen3_vl_moe_arch(arch) {
return Err(anyhow!(
"Qwen3VlTextConfig::from_gguf: MoE Qwen3-VL ({arch:?}) is not yet supported \
by hf2q (no convert pipeline emits it; iter-228 closes only the dense path)"
));
}
let prefix = if gguf
.metadata_u32(&format!("{}.block_count", ARCH_QWEN3_VL))
.is_some()
{
ARCH_QWEN3_VL
} else if gguf
.metadata_u32(&format!("{}.block_count", ARCH_QWEN3VL_UPSTREAM))
.is_some()
{
ARCH_QWEN3VL_UPSTREAM
} else {
return Err(anyhow!(
"Qwen3VlTextConfig::from_gguf: neither {:?}.block_count nor {:?}.block_count \
is present in metadata — GGUF appears Qwen3-VL by arch but is missing core \
architecture facts.",
ARCH_QWEN3_VL,
ARCH_QWEN3VL_UPSTREAM,
));
};
let req_u32 = |key: &str| -> Result<u32> {
gguf.metadata_u32(&format!("{prefix}.{key}"))
.ok_or_else(|| anyhow!("Qwen3VlTextConfig: missing {prefix}.{key}"))
};
let num_hidden_layers = req_u32("block_count").context("Qwen3VlTextConfig: block_count")?;
let hidden_size =
req_u32("embedding_length").context("Qwen3VlTextConfig: embedding_length")?;
let num_attention_heads =
req_u32("attention.head_count").context("Qwen3VlTextConfig: attention.head_count")?;
let num_key_value_heads = req_u32("attention.head_count_kv")
.context("Qwen3VlTextConfig: attention.head_count_kv")?;
let intermediate_size =
req_u32("feed_forward_length").context("Qwen3VlTextConfig: feed_forward_length")?;
let max_position_embeddings = gguf
.metadata_u32(&format!("{prefix}.context_length"))
.unwrap_or(0);
if num_attention_heads == 0 {
return Err(anyhow!(
"Qwen3VlTextConfig: num_attention_heads is zero — invalid"
));
}
if num_key_value_heads == 0 {
return Err(anyhow!(
"Qwen3VlTextConfig: num_key_value_heads is zero — invalid \
(also avoids modulo-by-zero in the GQA divisibility check)"
));
}
if hidden_size % num_attention_heads != 0 {
return Err(anyhow!(
"Qwen3VlTextConfig: hidden_size ({hidden_size}) must be \
divisible by num_attention_heads ({num_attention_heads}) \
for the derived head_dim to be exact"
));
}
let head_dim = gguf
.metadata_u32(&format!("{prefix}.attention.key_length"))
.unwrap_or(hidden_size / num_attention_heads);
if head_dim == 0 {
return Err(anyhow!(
"Qwen3VlTextConfig: derived head_dim is zero (hidden={hidden_size}, \
heads={num_attention_heads}); refusing to load"
));
}
if hidden_size != num_attention_heads * head_dim {
return Err(anyhow!(
"Qwen3VlTextConfig: hidden_size ({hidden_size}) != \
num_attention_heads ({num_attention_heads}) * head_dim \
({head_dim}) — config is internally inconsistent"
));
}
if num_attention_heads % num_key_value_heads != 0 {
return Err(anyhow!(
"Qwen3VlTextConfig: invalid GQA shape — num_attention_heads={num_attention_heads} \
must be divisible by num_key_value_heads={num_key_value_heads}"
));
}
let rope_theta = gguf
.metadata_f32(&format!("{prefix}.rope.freq_base"))
.unwrap_or(DEFAULT_ROPE_THETA);
let rms_norm_eps = gguf
.metadata_f32(&format!("{prefix}.attention.layer_norm_rms_epsilon"))
.unwrap_or(DEFAULT_RMS_NORM_EPS);
let mrope_section: [u32; 4] =
match gguf.metadata(&format!("{prefix}.rope.dimension_sections")) {
Some(MetadataValue::Array(arr)) => {
if arr.len() < 4 {
return Err(anyhow!(
"Qwen3VlTextConfig: {prefix}.rope.dimension_sections has length {}, \
must be ≥ 4 (peer canonical layout is exactly 4 ints; \
convert_hf_to_gguf.py:11944 pads with 0 when source has 3)",
arr.len()
));
}
let mut out = [0u32; 4];
for (i, slot) in out.iter_mut().enumerate() {
*slot = match &arr[i] {
MetadataValue::Int32(v) if *v >= 0 => *v as u32,
MetadataValue::Uint32(v) => *v,
other => {
return Err(anyhow!(
"Qwen3VlTextConfig: {prefix}.rope.dimension_sections[{i}] \
has unexpected metadata type ({other:?}); expected Int32 \
or Uint32"
));
}
};
}
out
}
Some(other) => {
return Err(anyhow!(
"Qwen3VlTextConfig: {prefix}.rope.dimension_sections has unexpected \
metadata kind ({other:?}); expected Array of Int32"
));
}
None => DEFAULT_MROPE_SECTION,
};
let rotary_axis_total: u32 = mrope_section.iter().sum();
let rotary_axis_target = head_dim / 2;
if rotary_axis_total != rotary_axis_target {
return Err(anyhow!(
"Qwen3VlTextConfig: rope.dimension_sections sum ({rotary_axis_total}) != \
head_dim/2 ({rotary_axis_target}) — IMROPE rotary-axis budget violated; \
sections={mrope_section:?}, head_dim={head_dim}"
));
}
let n_deepstack_layers = gguf
.metadata_u32(&format!("{prefix}.n_deepstack_layers"))
.map(|v| v as usize)
.unwrap_or(DEFAULT_N_DEEPSTACK_LAYERS);
if n_deepstack_layers > num_hidden_layers as usize {
return Err(anyhow!(
"Qwen3VlTextConfig: n_deepstack_layers ({n_deepstack_layers}) exceeds \
num_hidden_layers ({num_hidden_layers}) — peer's per-layer dispatch \
(`il < n_deepstack_layers`) would index past layers[]"
));
}
let vocab_size = match gguf.tensor_info("token_embd.weight") {
Some(info) if info.shape.len() == 2 => info.shape[0] as u32,
Some(info) => {
return Err(anyhow!(
"Qwen3VlTextConfig: token_embd.weight has unexpected rank {} (shape={:?})",
info.shape.len(),
info.shape
));
}
None => {
return Err(anyhow!(
"Qwen3VlTextConfig: missing token_embd.weight tensor (cannot derive vocab_size)"
));
}
};
let tied_word_embeddings = gguf.tensor_info("output.weight").is_none();
let layer_types = vec![Qwen3VlTextLayerKind::Dense; num_hidden_layers as usize];
Ok(Self {
num_hidden_layers,
hidden_size,
num_attention_heads,
num_key_value_heads,
head_dim,
intermediate_size,
vocab_size,
max_position_embeddings,
rope_theta,
rms_norm_eps,
mrope_section,
n_deepstack_layers,
tied_word_embeddings,
layer_types,
})
}
pub fn gqa_group_ratio(&self) -> u32 {
self.num_attention_heads / self.num_key_value_heads
}
}
#[cfg(test)]
pub(crate) mod test_fixtures {
use std::io::Write;
use std::path::PathBuf;
pub(crate) enum Kv {
#[allow(dead_code)]
Str(String, String),
U32(String, u32),
#[allow(dead_code)]
F32(String, f32),
ArrayI32(String, Vec<i32>),
}
pub(crate) struct TensorDesc<'a> {
pub name: &'a str,
pub shape: &'a [usize],
}
pub(crate) fn write_minimal_qwen3vl_gguf(
arch_str: &str,
extra_kvs: &[Kv],
tensors: &[TensorDesc<'_>],
) -> PathBuf {
const GGUF_TYPE_UINT32: u32 = 4;
const GGUF_TYPE_INT32: u32 = 5;
const GGUF_TYPE_FLOAT32: u32 = 6;
const GGUF_TYPE_STRING: u32 = 8;
const GGUF_TYPE_ARRAY: u32 = 9;
const GGML_TYPE_F32: u32 = 0;
const ALIGNMENT: u64 = 32;
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(b"GGUF");
buf.extend_from_slice(&3u32.to_le_bytes()); buf.extend_from_slice(&(tensors.len() as u64).to_le_bytes()); let kv_count = 1 + extra_kvs.len() as u64;
buf.extend_from_slice(&kv_count.to_le_bytes());
let key = b"general.architecture";
buf.extend_from_slice(&(key.len() as u64).to_le_bytes());
buf.extend_from_slice(key);
buf.extend_from_slice(&GGUF_TYPE_STRING.to_le_bytes());
let val = arch_str.as_bytes();
buf.extend_from_slice(&(val.len() as u64).to_le_bytes());
buf.extend_from_slice(val);
for kv in extra_kvs {
match kv {
Kv::Str(k, v) => {
buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
buf.extend_from_slice(k.as_bytes());
buf.extend_from_slice(&GGUF_TYPE_STRING.to_le_bytes());
buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
buf.extend_from_slice(v.as_bytes());
}
Kv::U32(k, v) => {
buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
buf.extend_from_slice(k.as_bytes());
buf.extend_from_slice(&GGUF_TYPE_UINT32.to_le_bytes());
buf.extend_from_slice(&v.to_le_bytes());
}
Kv::F32(k, v) => {
buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
buf.extend_from_slice(k.as_bytes());
buf.extend_from_slice(&GGUF_TYPE_FLOAT32.to_le_bytes());
buf.extend_from_slice(&v.to_le_bytes());
}
Kv::ArrayI32(k, vs) => {
buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
buf.extend_from_slice(k.as_bytes());
buf.extend_from_slice(&GGUF_TYPE_ARRAY.to_le_bytes());
buf.extend_from_slice(&GGUF_TYPE_INT32.to_le_bytes());
buf.extend_from_slice(&(vs.len() as u64).to_le_bytes());
for v in vs {
buf.extend_from_slice(&v.to_le_bytes());
}
}
}
}
let mut data_offset: u64 = 0;
for t in tensors {
buf.extend_from_slice(&(t.name.len() as u64).to_le_bytes());
buf.extend_from_slice(t.name.as_bytes());
buf.extend_from_slice(&(t.shape.len() as u32).to_le_bytes());
for d in t.shape {
buf.extend_from_slice(&(*d as u64).to_le_bytes());
}
buf.extend_from_slice(&GGML_TYPE_F32.to_le_bytes());
buf.extend_from_slice(&data_offset.to_le_bytes());
let elem_count: usize = t.shape.iter().product();
data_offset += (elem_count * 4) as u64;
}
let header_len = buf.len() as u64;
let pad = ((header_len + ALIGNMENT - 1) & !(ALIGNMENT - 1)) - header_len;
buf.extend(std::iter::repeat(0u8).take(pad as usize));
buf.extend(std::iter::repeat(0u8).take(data_offset as usize));
let path = std::env::temp_dir().join(format!(
"hf2q-qwen3vl-test-{}.gguf",
std::process::id() as u64 ^ rand_u64()
));
let mut f = std::fs::File::create(&path).expect("create temp gguf");
f.write_all(&buf).expect("write gguf");
f.flush().expect("flush gguf");
path
}
fn rand_u64() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(1);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
nanos ^ (COUNTER.fetch_add(1, Ordering::Relaxed)).wrapping_mul(0x9E37_79B9_7F4A_7C15)
}
}
#[cfg(test)]
mod tests {
use super::test_fixtures::{write_minimal_qwen3vl_gguf, Kv, TensorDesc};
use super::*;
fn standard_2b_kvs(prefix: &str) -> Vec<Kv> {
vec![
Kv::U32(format!("{prefix}.block_count"), 28),
Kv::U32(format!("{prefix}.embedding_length"), 2048),
Kv::U32(format!("{prefix}.attention.head_count"), 16),
Kv::U32(format!("{prefix}.attention.head_count_kv"), 8),
Kv::U32(format!("{prefix}.feed_forward_length"), 6144),
Kv::U32(format!("{prefix}.context_length"), 262144),
]
}
#[test]
fn from_gguf_parses_underscored_arch_with_real_2b_shape() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let kvs = standard_2b_kvs("qwen3_vl");
let tensors = [TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
}];
let path = write_minimal_qwen3vl_gguf("qwen3_vl", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let cfg = Qwen3VlTextConfig::from_gguf(&gguf)
.expect("parse Qwen3VlTextConfig from synthetic 2B GGUF");
assert_eq!(cfg.num_hidden_layers, 28);
assert_eq!(cfg.hidden_size, 2048);
assert_eq!(cfg.num_attention_heads, 16);
assert_eq!(cfg.num_key_value_heads, 8);
assert_eq!(cfg.intermediate_size, 6144);
assert_eq!(cfg.head_dim, 128, "derived head_dim 2048/16");
assert_eq!(cfg.vocab_size, 151936);
assert_eq!(cfg.max_position_embeddings, 262144);
assert!(
(cfg.rope_theta - DEFAULT_ROPE_THETA).abs() < 1.0,
"rope_theta default {}",
cfg.rope_theta
);
assert!(
(cfg.rms_norm_eps - DEFAULT_RMS_NORM_EPS).abs() < 1e-12,
"rms_norm_eps default {}",
cfg.rms_norm_eps
);
assert_eq!(cfg.mrope_section, DEFAULT_MROPE_SECTION);
assert_eq!(cfg.n_deepstack_layers, DEFAULT_N_DEEPSTACK_LAYERS);
assert!(
cfg.tied_word_embeddings,
"tied detection — output.weight is NOT emitted in this fixture"
);
assert_eq!(cfg.layer_types.len(), 28);
assert!(
cfg.layer_types
.iter()
.all(|k| *k == Qwen3VlTextLayerKind::Dense),
"every layer is Dense for Qwen3-VL-2B/4B"
);
assert_eq!(cfg.gqa_group_ratio(), 2);
let _ = std::fs::remove_file(&path);
}
#[test]
fn from_gguf_parses_upstream_no_underscore_arch() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let kvs = standard_2b_kvs("qwen3vl");
let tensors = [TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
}];
let path = write_minimal_qwen3vl_gguf("qwen3vl", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let cfg = Qwen3VlTextConfig::from_gguf(&gguf)
.expect("parse Qwen3VlTextConfig from upstream-arch GGUF");
assert_eq!(cfg.num_hidden_layers, 28);
assert_eq!(cfg.hidden_size, 2048);
let _ = std::fs::remove_file(&path);
}
#[test]
fn from_gguf_rejects_non_qwen3vl_arch() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let kvs = standard_2b_kvs("gemma4");
let tensors = [TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
}];
let path = write_minimal_qwen3vl_gguf("gemma4", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let err =
Qwen3VlTextConfig::from_gguf(&gguf).expect_err("non-Qwen3-VL arch must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("not a Qwen3-VL family arch"),
"expected 'not a Qwen3-VL family arch' in error message; got: {msg}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn from_gguf_rejects_moe_variant_with_specific_message() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let kvs = standard_2b_kvs("qwen3vlmoe");
let tensors = [TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
}];
let path = write_minimal_qwen3vl_gguf("qwen3vlmoe", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let err = Qwen3VlTextConfig::from_gguf(&gguf)
.expect_err("MoE Qwen3-VL must be rejected by the dense parser");
let msg = format!("{err:#}");
assert!(
msg.contains("MoE Qwen3-VL"),
"expected MoE-specific message; got: {msg}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn from_gguf_detects_untied_embeddings_when_output_present() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let kvs = standard_2b_kvs("qwen3_vl");
let tensors = [
TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
},
TensorDesc {
name: "output.weight",
shape: &[2048, 151936],
},
];
let path = write_minimal_qwen3vl_gguf("qwen3_vl", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let cfg = Qwen3VlTextConfig::from_gguf(&gguf)
.expect("parse Qwen3VlTextConfig with output.weight present");
assert!(
!cfg.tied_word_embeddings,
"output.weight present → must NOT report tied"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn from_gguf_rejects_invalid_gqa_shape() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let kvs = vec![
Kv::U32("qwen3_vl.block_count".to_string(), 28),
Kv::U32("qwen3_vl.embedding_length".to_string(), 2048),
Kv::U32("qwen3_vl.attention.head_count".to_string(), 16),
Kv::U32("qwen3_vl.attention.head_count_kv".to_string(), 5),
Kv::U32("qwen3_vl.feed_forward_length".to_string(), 6144),
Kv::U32("qwen3_vl.context_length".to_string(), 262144),
];
let tensors = [TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
}];
let path = write_minimal_qwen3vl_gguf("qwen3_vl", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let err = Qwen3VlTextConfig::from_gguf(&gguf)
.expect_err("indivisible GQA shape must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("invalid GQA shape"),
"expected 'invalid GQA shape' in error; got: {msg}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn arch_constants_match_qwen35_module_re_export() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
assert_eq!(ARCH_QWEN3_VL, "qwen3_vl");
assert_eq!(ARCH_QWEN3VL_UPSTREAM, "qwen3vl");
assert_eq!(ARCH_QWEN3VLMOE_UPSTREAM, "qwen3vlmoe");
}
#[test]
fn defaults_match_hf_text_config_for_qwen3vl_2b() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
assert!((DEFAULT_ROPE_THETA - 5_000_000.0).abs() < 1.0);
assert!((DEFAULT_RMS_NORM_EPS - 1.0e-6).abs() < 1e-12);
assert_eq!(DEFAULT_MROPE_SECTION, [24, 20, 20, 0]);
assert_eq!(
DEFAULT_MROPE_SECTION.iter().sum::<u32>(),
64,
"IMROPE rotary-axis sum must equal head_dim/2 = 64 for Qwen3-VL-2B"
);
assert_eq!(DEFAULT_N_DEEPSTACK_LAYERS, 3);
assert_eq!(
QWEN3VL_ROPE_MODE, 40,
"Qwen3-VL text-LM is IMROPE=40 (Qwen3.5/3.6 share the mode); \
pre-iter-8a value of 24 (VISION) was a misread of the mtmd \
ViT prelude's RoPE-multi mode"
);
}
#[test]
fn from_gguf_reads_explicit_rope_dimension_sections() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let mut kvs = standard_2b_kvs("qwen3_vl");
kvs.push(Kv::ArrayI32(
"qwen3_vl.rope.dimension_sections".to_string(),
vec![20, 22, 22, 0],
));
let tensors = [TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
}];
let path = write_minimal_qwen3vl_gguf("qwen3_vl", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let cfg = Qwen3VlTextConfig::from_gguf(&gguf)
.expect("parse with explicit rope.dimension_sections");
assert_eq!(
cfg.mrope_section,
[20, 22, 22, 0],
"explicit GGUF sections must override the default"
);
assert_eq!(cfg.mrope_section.iter().sum::<u32>(), 64);
let _ = std::fs::remove_file(&path);
}
#[test]
fn from_gguf_rejects_rope_sections_with_wrong_sum() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let mut kvs = standard_2b_kvs("qwen3_vl");
kvs.push(Kv::ArrayI32(
"qwen3_vl.rope.dimension_sections".to_string(),
vec![10, 10, 10, 0], ));
let tensors = [TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
}];
let path = write_minimal_qwen3vl_gguf("qwen3_vl", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let err = Qwen3VlTextConfig::from_gguf(&gguf)
.expect_err("wrong-sum rope sections must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("rotary-axis budget"),
"expected 'rotary-axis budget' in error; got: {msg}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn from_gguf_rejects_rope_sections_array_too_short() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let mut kvs = standard_2b_kvs("qwen3_vl");
kvs.push(Kv::ArrayI32(
"qwen3_vl.rope.dimension_sections".to_string(),
vec![24, 20, 20], ));
let tensors = [TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
}];
let path = write_minimal_qwen3vl_gguf("qwen3_vl", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let err = Qwen3VlTextConfig::from_gguf(&gguf)
.expect_err("3-entry rope sections must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("must be ≥ 4"),
"expected '≥ 4' length error; got: {msg}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn from_gguf_reads_explicit_n_deepstack_layers() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let mut kvs = standard_2b_kvs("qwen3_vl");
kvs.push(Kv::U32("qwen3_vl.n_deepstack_layers".to_string(), 5));
let tensors = [TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
}];
let path = write_minimal_qwen3vl_gguf("qwen3_vl", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let cfg =
Qwen3VlTextConfig::from_gguf(&gguf).expect("parse with explicit n_deepstack_layers");
assert_eq!(cfg.n_deepstack_layers, 5);
let _ = std::fs::remove_file(&path);
}
#[test]
fn from_gguf_rejects_n_deepstack_exceeding_n_layers() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
let mut kvs = standard_2b_kvs("qwen3_vl");
kvs.push(Kv::U32(
"qwen3_vl.n_deepstack_layers".to_string(),
100, ));
let tensors = [TensorDesc {
name: "token_embd.weight",
shape: &[2048, 151936],
}];
let path = write_minimal_qwen3vl_gguf("qwen3_vl", &kvs, &tensors);
let gguf = GgufFile::open(&path).expect("open synthetic GGUF");
let err = Qwen3VlTextConfig::from_gguf(&gguf)
.expect_err("n_deepstack_layers > num_hidden_layers must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("exceeds"),
"expected 'exceeds' in error; got: {msg}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn from_gguf_parses_real_qwen3vl_2b_when_operator_gated() {
let _gpu = crate::inference::hf2q_gpu_test_lock();
if std::env::var("HF2Q_QWEN3VL_LM_LOAD").ok().as_deref() != Some("1") {
eprintln!("skip: HF2Q_QWEN3VL_LM_LOAD!=1");
return;
}
let p =
std::path::PathBuf::from("/opt/hf2q/.cfa-archive/wedge4f-out/qwen3-vl-2b-q4_0.gguf");
if !p.exists() {
eprintln!("skip: real GGUF fixture not present at {}", p.display());
return;
}
let gguf = GgufFile::open(&p).expect("open real Qwen3-VL-2B GGUF");
let cfg = Qwen3VlTextConfig::from_gguf(&gguf)
.expect("parse Qwen3VlTextConfig from real Qwen3-VL-2B GGUF");
assert_eq!(cfg.num_hidden_layers, 28, "Qwen3-VL-2B layers");
assert_eq!(cfg.hidden_size, 2048, "Qwen3-VL-2B hidden");
assert_eq!(cfg.num_attention_heads, 16, "Qwen3-VL-2B heads");
assert_eq!(cfg.num_key_value_heads, 8, "Qwen3-VL-2B kv heads");
assert_eq!(cfg.intermediate_size, 6144, "Qwen3-VL-2B ffn");
assert_eq!(cfg.head_dim, 128, "Qwen3-VL-2B head_dim");
assert_eq!(cfg.vocab_size, 151936, "Qwen3-VL family vocab");
assert_eq!(cfg.max_position_embeddings, 262144, "Qwen3-VL-2B ctx");
assert!(cfg.tied_word_embeddings, "Qwen3-VL-2B tied embeddings");
}
}