use std::fs;
use std::io::Write;
use std::path::Path;
use crate::error::InferenceError;
use crate::model::qwen35::qwen_required_tensor_names;
use crate::model::qwen35_config::Qwen35Config;
use crate::quant::quarot::forward_equivalence::{
ForwardEquivalenceConfig, ForwardEquivalenceReport, assert_forward_equivalence_qwen35,
};
use crate::quant::quarot::hadamard::RandomizedHadamard;
use crate::quant::quarot::io::QuarotTensorReader;
use crate::quant::quarot::lm_head::{
materialize_lm_head_for_qwen35, qwen35_final_norm_fusion_target,
untie_word_embeddings_in_config_json,
};
use crate::quant::quarot::pipeline::{
TensorEntry, absorb_rotations, fuse_rmsnorms, load_tensors_f64,
};
use crate::quant::quarot::plan::RotationPlan;
use crate::quant::quarot::rmsnorm_fusion::qwen35_per_layer_fusion_plan;
use crate::weights::q4_weights::{q4_f32_to_f16, quantize_f64_to_q4, save_q4_file};
#[derive(Debug, Clone)]
pub struct ConversionOptions {
pub rotation_seed: u64,
pub tolerance: f64,
pub num_probe_tokens: usize,
pub dry_run: bool,
}
impl Default for ConversionOptions {
fn default() -> Self {
Self {
rotation_seed: 0xCAFE_BABE_DEAD_BEEF,
tolerance: 1e-5,
num_probe_tokens: 4,
dry_run: false,
}
}
}
#[derive(Debug, Clone)]
pub struct ConversionReport {
pub planned_quantized: usize,
pub kept_f16: usize,
pub total_bytes_in: u64,
pub total_bytes_out: u64,
pub forward_equivalence: ForwardEquivalenceReport,
pub was_tied: bool,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct IndexEntry {
name: String,
file: String,
quantized: bool,
shape: Vec<usize>,
numel: usize,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct QuantizeIndex {
#[serde(skip_serializing_if = "Option::is_none")]
quarot_seed: Option<u64>,
tensors: Vec<IndexEntry>,
}
fn inject_quarot_seed(json: &str, seed: u64) -> Result<String, InferenceError> {
let mut value: serde_json::Value = serde_json::from_str(json)
.map_err(|e| InferenceError::Inference(format!("inject_quarot_seed: invalid JSON: {e}")))?;
let obj = value.as_object_mut().ok_or_else(|| {
InferenceError::Inference(
"inject_quarot_seed: top-level JSON must be an object".to_string(),
)
})?;
if let Some(text_config) = obj.get_mut("text_config")
&& let Some(text_obj) = text_config.as_object_mut()
{
text_obj.insert(
"quarot_rotation_seed".to_string(),
serde_json::Value::Number(seed.into()),
);
}
obj.insert(
"quarot_rotation_seed".to_string(),
serde_json::Value::Number(seed.into()),
);
serde_json::to_string_pretty(&value).map_err(|e| {
InferenceError::Inference(format!("inject_quarot_seed: serialize failed: {e}"))
})
}
fn f16_file_byte_count(data_len: usize, shape_len: usize) -> u64 {
let header: u64 = 4 + 4 + 4 + 8 * shape_len as u64 + 8;
let payload: u64 = data_len as u64 * 2;
header + payload
}
fn write_mtp_weights_quarot(
reader: &QuarotTensorReader,
output_dir: &Path,
dry_run: bool,
index_entries: &mut Vec<IndexEntry>,
kept_f16: &mut usize,
_planned_quantized: &mut usize,
total_bytes_out: &mut u64,
) -> Result<(), InferenceError> {
let proj_names = [
"mtp.fc.weight",
"mtp.layers.0.self_attn.q_proj.weight",
"mtp.layers.0.self_attn.k_proj.weight",
"mtp.layers.0.self_attn.v_proj.weight",
"mtp.layers.0.self_attn.o_proj.weight",
"mtp.layers.0.mlp.gate_proj.weight",
"mtp.layers.0.mlp.up_proj.weight",
"mtp.layers.0.mlp.down_proj.weight",
];
let norm_names = [
"mtp.layers.0.input_layernorm.weight",
"mtp.layers.0.post_attention_layernorm.weight",
"mtp.layers.0.self_attn.q_norm.weight",
"mtp.layers.0.self_attn.k_norm.weight",
"mtp.norm.weight",
"mtp.pre_fc_norm_embedding.weight",
"mtp.pre_fc_norm_hidden.weight",
];
let mut process_as_f16 = |name: &str| -> Result<(), InferenceError> {
if !reader.has_tensor(name) {
return Ok(());
}
let (data, shape) = reader.read_tensor_f64(name)?;
let sanitized = sanitize_tensor_name(name);
let file_name = format!("{sanitized}.f16");
*total_bytes_out += f16_file_byte_count(data.len(), shape.len());
if !dry_run {
let out_path = output_dir.join(&file_name);
write_f16_file(&out_path, &data, &shape)?;
}
*kept_f16 += 1;
index_entries.push(IndexEntry {
name: name.to_string(),
file: file_name,
quantized: false,
shape: shape.clone(),
numel: data.len(),
});
Ok(())
};
for name in &proj_names {
process_as_f16(name)?;
}
for name in &norm_names {
process_as_f16(name)?;
}
Ok(())
}
pub fn convert_quarot_qwen35(
input_dir: &Path,
output_dir: &Path,
opts: &ConversionOptions,
) -> Result<ConversionReport, InferenceError> {
if !opts.dry_run {
validate_output_dir_layout(input_dir, output_dir)?;
}
let config_path = input_dir.join("config.json");
let config_json = fs::read_to_string(&config_path).map_err(|e| {
InferenceError::Inference(format!(
"convert_quarot_qwen35: failed to read {}: {e}",
config_path.display()
))
})?;
let cfg = Qwen35Config::from_config_json_str(&config_json)?;
if !cfg.hidden_size.is_power_of_two() {
return Err(InferenceError::Inference(format!(
"convert_quarot_qwen35: hidden_size={} is not a power of 2; \
QuaRot v0 only supports power-of-2 hidden dims \
(see ADR-044 §Model coverage)",
cfg.hidden_size
)));
}
if cfg.is_moe() {
return Err(InferenceError::Inference(
"convert_quarot_qwen35: MoE configs are deferred to v1 (see ADR-044 §Out of v0)"
.to_string(),
));
}
let reader = QuarotTensorReader::open(input_dir)?;
let required_names = qwen_required_tensor_names(&cfg);
let mut total_bytes_in: u64 = required_names
.iter()
.map(|name| reader.tensor_byte_len(name))
.collect::<Result<Vec<u64>, _>>()?
.into_iter()
.sum();
if cfg.mtp_num_hidden_layers > 0 {
let mtp_names = [
"mtp.fc.weight",
"mtp.layers.0.self_attn.q_proj.weight",
"mtp.layers.0.self_attn.k_proj.weight",
"mtp.layers.0.self_attn.v_proj.weight",
"mtp.layers.0.self_attn.o_proj.weight",
"mtp.layers.0.mlp.gate_proj.weight",
"mtp.layers.0.mlp.up_proj.weight",
"mtp.layers.0.mlp.down_proj.weight",
"mtp.layers.0.input_layernorm.weight",
"mtp.layers.0.post_attention_layernorm.weight",
"mtp.layers.0.self_attn.q_norm.weight",
"mtp.layers.0.self_attn.k_norm.weight",
"mtp.norm.weight",
"mtp.pre_fc_norm_embedding.weight",
"mtp.pre_fc_norm_hidden.weight",
];
for name in &mtp_names {
if reader.has_tensor(name) {
total_bytes_in += reader.tensor_byte_len(name)?;
}
}
}
let mut working_set = load_tensors_f64(&reader, &required_names)?;
let was_tied = cfg.tie_word_embeddings;
if was_tied {
materialize_lm_head_for_qwen35(&mut working_set, &cfg)?;
}
let original_snapshot = working_set.clone();
let mut fusion_plan = qwen35_per_layer_fusion_plan(&cfg)?;
fusion_plan.push(qwen35_final_norm_fusion_target());
let rotation_plan = RotationPlan::qwen35_residual_stream_linear_layers();
let rotation = RandomizedHadamard::new(opts.rotation_seed, cfg.hidden_size)?;
fuse_rmsnorms(&mut working_set, &fusion_plan)?;
absorb_rotations(&mut working_set, &rotation_plan, &rotation)?;
let forward_equivalence = assert_forward_equivalence_qwen35(
&original_snapshot,
&working_set,
&cfg,
&rotation,
&ForwardEquivalenceConfig {
num_probe_tokens: opts.num_probe_tokens,
tolerance: opts.tolerance,
seed: opts.rotation_seed,
},
)?;
if !opts.dry_run {
fs::create_dir_all(output_dir).map_err(|e| {
InferenceError::Inference(format!(
"convert_quarot_qwen35: failed to create output directory {}: {e}",
output_dir.display()
))
})?;
}
let mut names: Vec<String> = working_set.keys().cloned().collect();
names.sort();
let mut index_entries: Vec<IndexEntry> = Vec::with_capacity(names.len());
let mut planned_quantized: usize = 0;
let mut kept_f16: usize = 0;
let mut total_bytes_out: u64 = 0;
for name in &names {
let entry: &TensorEntry = &working_set[name];
let sanitized = sanitize_tensor_name(name);
let is_planned = rotation_plan.for_tensor(name).is_some();
if is_planned {
if entry.shape.len() != 2 {
return Err(InferenceError::Inference(format!(
"convert_quarot_qwen35: planned tensor `{name}` has shape {:?}, \
expected 2-D for Q4 quantization (rotation plan invariant violated)",
entry.shape
)));
}
let header_bytes = (4 + 4 + 4 + 8 * entry.shape.len() + 8) as u64;
let n_blocks = entry.data.len().div_ceil(32) as u64;
total_bytes_out += header_bytes + n_blocks.saturating_mul(20);
if !opts.dry_run {
let q4 = quantize_f64_to_q4(&entry.data, &entry.shape)?;
let file_name = format!("{sanitized}.q4");
let out_path = output_dir.join(&file_name);
save_q4_file(&out_path, &q4).map_err(|e| {
InferenceError::Inference(format!(
"convert_quarot_qwen35: failed to write {}: {e}",
out_path.display()
))
})?;
index_entries.push(IndexEntry {
name: name.clone(),
file: file_name,
quantized: true,
shape: entry.shape.clone(),
numel: entry.data.len(),
});
}
planned_quantized += 1;
} else {
total_bytes_out += f16_file_byte_count(entry.data.len(), entry.shape.len());
if !opts.dry_run {
let file_name = format!("{sanitized}.f16");
let out_path = output_dir.join(&file_name);
write_f16_file(&out_path, &entry.data, &entry.shape)?;
index_entries.push(IndexEntry {
name: name.clone(),
file: file_name,
quantized: false,
shape: entry.shape.clone(),
numel: entry.data.len(),
});
}
kept_f16 += 1;
}
}
if cfg.mtp_num_hidden_layers > 0 {
write_mtp_weights_quarot(
&reader,
output_dir,
opts.dry_run,
&mut index_entries,
&mut kept_f16,
&mut planned_quantized,
&mut total_bytes_out,
)?;
}
if !opts.dry_run {
let index_path = output_dir.join("quantize_index.json");
let index_record = QuantizeIndex {
quarot_seed: Some(opts.rotation_seed),
tensors: index_entries,
};
let index_json = serde_json::to_string_pretty(&index_record).map_err(|e| {
InferenceError::Inference(format!(
"convert_quarot_qwen35: failed to serialize quantize_index.json: {e}"
))
})?;
fs::write(&index_path, index_json).map_err(|e| {
InferenceError::Inference(format!(
"convert_quarot_qwen35: failed to write {}: {e}",
index_path.display()
))
})?;
let mut output_config_json = untie_word_embeddings_in_config_json(&config_json)?;
output_config_json = inject_quarot_seed(&output_config_json, opts.rotation_seed)?;
let out_config_path = output_dir.join("config.json");
fs::write(&out_config_path, &output_config_json).map_err(|e| {
InferenceError::Inference(format!(
"convert_quarot_qwen35: failed to write {}: {e}",
out_config_path.display()
))
})?;
}
Ok(ConversionReport {
planned_quantized,
kept_f16,
total_bytes_in,
total_bytes_out,
forward_equivalence,
was_tied,
})
}
fn validate_output_dir_layout(input_dir: &Path, output_dir: &Path) -> Result<(), InferenceError> {
let input_canon = fs::canonicalize(input_dir).map_err(|e| {
InferenceError::Inference(format!(
"validate_output_dir_layout: cannot canonicalize input_dir {}: {e}",
input_dir.display()
))
})?;
if !output_dir.exists() {
return Ok(());
}
let output_canon = fs::canonicalize(output_dir).map_err(|e| {
InferenceError::Inference(format!(
"validate_output_dir_layout: cannot canonicalize output_dir {}: {e}",
output_dir.display()
))
})?;
if input_canon == output_canon {
return Err(InferenceError::Inference(format!(
"validate_output_dir_layout: input and output directories resolve to the same \
path ({}); refusing to overwrite source artifacts. Pass a separate \
--output-dir to avoid corrupting the input checkpoint.",
input_canon.display()
)));
}
let mut entries = fs::read_dir(output_dir).map_err(|e| {
InferenceError::Inference(format!(
"validate_output_dir_layout: cannot read output_dir {}: {e}",
output_dir.display()
))
})?;
if entries.next().is_some() {
return Err(InferenceError::Inference(format!(
"validate_output_dir_layout: output_dir {} is not empty; refusing to mix \
new conversion output with pre-existing files. Remove the directory or \
pass a fresh path — a refused conversion must not leave a partial mix \
of stale + new artifacts.",
output_canon.display()
)));
}
Ok(())
}
fn sanitize_tensor_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect()
}
fn write_f16_file(path: &Path, data: &[f64], shape: &[usize]) -> Result<usize, InferenceError> {
let mut file = fs::File::create(path).map_err(|e| {
InferenceError::Inference(format!(
"write_f16_file: failed to create {}: {e}",
path.display()
))
})?;
let mut bytes_written: usize = 0;
let mut write_all = |buf: &[u8]| -> Result<(), InferenceError> {
file.write_all(buf).map_err(|e| {
InferenceError::Inference(format!(
"write_f16_file: write failure on {}: {e}",
path.display()
))
})
};
write_all(b"KHF1")?;
bytes_written += 4;
write_all(&1u32.to_le_bytes())?;
bytes_written += 4;
write_all(&(shape.len() as u32).to_le_bytes())?;
bytes_written += 4;
for &dim in shape {
write_all(&(dim as u64).to_le_bytes())?;
bytes_written += 8;
}
write_all(&(data.len() as u64).to_le_bytes())?;
bytes_written += 8;
let mut payload = Vec::with_capacity(data.len() * 2);
for &v in data {
let h = q4_f32_to_f16(v as f32);
payload.extend_from_slice(&h.to_le_bytes());
}
write_all(&payload)?;
bytes_written += payload.len();
Ok(bytes_written)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::qwen35_config::{LayerType, compute_layer_types};
use serde_json::Value;
use std::path::PathBuf;
fn tiny_cfg(tied: bool) -> Qwen35Config {
let mut cfg = Qwen35Config::qwen35_0_8b();
cfg.hidden_size = 8;
cfg.num_hidden_layers = 2;
cfg.vocab_size = 4;
cfg.intermediate_size = 16;
cfg.num_attention_heads = 2;
cfg.num_key_value_heads = 1;
cfg.head_dim = 4;
cfg.linear_num_key_heads = 1;
cfg.linear_key_head_dim = 2;
cfg.linear_value_head_dim = 2;
cfg.linear_num_value_heads = Some(1);
cfg.linear_conv_kernel_dim = 4;
cfg.full_attention_interval = 2;
cfg.layer_types = compute_layer_types(cfg.num_hidden_layers, cfg.full_attention_interval);
cfg.layer_mask = vec![true; cfg.num_hidden_layers];
cfg.tie_word_embeddings = tied;
cfg.rms_norm_eps = 1e-6;
cfg.partial_rotary_factor = 0.25;
cfg.rope_theta = 1_000_000.0;
cfg.max_position_embeddings = 1024;
cfg.eos_token_id = 3;
cfg
}
fn tiny_config_json(cfg: &Qwen35Config) -> String {
let layer_types: Vec<Value> = cfg
.layer_types
.iter()
.map(|t| match t {
LayerType::FullAttention => Value::String("full_attention".into()),
LayerType::LinearAttention => Value::String("linear_attention".into()),
})
.collect();
let mut text_config = serde_json::Map::new();
text_config.insert("hidden_size".into(), Value::from(cfg.hidden_size));
text_config.insert(
"num_hidden_layers".into(),
Value::from(cfg.num_hidden_layers),
);
text_config.insert("vocab_size".into(), Value::from(cfg.vocab_size));
text_config.insert(
"intermediate_size".into(),
Value::from(cfg.intermediate_size),
);
text_config.insert("rms_norm_eps".into(), Value::from(cfg.rms_norm_eps));
text_config.insert(
"num_attention_heads".into(),
Value::from(cfg.num_attention_heads),
);
text_config.insert(
"num_key_value_heads".into(),
Value::from(cfg.num_key_value_heads),
);
text_config.insert("head_dim".into(), Value::from(cfg.head_dim));
text_config.insert("rope_theta".into(), Value::from(cfg.rope_theta));
text_config.insert(
"partial_rotary_factor".into(),
Value::from(cfg.partial_rotary_factor),
);
text_config.insert(
"linear_num_key_heads".into(),
Value::from(cfg.linear_num_key_heads),
);
if let Some(v) = cfg.linear_num_value_heads {
text_config.insert("linear_num_value_heads".into(), Value::from(v));
}
text_config.insert(
"linear_key_head_dim".into(),
Value::from(cfg.linear_key_head_dim),
);
text_config.insert(
"linear_value_head_dim".into(),
Value::from(cfg.linear_value_head_dim),
);
text_config.insert(
"linear_conv_kernel_dim".into(),
Value::from(cfg.linear_conv_kernel_dim),
);
text_config.insert(
"tie_word_embeddings".into(),
Value::from(cfg.tie_word_embeddings),
);
text_config.insert(
"full_attention_interval".into(),
Value::from(cfg.full_attention_interval),
);
text_config.insert("layer_types".into(), Value::Array(layer_types));
text_config.insert("eos_token_id".into(), Value::from(cfg.eos_token_id));
text_config.insert(
"max_position_embeddings".into(),
Value::from(cfg.max_position_embeddings),
);
if let Some(v) = cfg.num_experts {
text_config.insert("num_experts".into(), Value::from(v));
}
if let Some(v) = cfg.num_experts_per_tok {
text_config.insert("num_experts_per_tok".into(), Value::from(v));
}
if let Some(v) = cfg.moe_intermediate_size {
text_config.insert("moe_intermediate_size".into(), Value::from(v));
}
if let Some(v) = cfg.shared_expert_intermediate_size {
text_config.insert("shared_expert_intermediate_size".into(), Value::from(v));
}
serde_json::to_string_pretty(&serde_json::json!({
"tie_word_embeddings": cfg.tie_word_embeddings,
"text_config": Value::Object(text_config),
}))
.unwrap()
}
fn f32_to_bf16_bits(v: f32) -> u16 {
let bits = v.to_bits();
let lsb = (bits >> 16) & 1;
let rounding_bias = 0x7fff + lsb;
((bits.wrapping_add(rounding_bias)) >> 16) as u16
}
fn write_test_safetensors(path: &Path, tensors: &[(&str, Vec<usize>, &[f64])]) {
let mut header = serde_json::Map::new();
let mut payload: Vec<u8> = Vec::new();
for (name, shape, values) in tensors {
assert_eq!(values.len(), shape.iter().product::<usize>());
let start = payload.len();
for &v in *values {
payload.extend_from_slice(&(v as f32).to_le_bytes());
}
let end = payload.len();
let mut entry = serde_json::Map::new();
entry.insert("dtype".into(), Value::String("F32".into()));
entry.insert(
"shape".into(),
Value::Array(shape.iter().map(|d| Value::from(*d as u64)).collect()),
);
entry.insert(
"data_offsets".into(),
Value::Array(vec![Value::from(start as u64), Value::from(end as u64)]),
);
header.insert((*name).to_string(), Value::Object(entry));
}
let header_str = serde_json::to_string(&Value::Object(header)).unwrap();
let mut file = fs::File::create(path).unwrap();
file.write_all(&(header_str.len() as u64).to_le_bytes())
.unwrap();
file.write_all(header_str.as_bytes()).unwrap();
file.write_all(&payload).unwrap();
}
fn synth_data(n: usize, seed: u64) -> Vec<f64> {
let mut state = seed;
(0..n)
.map(|_| {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let bits = (state >> 11) as u32;
(bits as f64 / u32::MAX as f64) - 0.5
})
.collect()
}
fn write_required_tensors_for(cfg: &Qwen35Config, path: &Path, seed: u64) {
let hidden = cfg.hidden_size;
let vocab = cfg.vocab_size;
let intermediate = cfg.intermediate_size;
let head_dim = cfg.head_dim;
let full_q_dim = cfg.full_q_dim();
let full_kv_dim = cfg.full_kv_dim();
let linear_qkv_dim = cfg.linear_qkv_dim();
let linear_output_dim = cfg.linear_output_dim();
let linear_num_heads = cfg.linear_num_key_heads;
let kernel = cfg.linear_conv_kernel_dim;
let mut entries: Vec<(String, Vec<usize>, Vec<f64>)> = Vec::new();
let mut s = seed;
let mut next = |n: usize| -> Vec<f64> {
s = s.wrapping_add(1);
synth_data(n, s)
};
entries.push((
"model.language_model.embed_tokens.weight".to_string(),
vec![vocab, hidden],
next(vocab * hidden),
));
entries.push((
"model.language_model.norm.weight".to_string(),
vec![hidden],
next(hidden),
));
if !cfg.tie_word_embeddings {
entries.push((
"lm_head.weight".to_string(),
vec![vocab, hidden],
next(vocab * hidden),
));
}
for i in 0..cfg.num_hidden_layers {
let prefix = format!("model.language_model.layers.{i}");
entries.push((
format!("{prefix}.input_layernorm.weight"),
vec![hidden],
next(hidden),
));
entries.push((
format!("{prefix}.post_attention_layernorm.weight"),
vec![hidden],
next(hidden),
));
if cfg.is_full_attention(i) {
entries.push((
format!("{prefix}.self_attn.q_proj.weight"),
vec![2 * full_q_dim, hidden],
next(2 * full_q_dim * hidden),
));
entries.push((
format!("{prefix}.self_attn.k_proj.weight"),
vec![full_kv_dim, hidden],
next(full_kv_dim * hidden),
));
entries.push((
format!("{prefix}.self_attn.v_proj.weight"),
vec![full_kv_dim, hidden],
next(full_kv_dim * hidden),
));
entries.push((
format!("{prefix}.self_attn.o_proj.weight"),
vec![hidden, full_q_dim],
next(hidden * full_q_dim),
));
entries.push((
format!("{prefix}.self_attn.q_norm.weight"),
vec![head_dim],
next(head_dim),
));
entries.push((
format!("{prefix}.self_attn.k_norm.weight"),
vec![head_dim],
next(head_dim),
));
} else {
entries.push((
format!("{prefix}.linear_attn.in_proj_qkv.weight"),
vec![linear_qkv_dim, hidden],
next(linear_qkv_dim * hidden),
));
entries.push((
format!("{prefix}.linear_attn.in_proj_z.weight"),
vec![linear_output_dim, hidden],
next(linear_output_dim * hidden),
));
entries.push((
format!("{prefix}.linear_attn.in_proj_b.weight"),
vec![linear_num_heads, hidden],
next(linear_num_heads * hidden),
));
entries.push((
format!("{prefix}.linear_attn.in_proj_a.weight"),
vec![linear_num_heads, hidden],
next(linear_num_heads * hidden),
));
entries.push((
format!("{prefix}.linear_attn.A_log"),
vec![linear_num_heads],
next(linear_num_heads),
));
entries.push((
format!("{prefix}.linear_attn.dt_bias"),
vec![linear_num_heads],
next(linear_num_heads),
));
entries.push((
format!("{prefix}.linear_attn.conv1d.weight"),
vec![linear_qkv_dim, 1, kernel],
next(linear_qkv_dim * kernel),
));
entries.push((
format!("{prefix}.linear_attn.norm.weight"),
vec![linear_output_dim],
next(linear_output_dim),
));
entries.push((
format!("{prefix}.linear_attn.out_proj.weight"),
vec![hidden, linear_output_dim],
next(hidden * linear_output_dim),
));
}
entries.push((
format!("{prefix}.mlp.gate_proj.weight"),
vec![intermediate, hidden],
next(intermediate * hidden),
));
entries.push((
format!("{prefix}.mlp.up_proj.weight"),
vec![intermediate, hidden],
next(intermediate * hidden),
));
entries.push((
format!("{prefix}.mlp.down_proj.weight"),
vec![hidden, intermediate],
next(hidden * intermediate),
));
}
let borrowed: Vec<(&str, Vec<usize>, &[f64])> = entries
.iter()
.map(|(n, s, d)| (n.as_str(), s.clone(), d.as_slice()))
.collect();
write_test_safetensors(path, &borrowed);
}
fn write_input_dir(cfg: &Qwen35Config, dir: &Path, seed: u64) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join("config.json"), tiny_config_json(cfg)).unwrap();
write_required_tensors_for(cfg, &dir.join("model.safetensors"), seed);
}
#[test]
fn convert_quarot_qwen35_tied_end_to_end() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let cfg = tiny_cfg(true);
write_input_dir(&cfg, &input, 1);
let report = convert_quarot_qwen35(
&input,
&output,
&ConversionOptions {
rotation_seed: 0xC0FFEE,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: false,
},
)
.unwrap();
assert!(report.was_tied);
assert!(report.planned_quantized > 0);
assert!(report.kept_f16 > 0);
assert!(report.total_bytes_out > 0);
assert!(report.forward_equivalence.max_abs_error <= 1e-5);
assert!(output.join("config.json").exists());
assert!(output.join("quantize_index.json").exists());
let lm_head_q4 = output.join("lm_head_weight.q4");
assert!(
lm_head_q4.exists(),
"lm_head .q4 should exist: {lm_head_q4:?}"
);
let out_cfg_str = fs::read_to_string(output.join("config.json")).unwrap();
let out_cfg = Qwen35Config::from_config_json_str(&out_cfg_str).unwrap();
assert!(
!out_cfg.tie_word_embeddings,
"output config must be untied after tied-input conversion"
);
let idx_str = fs::read_to_string(output.join("quantize_index.json")).unwrap();
let idx: serde_json::Value = serde_json::from_str(&idx_str).unwrap();
let tensors = idx
.get("tensors")
.and_then(|v| v.as_array())
.expect("quantize_index.json must have a `tensors` array");
assert_eq!(tensors.len(), report.planned_quantized + report.kept_f16);
assert!(
idx.get("quarot_seed")
.and_then(serde_json::Value::as_u64)
.is_some(),
"quantize_index.json must carry quarot_seed (ADR-051 contract)"
);
}
#[test]
fn convert_quarot_qwen35_untied_end_to_end() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let cfg = tiny_cfg(false);
write_input_dir(&cfg, &input, 2);
let report = convert_quarot_qwen35(
&input,
&output,
&ConversionOptions {
rotation_seed: 0xFEED_FACE,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: false,
},
)
.unwrap();
assert!(!report.was_tied);
assert!(report.planned_quantized > 0);
assert!(output.join("config.json").exists());
let out_cfg_str = fs::read_to_string(output.join("config.json")).unwrap();
let out_cfg = Qwen35Config::from_config_json_str(&out_cfg_str).unwrap();
assert!(!out_cfg.tie_word_embeddings);
}
#[test]
fn convert_quarot_qwen35_dry_run_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let cfg = tiny_cfg(true);
write_input_dir(&cfg, &input, 3);
let report = convert_quarot_qwen35(
&input,
&output,
&ConversionOptions {
rotation_seed: 0xDEADBEEF,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: true,
},
)
.unwrap();
assert!(
report.planned_quantized > 0,
"dry-run must report planned_quantized > 0"
);
assert!(report.kept_f16 > 0, "dry-run must report kept_f16 > 0");
assert!(
report.total_bytes_out > 0,
"dry-run must report total_bytes_out > 0"
);
assert!(report.forward_equivalence.max_abs_error <= 1e-5);
assert!(
!output.exists(),
"dry-run must not create the output directory"
);
}
#[test]
fn convert_quarot_qwen35_refuses_when_tolerance_unmet() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let cfg = tiny_cfg(true);
write_input_dir(&cfg, &input, 4);
let err = convert_quarot_qwen35(
&input,
&output,
&ConversionOptions {
rotation_seed: 0xAB12_34CD,
tolerance: 0.0_f64.next_up(), num_probe_tokens: 2,
dry_run: false,
},
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("forward-equivalence refused") || msg.contains("exceeds tolerance"),
"unexpected error: {msg}"
);
assert!(
!output.exists(),
"refused conversion must not create the output directory"
);
}
#[test]
fn convert_quarot_qwen35_errors_when_config_missing() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
fs::create_dir_all(&input).unwrap();
let err =
convert_quarot_qwen35(&input, &output, &ConversionOptions::default()).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("config.json"), "unexpected error: {msg}");
}
#[test]
fn convert_quarot_qwen35_rejects_non_power_of_two_hidden() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let mut cfg = tiny_cfg(true);
cfg.hidden_size = 10; fs::create_dir_all(&input).unwrap();
fs::write(input.join("config.json"), tiny_config_json(&cfg)).unwrap();
let err =
convert_quarot_qwen35(&input, &output, &ConversionOptions::default()).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("hidden_size=10") && msg.contains("power of 2"),
"unexpected error: {msg}"
);
assert!(!output.exists());
}
#[test]
fn convert_quarot_qwen35_rejects_moe_config() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let mut moe_cfg = tiny_cfg(true);
moe_cfg.num_experts = Some(2);
moe_cfg.num_experts_per_tok = Some(1);
moe_cfg.moe_intermediate_size = Some(moe_cfg.intermediate_size);
fs::create_dir_all(&input).unwrap();
fs::write(input.join("config.json"), tiny_config_json(&moe_cfg)).unwrap();
let err =
convert_quarot_qwen35(&input, &output, &ConversionOptions::default()).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("MoE"), "unexpected error: {msg}");
assert!(
!output.exists(),
"MoE-rejected conversion must not create output dir"
);
}
#[test]
fn dry_run_bytes_out_matches_real_write() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output_dry = tmp.path().join("output_dry");
let output_real = tmp.path().join("output_real");
let cfg = tiny_cfg(false); write_input_dir(&cfg, &input, 99);
let opts = ConversionOptions {
rotation_seed: 0xABCD_5678,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: false,
};
let dry_report = convert_quarot_qwen35(
&input,
&output_dry,
&ConversionOptions {
dry_run: true,
..opts.clone()
},
)
.unwrap();
let real_report = convert_quarot_qwen35(&input, &output_real, &opts).unwrap();
assert_eq!(
dry_report.total_bytes_out, real_report.total_bytes_out,
"dry-run total_bytes_out ({}) must equal real-write total_bytes_out ({})",
dry_report.total_bytes_out, real_report.total_bytes_out,
);
assert!(
dry_report.total_bytes_out > 0,
"total_bytes_out must be > 0; got 0 (accounting is broken)"
);
assert_eq!(
dry_report.planned_quantized, real_report.planned_quantized,
"planned_quantized mismatch between dry and real"
);
assert_eq!(
dry_report.kept_f16, real_report.kept_f16,
"kept_f16 mismatch between dry and real"
);
assert!(
!output_dry.exists(),
"dry-run must not create the output directory"
);
let mut on_disk: u64 = 0;
for dent in std::fs::read_dir(&output_real).unwrap() {
let path = dent.unwrap().path();
if matches!(
path.extension().and_then(|e| e.to_str()),
Some("q4") | Some("f16")
) {
on_disk += std::fs::metadata(&path).unwrap().len();
}
}
assert_eq!(
real_report.total_bytes_out, on_disk,
"reported total_bytes_out ({}) must equal summed on-disk .q4/.f16 file sizes ({})",
real_report.total_bytes_out, on_disk,
);
}
#[test]
fn dry_run_bytes_out_matches_real_write_tied() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output_dry = tmp.path().join("output_dry");
let output_real = tmp.path().join("output_real");
let cfg = tiny_cfg(true);
write_input_dir(&cfg, &input, 100);
let opts = ConversionOptions {
rotation_seed: 0xFACE_CAFE,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: false,
};
let dry_report = convert_quarot_qwen35(
&input,
&output_dry,
&ConversionOptions {
dry_run: true,
..opts.clone()
},
)
.unwrap();
let real_report = convert_quarot_qwen35(&input, &output_real, &opts).unwrap();
assert_eq!(
dry_report.total_bytes_out, real_report.total_bytes_out,
"tied: dry-run total_bytes_out ({}) must equal real-write total_bytes_out ({})",
dry_report.total_bytes_out, real_report.total_bytes_out,
);
assert!(dry_report.total_bytes_out > 0);
assert!(
!output_dry.exists(),
"dry-run must not create the output directory"
);
}
#[test]
fn sanitize_tensor_name_replaces_dots_and_slashes() {
assert_eq!(
sanitize_tensor_name("model.layers.0.mlp.gate_proj.weight"),
"model_layers_0_mlp_gate_proj_weight"
);
assert_eq!(sanitize_tensor_name("lm_head.weight"), "lm_head_weight");
assert_eq!(sanitize_tensor_name("a/b\\c"), "a_b_c");
}
#[test]
fn f16_file_has_khf1_header_and_correct_size() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("test.f16");
let data = vec![1.5_f64, -2.5, 0.25, -0.125];
let shape = vec![2_usize, 2];
let bytes_written = write_f16_file(&p, &data, &shape).unwrap();
let raw = fs::read(&p).unwrap();
assert_eq!(&raw[0..4], b"KHF1");
assert_eq!(u32::from_le_bytes(raw[4..8].try_into().unwrap()), 1);
assert_eq!(u32::from_le_bytes(raw[8..12].try_into().unwrap()), 2);
assert_eq!(u64::from_le_bytes(raw[12..20].try_into().unwrap()), 2);
assert_eq!(u64::from_le_bytes(raw[20..28].try_into().unwrap()), 2);
assert_eq!(u64::from_le_bytes(raw[28..36].try_into().unwrap()), 4);
assert_eq!(raw.len(), 36 + 8);
assert_eq!(bytes_written, raw.len());
}
#[test]
fn q4_f32_to_f16_canonical_and_subnormal_values() {
assert_eq!(q4_f32_to_f16(0.0), 0x0000);
assert_eq!(q4_f32_to_f16(-0.0), 0x8000);
assert_eq!(q4_f32_to_f16(1.0), 0x3c00);
assert_eq!(q4_f32_to_f16(-1.0), 0xbc00);
assert_eq!(q4_f32_to_f16(f32::INFINITY), 0x7c00);
assert_eq!(q4_f32_to_f16(f32::NEG_INFINITY), 0xfc00);
let h = q4_f32_to_f16(1e-7_f32);
assert_ne!(
h, 0,
"1e-7 (an f16 subnormal range value) must not flush to zero \
— that was the codex round-1 Medium"
);
assert_eq!(q4_f32_to_f16(1e-40_f32), 0);
}
#[test]
fn f32_to_bf16_bits_canonical_values() {
assert_eq!(f32_to_bf16_bits(0.0), 0);
assert_eq!(f32_to_bf16_bits(1.0), 0x3f80);
assert_eq!(f32_to_bf16_bits(-1.0), 0xbf80);
}
#[test]
fn convert_quarot_qwen35_rejects_same_input_output_dir() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let cfg = tiny_cfg(true);
write_input_dir(&cfg, &input, 50);
let config_before = fs::read(input.join("config.json")).unwrap();
let err = convert_quarot_qwen35(
&input,
&input, &ConversionOptions::default(),
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("same path"), "unexpected error: {msg}");
let config_after = fs::read(input.join("config.json")).unwrap();
assert_eq!(
config_before, config_after,
"rejected conversion must not have mutated the source config.json"
);
}
#[test]
fn convert_quarot_qwen35_rejects_same_input_output_dir_via_trailing_slash() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let cfg = tiny_cfg(true);
write_input_dir(&cfg, &input, 51);
let same_with_slash = tmp.path().join("input/.");
let err = convert_quarot_qwen35(&input, &same_with_slash, &ConversionOptions::default())
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("same path"), "unexpected error: {msg}");
}
#[test]
fn convert_quarot_qwen35_rejects_non_empty_output_dir() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let cfg = tiny_cfg(true);
write_input_dir(&cfg, &input, 52);
fs::create_dir_all(&output).unwrap();
let stale_path = output.join("stale_artifact.q4");
fs::write(&stale_path, b"old-q4-bytes").unwrap();
let err =
convert_quarot_qwen35(&input, &output, &ConversionOptions::default()).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("not empty"), "unexpected error: {msg}");
assert!(stale_path.exists(), "stale file must not be deleted");
let bytes = fs::read(&stale_path).unwrap();
assert_eq!(&bytes[..], b"old-q4-bytes");
}
#[test]
fn convert_quarot_qwen35_dry_run_ignores_same_output_dir() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let cfg = tiny_cfg(true);
write_input_dir(&cfg, &input, 60);
let listing_before = list_dir_recursive(&input);
let report = convert_quarot_qwen35(
&input,
&input, &ConversionOptions {
rotation_seed: 0xDEAD_C0DE,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: true,
},
)
.unwrap();
assert!(
report.planned_quantized > 0,
"dry-run must compute planned_quantized > 0"
);
assert!(
report.total_bytes_out > 0,
"dry-run must compute total_bytes_out > 0"
);
let listing_after = list_dir_recursive(&input);
assert_eq!(
listing_before, listing_after,
"dry-run must not mutate the directory it shares with input"
);
}
#[test]
fn convert_quarot_qwen35_dry_run_ignores_non_empty_output_dir() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let cfg = tiny_cfg(true);
write_input_dir(&cfg, &input, 61);
fs::create_dir_all(&output).unwrap();
let stale = output.join("stale.q4");
fs::write(&stale, b"old-bytes").unwrap();
let report = convert_quarot_qwen35(
&input,
&output,
&ConversionOptions {
rotation_seed: 0xBEEF_FACE,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: true,
},
)
.unwrap();
assert!(
report.planned_quantized > 0,
"dry-run must compute planned_quantized > 0"
);
assert!(report.kept_f16 > 0, "dry-run must compute kept_f16 > 0");
assert!(
report.total_bytes_out > 0,
"dry-run must compute total_bytes_out > 0"
);
assert!(stale.exists(), "stale file must not be deleted in dry-run");
assert_eq!(fs::read(&stale).unwrap(), b"old-bytes");
let listing: Vec<_> = fs::read_dir(&output)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(listing.len(), 1, "dry-run must not add files: {listing:?}");
}
fn list_dir_recursive(root: &Path) -> Vec<(PathBuf, u64)> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, u64)>) {
for entry in fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
let metadata = entry.metadata().unwrap();
if metadata.is_dir() {
walk(root, &path, out);
} else {
let rel = path.strip_prefix(root).unwrap().to_path_buf();
out.push((rel, metadata.len()));
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort();
out
}
#[test]
fn convert_quarot_qwen35_accepts_empty_pre_existing_output_dir() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let cfg = tiny_cfg(true);
write_input_dir(&cfg, &input, 53);
fs::create_dir_all(&output).unwrap();
let report = convert_quarot_qwen35(
&input,
&output,
&ConversionOptions {
rotation_seed: 0xABCD_EF01,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: false,
},
)
.unwrap();
assert!(report.planned_quantized > 0);
assert!(output.join("config.json").exists());
}
fn tiny_cfg_with_mtp(tied: bool) -> Qwen35Config {
let mut cfg = tiny_cfg(tied);
cfg.mtp_num_hidden_layers = 1;
cfg
}
fn tiny_config_json_with_mtp(cfg: &Qwen35Config) -> String {
let base = tiny_config_json(cfg);
let mut root: serde_json::Value = serde_json::from_str(&base).unwrap();
root.get_mut("text_config")
.unwrap()
.as_object_mut()
.unwrap()
.insert(
"mtp_num_hidden_layers".into(),
serde_json::Value::from(cfg.mtp_num_hidden_layers),
);
serde_json::to_string_pretty(&root).unwrap()
}
fn write_mtp_tensors_into(path: &Path, cfg: &Qwen35Config, mut seed: u64) {
let hidden = cfg.hidden_size;
let intermediate = cfg.intermediate_size;
let head_dim = cfg.head_dim;
let mut next = |n: usize| -> Vec<f64> {
seed = seed.wrapping_add(1);
synth_data(n, seed)
};
let vocab = cfg.vocab_size;
let full_q_dim = cfg.full_q_dim();
let full_kv_dim = cfg.full_kv_dim();
let linear_qkv_dim = cfg.linear_qkv_dim();
let linear_output_dim = cfg.linear_output_dim();
let linear_num_heads = cfg.linear_num_key_heads;
let kernel = cfg.linear_conv_kernel_dim;
let mut entries: Vec<(String, Vec<usize>, Vec<f64>)> = Vec::new();
entries.push((
"model.language_model.embed_tokens.weight".into(),
vec![vocab, hidden],
next(vocab * hidden),
));
entries.push((
"model.language_model.norm.weight".into(),
vec![hidden],
next(hidden),
));
if !cfg.tie_word_embeddings {
entries.push((
"lm_head.weight".into(),
vec![vocab, hidden],
next(vocab * hidden),
));
}
for i in 0..cfg.num_hidden_layers {
let prefix = format!("model.language_model.layers.{i}");
entries.push((
format!("{prefix}.input_layernorm.weight"),
vec![hidden],
next(hidden),
));
entries.push((
format!("{prefix}.post_attention_layernorm.weight"),
vec![hidden],
next(hidden),
));
if cfg.is_full_attention(i) {
entries.push((
format!("{prefix}.self_attn.q_proj.weight"),
vec![2 * full_q_dim, hidden],
next(2 * full_q_dim * hidden),
));
entries.push((
format!("{prefix}.self_attn.k_proj.weight"),
vec![full_kv_dim, hidden],
next(full_kv_dim * hidden),
));
entries.push((
format!("{prefix}.self_attn.v_proj.weight"),
vec![full_kv_dim, hidden],
next(full_kv_dim * hidden),
));
entries.push((
format!("{prefix}.self_attn.o_proj.weight"),
vec![hidden, full_q_dim],
next(hidden * full_q_dim),
));
entries.push((
format!("{prefix}.self_attn.q_norm.weight"),
vec![head_dim],
next(head_dim),
));
entries.push((
format!("{prefix}.self_attn.k_norm.weight"),
vec![head_dim],
next(head_dim),
));
} else {
entries.push((
format!("{prefix}.linear_attn.in_proj_qkv.weight"),
vec![linear_qkv_dim, hidden],
next(linear_qkv_dim * hidden),
));
entries.push((
format!("{prefix}.linear_attn.in_proj_z.weight"),
vec![linear_output_dim, hidden],
next(linear_output_dim * hidden),
));
entries.push((
format!("{prefix}.linear_attn.in_proj_b.weight"),
vec![linear_num_heads, hidden],
next(linear_num_heads * hidden),
));
entries.push((
format!("{prefix}.linear_attn.in_proj_a.weight"),
vec![linear_num_heads, hidden],
next(linear_num_heads * hidden),
));
entries.push((
format!("{prefix}.linear_attn.A_log"),
vec![linear_num_heads],
next(linear_num_heads),
));
entries.push((
format!("{prefix}.linear_attn.dt_bias"),
vec![linear_num_heads],
next(linear_num_heads),
));
entries.push((
format!("{prefix}.linear_attn.conv1d.weight"),
vec![linear_qkv_dim, 1, kernel],
next(linear_qkv_dim * kernel),
));
entries.push((
format!("{prefix}.linear_attn.norm.weight"),
vec![linear_output_dim],
next(linear_output_dim),
));
entries.push((
format!("{prefix}.linear_attn.out_proj.weight"),
vec![hidden, linear_output_dim],
next(hidden * linear_output_dim),
));
}
entries.push((
format!("{prefix}.mlp.gate_proj.weight"),
vec![intermediate, hidden],
next(intermediate * hidden),
));
entries.push((
format!("{prefix}.mlp.up_proj.weight"),
vec![intermediate, hidden],
next(intermediate * hidden),
));
entries.push((
format!("{prefix}.mlp.down_proj.weight"),
vec![hidden, intermediate],
next(hidden * intermediate),
));
}
entries.push((
"mtp.fc.weight".into(),
vec![hidden, 2 * hidden],
next(hidden * 2 * hidden),
));
entries.push((
"mtp.layers.0.self_attn.q_proj.weight".into(),
vec![4 * hidden, hidden],
next(4 * hidden * hidden),
));
entries.push((
"mtp.layers.0.self_attn.k_proj.weight".into(),
vec![hidden, hidden],
next(hidden * hidden),
));
entries.push((
"mtp.layers.0.self_attn.v_proj.weight".into(),
vec![hidden, hidden],
next(hidden * hidden),
));
entries.push((
"mtp.layers.0.self_attn.o_proj.weight".into(),
vec![hidden, 2 * hidden],
next(hidden * 2 * hidden),
));
entries.push((
"mtp.layers.0.mlp.gate_proj.weight".into(),
vec![intermediate, hidden],
next(intermediate * hidden),
));
entries.push((
"mtp.layers.0.mlp.up_proj.weight".into(),
vec![intermediate, hidden],
next(intermediate * hidden),
));
entries.push((
"mtp.layers.0.mlp.down_proj.weight".into(),
vec![hidden, intermediate],
next(hidden * intermediate),
));
entries.push((
"mtp.layers.0.input_layernorm.weight".into(),
vec![hidden],
next(hidden),
));
entries.push((
"mtp.layers.0.post_attention_layernorm.weight".into(),
vec![hidden],
next(hidden),
));
entries.push((
"mtp.layers.0.self_attn.q_norm.weight".into(),
vec![head_dim],
next(head_dim),
));
entries.push((
"mtp.layers.0.self_attn.k_norm.weight".into(),
vec![head_dim],
next(head_dim),
));
entries.push(("mtp.norm.weight".into(), vec![hidden], next(hidden)));
entries.push((
"mtp.pre_fc_norm_embedding.weight".into(),
vec![hidden],
next(hidden),
));
entries.push((
"mtp.pre_fc_norm_hidden.weight".into(),
vec![hidden],
next(hidden),
));
let borrowed: Vec<(&str, Vec<usize>, &[f64])> = entries
.iter()
.map(|(n, s, d)| (n.as_str(), s.clone(), d.as_slice()))
.collect();
write_test_safetensors(path, &borrowed);
}
fn write_input_dir_with_mtp(cfg: &Qwen35Config, dir: &Path, seed: u64) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join("config.json"), tiny_config_json_with_mtp(cfg)).unwrap();
write_mtp_tensors_into(&dir.join("model.safetensors"), cfg, seed);
}
#[test]
fn convert_quarot_qwen35_emits_mtp_files_for_quarot() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let cfg = tiny_cfg_with_mtp(true);
write_input_dir_with_mtp(&cfg, &input, 70);
let rotation_seed: u64 = 0xC0DE_BABE;
let _report = convert_quarot_qwen35(
&input,
&output,
&ConversionOptions {
rotation_seed,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: false,
},
)
.unwrap();
let expected_f16 = [
"mtp_fc_weight.f16",
"mtp_layers_0_self_attn_q_proj_weight.f16",
"mtp_layers_0_self_attn_k_proj_weight.f16",
"mtp_layers_0_self_attn_v_proj_weight.f16",
"mtp_layers_0_self_attn_o_proj_weight.f16",
"mtp_layers_0_mlp_gate_proj_weight.f16",
"mtp_layers_0_mlp_up_proj_weight.f16",
"mtp_layers_0_mlp_down_proj_weight.f16",
"mtp_layers_0_input_layernorm_weight.f16",
"mtp_layers_0_post_attention_layernorm_weight.f16",
"mtp_layers_0_self_attn_q_norm_weight.f16",
"mtp_layers_0_self_attn_k_norm_weight.f16",
"mtp_norm_weight.f16",
"mtp_pre_fc_norm_embedding_weight.f16",
"mtp_pre_fc_norm_hidden_weight.f16",
];
for name in &expected_f16 {
assert!(
output.join(name).exists(),
"MTP f16 file must be emitted: {name}"
);
}
for name in &expected_f16 {
let q4_variant = name.replace(".f16", ".q4");
assert!(
!output.join(&q4_variant).exists(),
"MTP Q4 file must NOT be emitted in Phase 1: {q4_variant}"
);
}
let idx_str = fs::read_to_string(output.join("quantize_index.json")).unwrap();
let idx_val: serde_json::Value = serde_json::from_str(&idx_str).unwrap();
assert_eq!(
idx_val
.get("quarot_seed")
.and_then(serde_json::Value::as_u64),
Some(rotation_seed),
"quantize_index.json must carry quarot_seed (ADR-051 contract)"
);
let out_cfg_str = fs::read_to_string(output.join("config.json")).unwrap();
let out_val: serde_json::Value = serde_json::from_str(&out_cfg_str).unwrap();
assert_eq!(
out_val
.get("text_config")
.and_then(|tc| tc.get("mtp_num_hidden_layers"))
.and_then(serde_json::Value::as_u64),
Some(1),
"output config text_config.mtp_num_hidden_layers must be 1"
);
assert_eq!(
out_val
.get("quarot_rotation_seed")
.and_then(serde_json::Value::as_u64),
Some(rotation_seed),
"output config must carry quarot_rotation_seed at top level"
);
assert_eq!(
out_val
.get("text_config")
.and_then(|tc| tc.get("quarot_rotation_seed"))
.and_then(serde_json::Value::as_u64),
Some(rotation_seed),
"output config text_config must carry quarot_rotation_seed"
);
}
#[test]
fn quarot_mtp_counter_rotation_roundtrip() {
use crate::quant::quarot::hadamard::RandomizedHadamard;
let hidden = 8usize; let seed: u64 = 0xDEAD_C0DE;
let rot = RandomizedHadamard::new(seed, hidden).unwrap();
let original: Vec<f32> = (0..hidden).map(|i| (i as f32 * 0.31 + 0.7).cos()).collect();
let mut data = original.clone();
rot.apply_inverse(&mut data).unwrap();
rot.apply(&mut data).unwrap();
for (i, (got, expected)) in data.iter().zip(original.iter()).enumerate() {
assert!(
(got - expected).abs() < 1e-4,
"roundtrip failed at index {i}: got={got}, expected={expected}"
);
}
}
#[test]
fn inject_quarot_seed_roundtrips_in_config_json() {
let json = r#"{"text_config": {"hidden_size": 8}, "some_key": 1}"#;
let seed: u64 = 0xCAFE_BABE;
let output = inject_quarot_seed(json, seed).unwrap();
let val: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(
val.get("quarot_rotation_seed")
.and_then(serde_json::Value::as_u64),
Some(seed),
"quarot_rotation_seed must be at top level"
);
assert_eq!(
val.get("text_config")
.and_then(|tc| tc.get("quarot_rotation_seed"))
.and_then(serde_json::Value::as_u64),
Some(seed),
"quarot_rotation_seed must be inside text_config"
);
}
#[test]
fn convert_quarot_qwen35_skips_mtp_when_tensors_missing() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let cfg = tiny_cfg_with_mtp(true);
fs::create_dir_all(&input).unwrap();
fs::write(input.join("config.json"), tiny_config_json_with_mtp(&cfg)).unwrap();
write_required_tensors_for(&cfg, &input.join("model.safetensors"), 71);
let report = convert_quarot_qwen35(
&input,
&output,
&ConversionOptions {
rotation_seed: 0xDEAD_BABE,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: false,
},
)
.unwrap();
let entries: Vec<_> = fs::read_dir(&output)
.unwrap()
.filter_map(std::result::Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
for name in &entries {
assert!(
!name.starts_with("mtp"),
"unexpected MTP file written when tensors were absent: {name}"
);
}
assert!(report.planned_quantized > 0);
assert!(output.join("config.json").exists());
}
#[test]
fn convert_quarot_qwen35_skips_mtp_when_config_has_zero_layers() {
let tmp = tempfile::tempdir().unwrap();
let input = tmp.path().join("input");
let output = tmp.path().join("output");
let mut cfg = tiny_cfg(true);
cfg.mtp_num_hidden_layers = 0;
assert_eq!(cfg.mtp_num_hidden_layers, 0);
fs::create_dir_all(&input).unwrap();
fs::write(input.join("config.json"), tiny_config_json(&cfg)).unwrap();
write_required_tensors_for(&cfg, &input.join("model.safetensors"), 72);
let report = convert_quarot_qwen35(
&input,
&output,
&ConversionOptions {
rotation_seed: 0xCAFE_F00D,
tolerance: 1e-5,
num_probe_tokens: 2,
dry_run: false,
},
)
.unwrap();
let entries: Vec<_> = fs::read_dir(&output)
.unwrap()
.filter_map(std::result::Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
for name in &entries {
assert!(
!name.starts_with("mtp"),
"unexpected MTP file written for zero-MTP-layer config: {name}"
);
}
assert!(report.planned_quantized > 0);
}
}