use crate::backends::gguf::types::MetaValue;
pub fn map_tensor_name(hf_name: &str) -> Option<String> {
let name = hf_name.strip_prefix("thinker.").unwrap_or(hf_name);
let stripped: String;
let name: &str = if name.contains("language_model.") {
stripped = name.replace("language_model.", "");
&stripped
} else {
name
};
if name.starts_with("visual.") || name.starts_with("model.visual.") {
return None;
}
if name.starts_with("mtp.") || name.starts_with("audio_tower.") {
return None;
}
match name {
"model.embed_tokens.weight" => return Some("token_embd.weight".to_string()),
"model.norm.weight" => return Some("output_norm.weight".to_string()),
"lm_head.weight" => return Some("output.weight".to_string()),
_ => {}
}
let stripped = name.strip_prefix("model.layers.")?;
let dot = stripped.find('.')?;
let (layer_str, rest_with_dot) = stripped.split_at(dot);
let layer: usize = layer_str.parse().ok()?;
if layer.to_string() != layer_str {
return None;
}
let rest = &rest_with_dot[1..];
let suffix = match rest {
"input_layernorm.weight" => "attn_norm.weight",
"post_attention_layernorm.weight" => "ffn_norm.weight",
"self_attn.q_proj.weight" => "attn_q.weight",
"self_attn.k_proj.weight" => "attn_k.weight",
"self_attn.v_proj.weight" => "attn_v.weight",
"self_attn.o_proj.weight" => "attn_output.weight",
"self_attn.q_norm.weight" => "attn_q_norm.weight",
"self_attn.k_norm.weight" => "attn_k_norm.weight",
"mlp.gate_proj.weight" => "ffn_gate.weight",
"mlp.up_proj.weight" => "ffn_up.weight",
"mlp.down_proj.weight" => "ffn_down.weight",
_ => return None,
};
Some(format!("blk.{layer}.{suffix}"))
}
fn count_deepstack_layers(config: &serde_json::Value) -> u32 {
let vc = config
.get("thinker_config")
.and_then(|tc| tc.get("vision_config"))
.or_else(|| config.get("vision_config"));
vc.and_then(|v| v.get("deepstack_visual_indexes"))
.and_then(|a| a.as_array())
.map(|a| a.len() as u32)
.unwrap_or(0)
}
pub fn build_metadata(
config: &serde_json::Value,
file_type: u32,
model_card: Option<&crate::convert::model_card::ModelCard>,
sampling: Option<&crate::convert::model_card::SamplingConfig>,
model_dir_basename: Option<&str>,
n_deepstack_override: Option<u32>,
) -> Vec<(String, MetaValue)> {
use crate::convert::model_card::{
emit_general_postlude, emit_general_prelude, get_model_id_components,
};
let raw_name = model_dir_basename
.map(|s| s.to_string())
.or_else(|| {
config
.get("_name_or_path")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "model".to_string());
let id_components = get_model_id_components(&raw_name);
let display_name = id_components
.name
.clone()
.unwrap_or_else(|| raw_name.clone());
let hidden_size = config["hidden_size"]
.as_u64()
.expect("config.json missing required key `hidden_size`") as u32;
let n_layers = config["num_hidden_layers"]
.as_u64()
.expect("config.json missing required key `num_hidden_layers`") as u32;
let ffn_len = config["intermediate_size"]
.as_u64()
.expect("config.json missing required key `intermediate_size`") as u32;
let n_head = config["num_attention_heads"]
.as_u64()
.expect("config.json missing required key `num_attention_heads`") as u32;
let ctx_len = config["max_position_embeddings"]
.as_u64()
.expect("config.json missing required key `max_position_embeddings`")
as u32;
let rms_eps = config["rms_norm_eps"]
.as_f64()
.expect("config.json missing required key `rms_norm_eps`") as f32;
let n_head_kv = config
.get("num_key_value_heads")
.and_then(|v| v.as_u64())
.map(|x| x as u32)
.unwrap_or(n_head);
let rope_theta = config
.get("rope_theta")
.and_then(|v| v.as_f64())
.unwrap_or(1_000_000.0) as f32;
let head_dim = config
.get("head_dim")
.and_then(|v| v.as_u64())
.map(|x| x as u32);
let mrope_section: Option<Vec<i32>> = config
.get("rope_scaling")
.and_then(|rs| rs.get("mrope_section"))
.and_then(|m| m.as_array())
.map(|arr| {
let mut out: Vec<i32> = arr
.iter()
.filter_map(|v| v.as_i64().map(|x| x as i32))
.collect();
while out.len() < 4 {
out.push(0);
}
out.truncate(4);
out
});
let n_deepstack = n_deepstack_override.unwrap_or_else(|| count_deepstack_layers(config));
let mut kv: Vec<(String, MetaValue)> = emit_general_prelude(
"qwen3vl",
display_name,
&id_components,
None,
model_card,
sampling,
);
kv.push(("qwen3vl.block_count".into(), MetaValue::U32(n_layers)));
kv.push(("qwen3vl.context_length".into(), MetaValue::U32(ctx_len)));
kv.push((
"qwen3vl.embedding_length".into(),
MetaValue::U32(hidden_size),
));
kv.push((
"qwen3vl.feed_forward_length".into(),
MetaValue::U32(ffn_len),
));
kv.push((
"qwen3vl.attention.head_count".into(),
MetaValue::U32(n_head),
));
kv.push((
"qwen3vl.attention.head_count_kv".into(),
MetaValue::U32(n_head_kv),
));
if let Some(sections) = mrope_section {
kv.push((
"qwen3vl.rope.dimension_sections".into(),
MetaValue::ArrayI32(sections),
));
}
kv.push(("qwen3vl.rope.freq_base".into(), MetaValue::F32(rope_theta)));
kv.push((
"qwen3vl.attention.layer_norm_rms_epsilon".into(),
MetaValue::F32(rms_eps),
));
if let Some(hd) = head_dim {
kv.push(("qwen3vl.attention.key_length".into(), MetaValue::U32(hd)));
kv.push(("qwen3vl.attention.value_length".into(), MetaValue::U32(hd)));
}
kv.push((
"qwen3vl.n_deepstack_layers".into(),
MetaValue::U32(n_deepstack),
));
kv.extend(emit_general_postlude(file_type));
kv
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn qwen3vl_text_tensor_name_round_trip() {
let cases: &[(&str, &str)] = &[
("model.embed_tokens.weight", "token_embd.weight"),
("model.norm.weight", "output_norm.weight"),
("lm_head.weight", "output.weight"),
("thinker.model.embed_tokens.weight", "token_embd.weight"),
("thinker.model.norm.weight", "output_norm.weight"),
("thinker.lm_head.weight", "output.weight"),
(
"model.layers.0.input_layernorm.weight",
"blk.0.attn_norm.weight",
),
(
"model.layers.15.input_layernorm.weight",
"blk.15.attn_norm.weight",
),
(
"model.layers.35.input_layernorm.weight",
"blk.35.attn_norm.weight",
),
(
"model.layers.0.post_attention_layernorm.weight",
"blk.0.ffn_norm.weight",
),
(
"model.layers.7.self_attn.q_proj.weight",
"blk.7.attn_q.weight",
),
(
"model.layers.7.self_attn.k_proj.weight",
"blk.7.attn_k.weight",
),
(
"model.layers.7.self_attn.v_proj.weight",
"blk.7.attn_v.weight",
),
(
"model.layers.7.self_attn.o_proj.weight",
"blk.7.attn_output.weight",
),
(
"model.layers.7.self_attn.q_norm.weight",
"blk.7.attn_q_norm.weight",
),
(
"model.layers.7.self_attn.k_norm.weight",
"blk.7.attn_k_norm.weight",
),
(
"model.layers.3.mlp.gate_proj.weight",
"blk.3.ffn_gate.weight",
),
("model.layers.3.mlp.up_proj.weight", "blk.3.ffn_up.weight"),
(
"model.layers.3.mlp.down_proj.weight",
"blk.3.ffn_down.weight",
),
(
"thinker.model.layers.5.self_attn.q_norm.weight",
"blk.5.attn_q_norm.weight",
),
];
for &(hf, expected_gguf) in cases {
let got = map_tensor_name(hf);
assert_eq!(
got.as_deref(),
Some(expected_gguf),
"map_tensor_name({hf:?}) = {got:?}, want Some({expected_gguf:?})"
);
}
}
#[test]
fn qwen3vl_text_rejects_vision_and_deepstack_tensors() {
assert_eq!(
map_tensor_name("visual.patch_embed.proj.weight"),
None,
"vision-side patch_embed lives in mmproj, not text decoder"
);
assert_eq!(
map_tensor_name("model.visual.blocks.5.attn.proj.weight"),
None,
"vision-side block tensor lives in mmproj"
);
assert_eq!(
map_tensor_name("visual.deepstack_merger_list.0.norm.weight"),
None,
"deepstack merger norm is mmproj-side"
);
assert_eq!(
map_tensor_name("visual.deepstack_merger_list.1.linear_fc1.weight"),
None,
"deepstack merger fc1 is mmproj-side"
);
assert_eq!(
map_tensor_name("visual.deepstack_merger_list.2.linear_fc2.bias"),
None,
"deepstack merger fc2 is mmproj-side"
);
assert_eq!(
map_tensor_name("model.visual.deepstack_merger_list.0.norm.weight"),
None,
"deepstack with model. prefix is mmproj-side"
);
assert_eq!(map_tensor_name("mtp.embed_tokens.weight"), None);
assert_eq!(
map_tensor_name("audio_tower.encoder.layer.0.conv1.weight"),
None
);
}
#[test]
fn qwen3vl_text_rejects_unknown_kinds() {
assert_eq!(map_tensor_name("model.unknown.weight"), None);
assert_eq!(map_tensor_name("transformer.layers.0.attn.weight"), None);
assert_eq!(
map_tensor_name("model.layers.0.self_attn.q_proj.bias"),
None
);
assert_eq!(
map_tensor_name("model.layers.01.self_attn.q_proj.weight"),
None
);
assert_eq!(
map_tensor_name("model.layers..self_attn.q_proj.weight"),
None
);
assert_eq!(map_tensor_name("model.layers.0.unknown.weight"), None);
assert_eq!(
map_tensor_name("model.layers.0.mlp.experts.0.gate_proj.weight"),
None
);
assert_eq!(
map_tensor_name("model.thinker.layers.0.input_layernorm.weight"),
None
);
}
#[test]
fn qwen3vl_text_metadata_built_from_config() {
let cfg = json!({
"_name_or_path": "Qwen/Qwen3-VL-2B-Instruct",
"hidden_size": 2048,
"num_hidden_layers": 28,
"intermediate_size": 6144,
"num_attention_heads": 16,
"num_key_value_heads": 8,
"head_dim": 128,
"max_position_embeddings": 128000,
"rms_norm_eps": 1.0e-6,
"rope_theta": 5_000_000.0,
"rope_scaling": {
"rope_type": "mrope",
"mrope_section": [24, 20, 20] },
"vision_config": {
"deepstack_visual_indexes": [5, 11, 17]
},
});
let kv = build_metadata(&cfg, 17 , None, None, None, None);
assert_eq!(
kv.len(),
21,
"Qwen3VL emits 21 KV pairs when head_dim, mrope_section, and \
deepstack are all present (incl. canonical general.* prelude)"
);
let by_key: std::collections::HashMap<_, _> =
kv.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
assert_eq!(
by_key["general.architecture"],
MetaValue::String("qwen3vl".into())
);
assert!(
matches!(by_key.get("general.name"), Some(MetaValue::String(_))),
"general.name must be present and a string"
);
assert_eq!(by_key["qwen3vl.context_length"], MetaValue::U32(128000));
assert_eq!(by_key["qwen3vl.embedding_length"], MetaValue::U32(2048));
assert_eq!(by_key["qwen3vl.block_count"], MetaValue::U32(28));
assert_eq!(by_key["qwen3vl.feed_forward_length"], MetaValue::U32(6144));
assert_eq!(by_key["qwen3vl.attention.head_count"], MetaValue::U32(16));
assert_eq!(by_key["qwen3vl.attention.head_count_kv"], MetaValue::U32(8));
assert_eq!(by_key["qwen3vl.attention.key_length"], MetaValue::U32(128));
assert_eq!(
by_key["qwen3vl.attention.value_length"],
MetaValue::U32(128)
);
assert_eq!(
by_key["qwen3vl.attention.layer_norm_rms_epsilon"],
MetaValue::F32(1.0e-6)
);
assert_eq!(
by_key["qwen3vl.rope.freq_base"],
MetaValue::F32(5_000_000.0)
);
assert_eq!(
by_key["qwen3vl.rope.dimension_sections"],
MetaValue::ArrayI32(vec![24, 20, 20, 0])
);
assert_eq!(
by_key["qwen3vl.n_deepstack_layers"],
MetaValue::U32(3),
"deepstack count = len(deepstack_visual_indexes)"
);
assert_eq!(by_key["general.file_type"], MetaValue::U32(17));
}
#[test]
fn qwen3vl_text_metadata_optional_key_defaults() {
let cfg = json!({
"hidden_size": 32,
"num_hidden_layers": 1,
"intermediate_size": 64,
"num_attention_heads": 4,
"max_position_embeddings": 2048,
"rms_norm_eps": 1.0e-6,
});
let kv = build_metadata(&cfg, 0, None, None, None, None);
assert_eq!(kv.len(), 14, "14 KVs when head_dim + mrope absent");
let by_key: std::collections::HashMap<_, _> =
kv.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
assert_eq!(
by_key["general.architecture"],
MetaValue::String("qwen3vl".into())
);
assert_eq!(
by_key["general.name"],
MetaValue::String("Model".into()),
"name defaults to title-cased 'Model' when no source available"
);
assert_eq!(
by_key["qwen3vl.attention.head_count_kv"],
MetaValue::U32(4),
"num_key_value_heads defaults to num_attention_heads"
);
assert!(
!by_key.contains_key("qwen3vl.attention.key_length"),
"key_length omitted when head_dim absent"
);
assert!(
!by_key.contains_key("qwen3vl.attention.value_length"),
"value_length omitted when head_dim absent"
);
assert_eq!(
by_key["qwen3vl.rope.freq_base"],
MetaValue::F32(1_000_000.0),
"rope_theta defaults to 1_000_000.0 (Qwen3-VL convention)"
);
assert!(
!by_key.contains_key("qwen3vl.rope.dimension_sections"),
"mrope sections omitted when rope_scaling.mrope_section absent"
);
assert_eq!(
by_key["qwen3vl.n_deepstack_layers"],
MetaValue::U32(0),
"deepstack count defaults to 0 when no vision_config"
);
assert_eq!(by_key["general.file_type"], MetaValue::U32(0));
}
#[test]
fn qwen3vl_text_metadata_thinker_config_deepstack_count() {
let cfg = json!({
"hidden_size": 32,
"num_hidden_layers": 1,
"intermediate_size": 64,
"num_attention_heads": 4,
"max_position_embeddings": 2048,
"rms_norm_eps": 1.0e-6,
"thinker_config": {
"vision_config": {
"deepstack_visual_indexes": [3, 7, 11, 15, 19]
}
},
});
let kv = build_metadata(&cfg, 0, None, None, None, None);
let by_key: std::collections::HashMap<_, _> =
kv.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
assert_eq!(
by_key["qwen3vl.n_deepstack_layers"],
MetaValue::U32(5),
"thinker_config.vision_config.deepstack_visual_indexes \
must be reachable; len = 5"
);
}
#[test]
fn qwen3vl_text_metadata_ftype_round_trips() {
let cfg = json!({
"hidden_size": 32,
"num_hidden_layers": 1,
"intermediate_size": 64,
"num_attention_heads": 4,
"max_position_embeddings": 2048,
"rms_norm_eps": 1.0e-6,
});
for &ftype in &[0u32, 1, 7, 15, 17, 23] {
let kv = build_metadata(&cfg, ftype, None, None, None, None);
let by_key: std::collections::HashMap<_, _> =
kv.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
assert_eq!(
by_key["general.file_type"],
MetaValue::U32(ftype),
"file_type {ftype} must round-trip as MetaValue::U32"
);
}
}
}