use std::collections::HashMap;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use crate::backends::gguf::types::MetaValue;
use crate::convert::arch::bake::BakeOp;
use crate::convert::arch::gemma4::MappedTensor as Gemma4Mapped;
use crate::convert::arch::minimax_m2::{ExpertRole, MappedTensor as MiniMaxMapped};
use crate::convert::arch::qwen35moe::{ExpertKind, MappedTensor as QwenMapped};
use crate::convert::arch::qwen35moe_full::Qwen35MoeFullCtx;
use crate::convert::arch::{
bake, bert, deepseek4, deepseek4_metadata, gemma4, gemma4_mmproj, llama3, minimax_m2,
nomic_bert, qwen35moe, qwen35moe_full, qwen3vl_text,
};
use crate::convert::orchestrator::PlanEntry;
use crate::convert::quant_selector::{approximate_for_apex, QuantSelector};
use crate::convert::receipt::{
clear_stale_receipt, prepare_success_receipt, promote_success_receipt,
require_converter_git_commit, PeakChunkBoundReceipt, ReceiptError, RemoteConversionSource,
};
use crate::convert::source_reader::SourceError;
use crate::convert::tokenizer::TokenizerError;
use crate::convert::{
build_tokenizer_metadata, ConvertOrchestrator, HfModelSource, HfTensor, OrchestratorError,
};
use crate::core::provenance::{KEY_PRODUCER_VERSION, KEY_SOURCE_SHA256};
use crate::quantize::ggml_quants::apex::{
detect_apex_config, load_mudler_config, ApexError, ApexPolicy, FingerprintHParams,
};
use crate::quantize::ggml_quants::standard_policy::HParams;
use crate::quantize::ggml_quants::ArchName;
use crate::quantize::ggml_quants::SourceDtype;
#[derive(Debug, Clone)]
pub struct ConvertArgs {
pub hf_dir: PathBuf,
pub selector: QuantSelector,
pub output: PathBuf,
pub dry_run: bool,
pub imatrix: Option<PathBuf>,
pub imatrix_corpus: Option<String>,
pub imatrix_out: Option<PathBuf>,
pub imatrix_n_ctx: Option<u32>,
pub mmproj: bool,
pub remote_source: Option<RemoteConversionSource>,
}
#[derive(Debug)]
pub enum ConvertError {
Source(SourceError),
Orchestrator(OrchestratorError),
Io(std::io::Error),
Integrity(crate::core::integrity::IntegrityError),
Receipt(ReceiptError),
UnsupportedArch { arch_name: String },
UnmappedTensor { hf_name: String, arch: String },
IncompleteExpertGroup {
gguf_name: String,
layer: usize,
kind_label: &'static str,
present_count: usize,
n_experts_config: usize,
},
DuplicateExpertIndex {
gguf_name: String,
layer: usize,
kind_label: &'static str,
expert_index: usize,
},
MissingHparam { key: &'static str },
ApexMissingLayerCount,
ApexCustomOutOfScope { path: PathBuf },
Apex(ApexError),
Tokenizer(TokenizerError),
Imatrix(crate::quantize::imatrix::ImatrixError),
ImatrixRequiredForITier { tier: &'static str },
ImatrixNCtxInvalid { n_ctx: u32 },
RepoAndDirMutuallyExclusive,
ImmutableRevisionRequired { supplied: Option<String> },
RevisionRequiresRepo,
InvalidRepoId { repo: String },
HfDownload {
repo: String,
exit_code: Option<i32>,
stderr: String,
},
}
impl std::fmt::Display for ConvertError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConvertError::Source(e) => write!(f, "convert/source: {e}"),
ConvertError::Orchestrator(e) => write!(f, "convert/orchestrator: {e}"),
ConvertError::Io(e) => write!(f, "convert/io: {e}"),
ConvertError::Integrity(e) => write!(f, "convert/integrity: {e}"),
ConvertError::Receipt(e) => write!(f, "convert/receipt: {e}"),
ConvertError::UnsupportedArch { arch_name } => {
write!(
f,
"convert: unsupported architecture `{arch_name}` \
(supported: llama, gemma3, bert, nomic_bert, qwen3_moe, qwen3_5_moe, \
qwen3_vl, minimax_m2)"
)
}
ConvertError::UnmappedTensor { hf_name, arch } => write!(
f,
"convert: tensor `{hf_name}` not recognized by `{arch}` mapper"
),
ConvertError::IncompleteExpertGroup {
gguf_name,
layer,
kind_label,
present_count,
n_experts_config,
} => write!(
f,
"convert: expert group `{gguf_name}` (layer={layer}, kind={kind_label}) \
only saw {present_count}/{n_experts_config} experts"
),
ConvertError::DuplicateExpertIndex {
gguf_name,
layer,
kind_label,
expert_index,
} => write!(
f,
"convert: duplicate expert index {expert_index} for \
`{gguf_name}` (layer={layer}, kind={kind_label})"
),
ConvertError::MissingHparam { key } => write!(
f,
"convert: config.json is missing required hparam `{key}`"
),
ConvertError::ApexMissingLayerCount => write!(
f,
"convert: --quant apex-<tier> requires `num_hidden_layers` in config.json"
),
ConvertError::ApexCustomOutOfScope { path } => write!(
f,
"convert: --quant apex-custom --tensor-type-file `{}` is reserved \
(out of v1 scope)",
path.display()
),
ConvertError::Apex(e) => write!(f, "convert/apex: {e}"),
ConvertError::Tokenizer(e) => write!(f, "convert/tokenizer: {e}"),
ConvertError::Imatrix(e) => write!(f, "convert/imatrix: {e}"),
ConvertError::ImatrixRequiredForITier { tier } => write!(
f,
"convert: --quant apex-{tier} requires `--imatrix <file>` \
or `--imatrix-corpus <name>` (ADR-033 §Pi Phase B SHIPPED 2026-05-19)"
),
ConvertError::ImatrixNCtxInvalid { n_ctx } => write!(
f,
"convert: --imatrix-n-ctx {n_ctx} is invalid; \
must be > 0 (default 512 matches stock `llama-imatrix -c 512`)"
),
ConvertError::RepoAndDirMutuallyExclusive => write!(
f,
"convert: `--repo <hf_repo>` and positional `<hf_dir>` are mutually exclusive — \
pass exactly one"
),
ConvertError::ImmutableRevisionRequired { supplied } => write!(
f,
"convert: `--repo` requires `--revision <40-hex-commit>`; got {}",
supplied.as_deref().unwrap_or("<missing>")
),
ConvertError::RevisionRequiresRepo => write!(
f,
"convert: `--revision` is valid only with `--repo`; local directories are used as supplied"
),
ConvertError::InvalidRepoId { repo } => write!(
f,
"convert: invalid HuggingFace repo id `{repo}`; expected slash-separated ASCII name components"
),
ConvertError::HfDownload {
repo,
exit_code,
stderr,
} => write!(
f,
"convert: HuggingFace download for {repo} exited with status {} — stderr:\n{}",
exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "<signal>".to_string()),
stderr.trim_end()
),
}
}
}
impl std::error::Error for ConvertError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConvertError::Source(e) => Some(e),
ConvertError::Orchestrator(e) => Some(e),
ConvertError::Io(e) => Some(e),
ConvertError::Integrity(e) => Some(e),
ConvertError::Receipt(e) => Some(e),
ConvertError::Apex(e) => Some(e),
ConvertError::Tokenizer(e) => Some(e),
ConvertError::Imatrix(e) => Some(e),
_ => None,
}
}
}
impl From<crate::quantize::imatrix::ImatrixError> for ConvertError {
fn from(e: crate::quantize::imatrix::ImatrixError) -> Self {
ConvertError::Imatrix(e)
}
}
impl From<SourceError> for ConvertError {
fn from(e: SourceError) -> Self {
ConvertError::Source(e)
}
}
impl From<OrchestratorError> for ConvertError {
fn from(e: OrchestratorError) -> Self {
ConvertError::Orchestrator(e)
}
}
impl From<std::io::Error> for ConvertError {
fn from(e: std::io::Error) -> Self {
ConvertError::Io(e)
}
}
impl From<crate::core::integrity::IntegrityError> for ConvertError {
fn from(e: crate::core::integrity::IntegrityError) -> Self {
ConvertError::Integrity(e)
}
}
impl From<ReceiptError> for ConvertError {
fn from(e: ReceiptError) -> Self {
ConvertError::Receipt(e)
}
}
impl From<ApexError> for ConvertError {
fn from(e: ApexError) -> Self {
ConvertError::Apex(e)
}
}
impl From<TokenizerError> for ConvertError {
fn from(e: TokenizerError) -> Self {
ConvertError::Tokenizer(e)
}
}
pub fn run_convert(args: ConvertArgs) -> Result<(), ConvertError> {
let src = HfModelSource::open(&args.hf_dir)?;
let excluded_dspark_count = src.excluded_mtp_tensor_count();
if excluded_dspark_count > 0 {
tracing::warn!(
target: "convert",
excluded = excluded_dspark_count,
"DeepSeek-V4 MTP/DSpark tensors excluded from base GGUF; separate draft artifact remains required"
);
}
let detected_arch = detect_arch(&src.config)?;
let arch = if args.mmproj {
if src.config.get("vision_config").is_none() {
return Err(ConvertError::UnsupportedArch {
arch_name: format!(
"{detected_arch:?} (--mmproj requires a `vision_config` sub-object in config.json; not present)"
),
});
}
match detected_arch {
ArchName::Gemma4 => {
let has_gemma4_vision = src
.tensor_metas()
.any(|m| m.name.starts_with("model.vision_tower.encoder.layers."));
if has_gemma4_vision {
ArchName::Gemma4VisionMmproj
} else {
ArchName::Gemma4Mmproj
}
}
other => {
return Err(ConvertError::UnsupportedArch {
arch_name: format!(
"--mmproj not supported for {other:?} yet (only Gemma 3 vision shipped)"
),
});
}
}
} else {
detected_arch
};
let hparams = build_hparams(&src.config)?;
let (mut orch, ftype_for_metadata) = match &args.selector {
QuantSelector::Standard(ftype) => {
let orch = ConvertOrchestrator::new(*ftype, arch, hparams);
(orch, *ftype)
}
QuantSelector::Deepseek4AgenticQ2 => {
if arch != ArchName::Deepseek4 {
return Err(ConvertError::UnsupportedArch {
arch_name: format!(
"--quant deepseek4-agentic-q2 requires DeepSeek-V4, detected {arch:?}"
),
});
}
let orch = ConvertOrchestrator::new_deepseek4_agentic_q2(arch, hparams);
(orch, crate::quantize::ggml_quants::LlamaFtype::MostlyQ2_K)
}
QuantSelector::Apex(tier) => {
let n_layers =
config_n_layers(&src.config).ok_or(ConvertError::ApexMissingLayerCount)?;
let n_expert = hparams.n_expert;
let imatrix_data = resolve_imatrix_input(
tier,
args.imatrix.as_deref(),
args.imatrix_corpus.as_deref(),
&args.hf_dir,
arch,
args.imatrix_n_ctx.unwrap_or(512),
)?;
let mut apex_policy = if imatrix_data.is_some() {
ApexPolicy::new_with_imatrix(*tier, arch, n_layers, n_expert)?
} else {
ApexPolicy::new(*tier, arch, n_layers, n_expert)?
};
if let (Some(out_path), Some(data)) = (&args.imatrix_out, imatrix_data.as_ref()) {
let label = data
.loaded
.datasets
.first()
.cloned()
.unwrap_or_else(|| "user-file".to_string());
data.write_gguf(out_path, &[label])?;
eprintln!(
"[hf2q imatrix] wrote {} ({} tensor pairs)",
out_path.display(),
data.tensor_pair_count()
);
}
let imatrix_for_orch = imatrix_data;
let effective = effective_config(&src.config);
if let Some(fp_hparams) = FingerprintHParams::from_config(effective) {
if let Some(entry) = detect_apex_config(&fp_hparams, *tier) {
let mudler = load_mudler_config(entry)?;
apex_policy = apex_policy.with_mudler_override(mudler);
eprintln!(
"[hf2q apex] auto-detected APEX config: {} \
(fingerprint={}, tier={}, arch={})",
entry.mudler_config_path,
&entry.fingerprint[..16],
entry.tier,
entry.arch,
);
}
}
let ftype = approximate_for_apex(*tier);
let orch = ConvertOrchestrator::new_with_apex(ftype, arch, hparams, apex_policy)
.with_imatrix(imatrix_for_orch);
(orch, ftype)
}
QuantSelector::ApexCustom(path) => {
return Err(ConvertError::ApexCustomOutOfScope { path: path.clone() });
}
};
let model_card = crate::convert::model_card::parse_readme_frontmatter(&args.hf_dir);
let sampling = crate::convert::model_card::parse_generation_config(&args.hf_dir);
let dir_basename: Option<String> = args
.hf_dir
.file_name()
.and_then(|s| s.to_str())
.map(String::from);
let size_label = compute_size_label_for_arch(arch, &src, &src.config);
let ftype_u32 = ftype_for_metadata as u32;
let bert_pooling_override = if matches!(arch, ArchName::Bert) {
resolve_bert_pooling_type(&args.hf_dir)
} else {
None
};
let qwen3vl_n_deepstack = if matches!(arch, ArchName::Qwen3VlText) {
let vc = src
.config
.get("thinker_config")
.and_then(|tc| tc.get("vision_config"))
.or_else(|| src.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)
} else {
None
};
let arch_metadata = build_metadata_for_arch(
arch,
&src.config,
ftype_u32,
model_card.as_ref(),
size_label.as_deref(),
sampling.as_ref(),
dir_basename.as_deref(),
bert_pooling_override,
qwen3vl_n_deepstack,
);
const POSTLUDE_KEYS: &[&str] = &["general.quantization_version", "general.file_type"];
let postlude_keys: &[&str] =
if matches!(arch, ArchName::Gemma4Mmproj | ArchName::Gemma4VisionMmproj) {
&["general.quantization_version"]
} else {
POSTLUDE_KEYS
};
let mut prelude: Vec<(String, MetaValue)> = Vec::with_capacity(arch_metadata.len());
let mut postlude: Vec<(String, MetaValue)> = Vec::with_capacity(postlude_keys.len());
for (k, v) in arch_metadata {
if postlude_keys.contains(&k.as_str()) {
postlude.push((k, v));
} else {
prelude.push((k, v));
}
}
for (k, v) in prelude {
orch.add_metadata(k, v);
}
if !matches!(arch, ArchName::Gemma4Mmproj | ArchName::Gemma4VisionMmproj) {
for (k, v) in build_tokenizer_metadata(&args.hf_dir, arch)? {
orch.add_metadata(k, v);
}
}
for (k, v) in postlude {
orch.add_metadata(k, v);
}
if matches!(&args.selector, QuantSelector::Deepseek4AgenticQ2) {
use crate::quantize::ggml_quants::{
DEEPSEEK4_AGENTIC_Q2_METADATA_KEY, DEEPSEEK4_AGENTIC_Q2_NAME,
};
orch.add_metadata(
DEEPSEEK4_AGENTIC_Q2_METADATA_KEY.to_string(),
MetaValue::String(DEEPSEEK4_AGENTIC_Q2_NAME.to_string()),
);
}
if let Some(remote) = args.remote_source.as_ref() {
orch.add_metadata(
KEY_PRODUCER_VERSION.to_string(),
MetaValue::String(format!("hf2q {}", env!("CARGO_PKG_VERSION"))),
);
orch.add_metadata(
KEY_SOURCE_SHA256.to_string(),
MetaValue::String(remote.source_sha256.clone()),
);
}
let synthesized: Vec<HfTensor> = synthesized_tensors_for_arch(arch, &src.config);
let plan = build_convert_plan(arch, &src, &synthesized)?;
let plan_entries: Vec<PlanEntry> = plan.steps.iter().map(|s| s.plan_entry()).collect();
orch.plan_tensors(plan_entries)?;
if args.dry_run {
let summary = orch.planned_size_summary()?;
eprintln!(
"hf2q convert dry-run: selector={} tensors={} payload={} bytes ({:.3} GiB) aligned={} bytes ({:.3} GiB)",
args.selector.receipt_name(),
summary.tensor_count,
summary.payload_bytes,
summary.payload_bytes as f64 / 1024_f64.powi(3),
summary.aligned_payload_bytes,
summary.aligned_payload_bytes as f64 / 1024_f64.powi(3),
);
for entry in summary.by_type {
eprintln!(
" {:<8} tensors={:<5} payload={} bytes ({:.3} GiB)",
entry.ggml_type.name(),
entry.tensor_count,
entry.payload_bytes,
entry.payload_bytes as f64 / 1024_f64.powi(3),
);
}
return Ok(());
}
let converter_git_commit = args
.remote_source
.as_ref()
.map(|_| require_converter_git_commit())
.transpose()?;
let mut temporary_output = create_temporary_output(&args.output)?;
let bw = BufWriter::new(temporary_output.as_file_mut());
let mut sw = orch.begin_write(bw)?;
let mut peak_chunk_bound = PeakChunkBoundReceipt::default();
for (idx, step) in plan.steps.iter().enumerate() {
match step {
PlanStep::Fused {
gguf_name,
member_hf_names,
per_expert_py_shape,
..
} => {
let per_expert_elems = per_expert_py_shape.iter().try_fold(
1usize,
|n, &d| n.checked_mul(d),
).ok_or_else(|| {
ConvertError::Source(SourceError::Safetensors(format!(
"fused expert tensor `{gguf_name}` shape product overflow: {per_expert_py_shape:?}"
)))
})?;
sw.begin_tensor_chunks(idx)?;
for name in member_hf_names {
let ht = src.materialize_tensor(name)?;
if ht.shape != *per_expert_py_shape || ht.data.len() != per_expert_elems {
return Err(ConvertError::Source(SourceError::Safetensors(format!(
"fused expert slice `{name}`: shape {:?} / data len {} != expected {:?} / {}",
ht.shape,
ht.data.len(),
per_expert_py_shape,
per_expert_elems
))));
}
sw.stream_tensor_chunk(idx, &ht.data)?;
}
let stats = sw.finish_tensor_chunks(idx)?;
peak_chunk_bound.observe(stats);
debug_assert_eq!(stats.chunk_count, member_hf_names.len());
debug_assert_eq!(stats.max_chunk_elements, per_expert_elems);
tracing::debug!(
target: "convert",
tensor = gguf_name,
experts = stats.chunk_count,
max_live_f32_elements = stats.max_chunk_elements,
"streamed fused expert tensor with bounded input chunks"
);
}
_ => {
let data: Vec<f32> = step.materialize(&src, &synthesized)?;
let stats = sw.stream_tensor(idx, &data)?;
peak_chunk_bound.observe(stats);
}
}
}
sw.finalize()?;
temporary_output.as_file().sync_all()?;
let prepared_receipt = args
.remote_source
.as_ref()
.zip(converter_git_commit.as_deref())
.map(|(remote, commit)| {
prepare_success_receipt(
temporary_output.path(),
&args.output,
remote,
commit,
&args.selector.receipt_name(),
excluded_dspark_count,
peak_chunk_bound,
)
})
.transpose()?;
promote_temporary_output(temporary_output, &args.output)?;
if let Some(receipt) = prepared_receipt {
if let Err(error) = promote_success_receipt(receipt) {
clear_stale_receipt(&args.output)?;
return Err(error.into());
}
} else {
clear_stale_receipt(&args.output)?;
}
Ok(())
}
fn create_temporary_output(output: &Path) -> Result<tempfile::NamedTempFile, ConvertError> {
let parent = output
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
Ok(tempfile::NamedTempFile::new_in(parent)?)
}
fn promote_temporary_output(
temporary: tempfile::NamedTempFile,
output: &Path,
) -> Result<(), ConvertError> {
temporary.as_file().sync_all()?;
temporary
.persist(output)
.map_err(|error| ConvertError::Io(error.error))?;
Ok(())
}
fn resolve_imatrix_input(
tier: &crate::quantize::ggml_quants::apex::ApexTier,
imatrix_path: Option<&std::path::Path>,
imatrix_corpus: Option<&str>,
hf_dir: &std::path::Path,
arch: crate::quantize::ggml_quants::ArchName,
n_ctx: u32,
) -> Result<Option<crate::quantize::imatrix::ImatrixData>, ConvertError> {
use crate::quantize::imatrix::{
compute_imatrix, ComputeImatrixParams, CorpusBytes, CorpusSource, ImatrixData,
};
if let Some(path) = imatrix_path {
let data = ImatrixData::load_from_path(path)?;
eprintln!(
"[hf2q imatrix] loaded {} ({} tensor pairs, chunks={}, chunk_size={})",
path.display(),
data.tensor_pair_count(),
data.loaded.chunk_count,
data.loaded.chunk_size,
);
return Ok(Some(data));
}
if let Some(corpus_name) = imatrix_corpus {
if n_ctx == 0 {
return Err(ConvertError::ImatrixNCtxInvalid { n_ctx });
}
let source = CorpusSource::from_cli(corpus_name)?;
let corpus = CorpusBytes::load(&source)?;
let label = source.dataset_label();
eprintln!(
"[hf2q imatrix] computing in-tree on corpus `{label}` \
({} bytes, ~{} words, n_ctx={n_ctx})",
corpus.byte_count(),
corpus.approx_word_count(),
);
let params = ComputeImatrixParams {
hf_dir: hf_dir.to_path_buf(),
corpus,
n_ctx,
arch,
};
let data = compute_imatrix(¶ms)?;
eprintln!(
"[hf2q imatrix] computed {} tensor pairs, chunks={}, chunk_size={}",
data.tensor_pair_count(),
data.loaded.chunk_count,
data.loaded.chunk_size,
);
return Ok(Some(data));
}
if tier.requires_imatrix() {
return Err(ConvertError::ImatrixRequiredForITier {
tier: tier.cli_name(),
});
}
Ok(None)
}
fn config_n_layers(config: &serde_json::Value) -> Option<u32> {
let cfg = effective_config(config);
let base = cfg
.get("num_hidden_layers")
.and_then(|v| v.as_u64())
.filter(|&x| x > 0)?;
let mtp = cfg
.get("mtp_num_hidden_layers")
.and_then(|v| v.as_u64())
.unwrap_or(0);
Some((base + mtp) as u32)
}
fn detect_arch(config: &serde_json::Value) -> Result<ArchName, ConvertError> {
let model_type = config.get("model_type").and_then(|v| v.as_str());
let architectures: Vec<&str> = config
.get("architectures")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|x| x.as_str()).collect())
.unwrap_or_default();
if let Some(mt) = model_type {
match mt {
"llama" => return Ok(ArchName::Llama3),
"gemma3" | "gemma" | "gemma4" | "gemma4_text" => return Ok(ArchName::Gemma4),
"bert" => return Ok(ArchName::Bert),
"nomic_bert" => return Ok(ArchName::NomicBert),
"qwen3_moe" => return Ok(ArchName::Qwen35Moe),
"qwen3_5_moe" | "qwen3_5_moe_text" => return Ok(ArchName::Qwen35MoeFull),
"qwen3_vl" | "qwen3_vl_moe" | "qwen3_vl_text" => return Ok(ArchName::Qwen3VlText),
"minimax_m2" => return Ok(ArchName::MiniMaxM2),
"deepseek_v4" => return Ok(ArchName::Deepseek4),
_ => {}
}
}
for cls in &architectures {
match *cls {
"LlamaForCausalLM" => return Ok(ArchName::Llama3),
s if s.starts_with("Gemma3")
|| s.starts_with("Gemma2")
|| s.starts_with("Gemma4")
|| s == "GemmaForCausalLM" =>
{
return Ok(ArchName::Gemma4);
}
"BertForMaskedLM" | "BertModel" => return Ok(ArchName::Bert),
"NomicBertModel" => return Ok(ArchName::NomicBert),
"Qwen3MoeForCausalLM" => return Ok(ArchName::Qwen35Moe),
"Qwen3_5MoeForCausalLM" | "Qwen3_5MoeForConditionalGeneration" => {
return Ok(ArchName::Qwen35MoeFull);
}
"Qwen3VLForConditionalGeneration"
| "Qwen3VLMoeForConditionalGeneration"
| "Qwen3VLTextForCausalLM" => {
return Ok(ArchName::Qwen3VlText);
}
"MiniMaxM2ForCausalLM" => return Ok(ArchName::MiniMaxM2),
"DeepseekV4ForCausalLM" => return Ok(ArchName::Deepseek4),
_ => {}
}
}
let observed = model_type
.map(|s| s.to_string())
.or_else(|| architectures.first().map(|s| s.to_string()))
.unwrap_or_else(|| "<missing model_type and architectures>".into());
Err(ConvertError::UnsupportedArch {
arch_name: observed,
})
}
fn resolve_bert_pooling_type(model_dir: &std::path::Path) -> Option<u32> {
let modules_path = model_dir.join("modules.json");
let modules_raw = std::fs::read_to_string(&modules_path).ok()?;
let modules: serde_json::Value = serde_json::from_str(&modules_raw).ok()?;
let pooling_subdir = modules.as_array()?.iter().find_map(|m| {
let ty = m.get("type")?.as_str()?;
if ty.ends_with("Pooling") {
m.get("path")?.as_str().map(String::from)
} else {
None
}
})?;
let pooling_config_path = model_dir.join(pooling_subdir).join("config.json");
let pooling_raw = std::fs::read_to_string(&pooling_config_path).ok()?;
let pooling: serde_json::Value = serde_json::from_str(&pooling_raw).ok()?;
if pooling
.get("pooling_mode_mean_tokens")
.and_then(|v| v.as_bool())
== Some(true)
{
Some(1) } else if pooling
.get("pooling_mode_cls_token")
.and_then(|v| v.as_bool())
== Some(true)
{
Some(2) } else if pooling
.get("pooling_mode_lasttoken")
.and_then(|v| v.as_bool())
== Some(true)
{
Some(3) } else if let Some(mode) = pooling.get("pooling_mode").and_then(|v| v.as_str()) {
match mode {
"mean" => Some(1),
"cls" => Some(2),
"lasttoken" => Some(3),
_ => None,
}
} else {
None
}
}
pub fn effective_config(config: &serde_json::Value) -> &serde_json::Value {
if let Some(text) = config.get("text_config") {
return text;
}
config
}
fn build_hparams(config: &serde_json::Value) -> Result<HParams, ConvertError> {
let config = effective_config(config);
let n_head = config
.get("num_attention_heads")
.or_else(|| config.get("n_head"))
.or_else(|| config.get("n_heads"))
.and_then(|v| v.as_u64())
.ok_or(ConvertError::MissingHparam {
key: "num_attention_heads",
})? as u32;
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 n_expert = config
.get("num_experts")
.or_else(|| config.get("num_local_experts"))
.or_else(|| config.get("n_routed_experts"))
.and_then(|v| v.as_u64())
.map(|x| x as u32)
.unwrap_or(0);
let n_hidden = config
.get("num_hidden_layers")
.or_else(|| config.get("n_layer"))
.and_then(|v| v.as_u64())
.ok_or(ConvertError::MissingHparam {
key: "num_hidden_layers",
})?;
let n_mtp = config
.get("mtp_num_hidden_layers")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let n_layer = (n_hidden + n_mtp) as u32;
Ok(HParams {
n_expert,
n_head,
n_head_kv,
n_layer,
n_mtp_layers: n_mtp as u32,
})
}
fn synthesized_tensors_for_arch(arch: ArchName, config: &serde_json::Value) -> Vec<HfTensor> {
let config = effective_config(config);
match arch {
ArchName::Gemma4 => gemma4::build_synthesized_tensors(config),
_ => Vec::new(),
}
}
fn build_metadata_for_arch(
arch: ArchName,
config: &serde_json::Value,
ftype: u32,
model_card: Option<&crate::convert::model_card::ModelCard>,
size_label: Option<&str>,
sampling: Option<&crate::convert::model_card::SamplingConfig>,
model_dir_basename: Option<&str>,
bert_pooling_override: Option<u32>,
qwen3vl_n_deepstack: Option<u32>,
) -> Vec<(String, MetaValue)> {
if matches!(arch, ArchName::Gemma4Mmproj) {
let vision = config
.get("vision_config")
.expect("--mmproj routing requires vision_config (validated in run_convert)");
return gemma4_mmproj::build_metadata(vision, ftype);
}
if matches!(arch, ArchName::Gemma4VisionMmproj) {
let vision = config
.get("vision_config")
.expect("--mmproj routing requires vision_config (validated in run_convert)");
let text_hidden = config
.get("text_config")
.and_then(|tc| tc.get("hidden_size"))
.or_else(|| config.get("hidden_size"))
.and_then(|v| v.as_u64())
.expect("--mmproj Gemma4Vision requires text_config.hidden_size")
as u32;
return crate::convert::arch::gemma4_vision_mmproj::build_metadata(
vision,
text_hidden,
ftype,
model_card,
sampling,
model_dir_basename,
);
}
let config = effective_config(config);
match arch {
ArchName::Llama3 => {
llama3::build_metadata(config, ftype, model_card, sampling, model_dir_basename)
}
ArchName::Gemma4 => {
gemma4::build_metadata(config, ftype, model_card, sampling, model_dir_basename)
}
ArchName::Gemma4Mmproj => unreachable!("handled above"),
ArchName::Gemma4VisionMmproj => unreachable!("handled above"),
ArchName::Bert => bert::build_metadata(
config,
ftype,
model_card,
sampling,
model_dir_basename,
bert_pooling_override,
),
ArchName::NomicBert => nomic_bert::build_metadata(config, ftype, model_card, size_label),
ArchName::Qwen35Moe => qwen35moe::build_metadata(config, ftype),
ArchName::Qwen35MoeFull => match build_qwen35moe_full_ctx(config) {
Some(ctx) => qwen35moe_full::build_metadata(
&ctx,
config,
ftype,
model_card,
sampling,
model_dir_basename,
size_label,
),
None => qwen35moe::build_metadata(config, ftype),
},
ArchName::Qwen3VlText => qwen3vl_text::build_metadata(
config,
ftype,
model_card,
sampling,
model_dir_basename,
qwen3vl_n_deepstack,
),
ArchName::MiniMaxM2 => minimax_m2::build_metadata(
config,
ftype,
model_card,
sampling,
model_dir_basename,
size_label,
),
ArchName::Deepseek4 => deepseek4_metadata::build_metadata(
config,
ftype,
model_card,
sampling,
model_dir_basename,
),
ArchName::Falcon => unreachable!(
"ArchName::Falcon is a target_for placeholder, not a convert-v2 supported arch"
),
}
}
enum MapOutcome {
Direct(String),
DirectWithBake {
gguf_name: String,
bake: BakeOp,
},
SplitInto(Vec<SplitOutput>),
Expert {
gguf_name: String,
layer: usize,
expert_index: usize,
kind: ExpertKind,
},
Drop,
Unmapped,
}
#[derive(Debug, Clone)]
pub struct SplitOutput {
pub gguf_name: String,
pub gguf_shape: Vec<usize>,
pub bake: BakeOp,
}
#[derive(Debug, Clone)]
struct Llama3Ctx {
n_head: usize,
n_kv_head: usize,
head_dim: usize,
inner: usize,
}
fn build_llama3_ctx(config: &serde_json::Value) -> Option<Llama3Ctx> {
let text = effective_config(config);
let hidden_size = text.get("hidden_size")?.as_u64()? as usize;
let n_head = text
.get("num_attention_heads")
.or_else(|| text.get("n_heads"))?
.as_u64()? as usize;
let n_kv_head = text
.get("num_key_value_heads")
.or_else(|| text.get("n_kv_heads"))
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(n_head);
if n_head == 0 || hidden_size % n_head != 0 {
return None;
}
Some(Llama3Ctx {
n_head,
n_kv_head,
head_dim: hidden_size / n_head,
inner: hidden_size,
})
}
fn llama3_attach_bake(
gguf_name: &str,
hf_name: &str,
ctx: &Llama3Ctx,
) -> Option<crate::convert::arch::bake::BakeOp> {
use crate::convert::arch::bake::BakeOp;
let is_weight = hf_name.ends_with(".weight");
let is_bias = hf_name.ends_with(".bias");
let inner = if is_weight {
ctx.inner
} else if is_bias {
1
} else {
return None;
};
if gguf_name.ends_with("attn_q.weight") || gguf_name.ends_with("attn_q.bias") {
Some(BakeOp::PermuteRopeHalves {
n_head: ctx.n_head,
head_dim: ctx.head_dim,
inner,
})
} else if gguf_name.ends_with("attn_k.weight") || gguf_name.ends_with("attn_k.bias") {
Some(BakeOp::PermuteRopeHalves {
n_head: ctx.n_kv_head,
head_dim: ctx.head_dim,
inner,
})
} else {
None
}
}
fn map_tensor(
arch: ArchName,
hf_name: &str,
hf_shape: &[usize],
qwen35moe_full_ctx: Option<&Qwen35MoeFullCtx>,
llama3_ctx: Option<&Llama3Ctx>,
nomic_bert_ctx: &nomic_bert::NomicBertCtx,
) -> MapOutcome {
match arch {
ArchName::Llama3 => match llama3::map_tensor_name(hf_name) {
Some(gguf_name) => {
match llama3_ctx.and_then(|c| llama3_attach_bake(&gguf_name, hf_name, c)) {
Some(bake) => MapOutcome::DirectWithBake { gguf_name, bake },
None => MapOutcome::Direct(gguf_name),
}
}
None => MapOutcome::Unmapped,
},
ArchName::Gemma4 => lift_gemma4_mapped(gemma4::map_tensor_name(hf_name)),
ArchName::Gemma4Mmproj => match gemma4_mmproj::map_tensor_name(hf_name) {
Some(s) => MapOutcome::Direct(s),
None => MapOutcome::Drop,
},
ArchName::Gemma4VisionMmproj => {
match crate::convert::arch::gemma4_vision_mmproj::map_tensor_name(hf_name) {
Some(gguf_name) => {
if gguf_name == "v.patch_embd.weight" {
if hf_shape.len() == 2 {
let out_features = hf_shape[0];
let inner = hf_shape[1];
let channels = 3;
if inner % channels == 0 {
let patch_sq = inner / channels;
let patch_size = (patch_sq as f64).sqrt() as usize;
if patch_size * patch_size == patch_sq {
return MapOutcome::DirectWithBake {
gguf_name,
bake: BakeOp::PatchEmbedderReshape {
out_features,
patch_h: patch_size,
patch_w: patch_size,
channels,
},
};
}
}
}
}
MapOutcome::Direct(gguf_name)
}
None => MapOutcome::Drop,
}
}
ArchName::Bert => match bert::map_tensor_name(hf_name) {
Some(s) => MapOutcome::Direct(s),
None => MapOutcome::Unmapped,
},
ArchName::NomicBert => match nomic_bert::map_tensor_name(hf_name, hf_shape, nomic_bert_ctx)
{
Some(nomic_bert::MappedTensor::Direct(s)) => MapOutcome::Direct(s),
Some(nomic_bert::MappedTensor::DirectWithBake { gguf_name, bake }) => {
MapOutcome::DirectWithBake { gguf_name, bake }
}
Some(nomic_bert::MappedTensor::Drop) => MapOutcome::Drop,
None => MapOutcome::Unmapped,
},
ArchName::Qwen3VlText => match qwen3vl_text::map_tensor_name(hf_name) {
Some(s) => MapOutcome::Direct(s),
None => {
if hf_name.contains("visual.")
|| hf_name.contains("vision.")
|| hf_name.contains("audio.")
|| hf_name.contains("audio_tower.")
|| hf_name.starts_with("mtp.")
|| hf_name.contains("patch_embed")
|| hf_name.contains("patch_embedding")
|| hf_name.contains("patch_merger.")
|| hf_name.contains("merger.")
|| hf_name.contains("vit.")
{
MapOutcome::Drop
} else {
MapOutcome::Unmapped
}
}
},
ArchName::Qwen35Moe => lift_qwen_mapped(qwen35moe::map_tensor_name(hf_name)),
ArchName::Qwen35MoeFull => match qwen35moe_full_ctx {
Some(ctx) => {
lift_qwen35moe_full_mapped(qwen35moe_full::map_tensor_name(hf_name, hf_shape, ctx))
}
None => MapOutcome::Unmapped,
},
ArchName::MiniMaxM2 => lift_minimax_mapped(minimax_m2::map_tensor_name(hf_name)),
ArchName::Deepseek4 => lift_qwen_mapped(deepseek4::map_tensor_name(hf_name)),
ArchName::Falcon => MapOutcome::Unmapped,
}
}
fn lift_qwen35moe_full_mapped(m: Option<qwen35moe_full::MappedTensor>) -> MapOutcome {
match m {
Some(qwen35moe_full::MappedTensor::Direct(s)) => MapOutcome::Direct(s),
Some(qwen35moe_full::MappedTensor::DirectWithBake { gguf_name, bake }) => {
MapOutcome::DirectWithBake { gguf_name, bake }
}
Some(qwen35moe_full::MappedTensor::SplitInto(outputs)) => MapOutcome::SplitInto(outputs),
Some(qwen35moe_full::MappedTensor::ExpertGroup {
gguf_name,
layer,
expert_index,
kind,
}) => MapOutcome::Expert {
gguf_name,
layer,
expert_index,
kind,
},
Some(qwen35moe_full::MappedTensor::Drop) => MapOutcome::Drop,
None => MapOutcome::Unmapped,
}
}
fn compute_size_label_for_arch(
arch: ArchName,
src: &HfModelSource,
config: &serde_json::Value,
) -> Option<String> {
use crate::convert::model_card::compute_size_label;
match arch {
ArchName::MiniMaxM2 => {
let n_experts = config
.get("num_local_experts")
.or_else(|| config.get("num_experts"))
.and_then(|v| v.as_u64())
.map(|n| n as u32)?;
let iter = src.tensor_metas().map(|m| {
let is_expert = m.name.contains(".block_sparse_moe.experts.");
(m.numel() as u64, is_expert)
});
Some(compute_size_label(iter, n_experts))
}
ArchName::NomicBert => {
let nomic_ctx = build_nomic_bert_ctx(config);
let n_experts = nomic_ctx.num_experts? as u32;
let iter = src.tensor_metas().filter_map(|m| {
let stripped = m.name.strip_prefix("bert.").unwrap_or(&m.name);
if stripped.contains("mlp.experts.bias") {
return None;
}
let is_expert = stripped.contains(".mlp.experts.mlp.w");
Some((m.numel() as u64, is_expert))
});
Some(compute_size_label(iter, n_experts))
}
_ => None,
}
}
fn build_nomic_bert_ctx(config: &serde_json::Value) -> nomic_bert::NomicBertCtx {
let num_experts = config
.get("num_experts")
.or_else(|| config.get("num_local_experts"))
.and_then(|v| v.as_u64())
.map(|n| n as usize);
nomic_bert::NomicBertCtx { num_experts }
}
fn build_qwen35moe_full_ctx(config: &serde_json::Value) -> Option<Qwen35MoeFullCtx> {
let text = effective_config(config);
let num_hidden_layers = text.get("num_hidden_layers")?.as_u64()? as usize;
let num_experts = text
.get("num_experts")
.or_else(|| text.get("num_local_experts"))?
.as_u64()? as usize;
let moe_intermediate_size = text.get("moe_intermediate_size")?.as_u64()? as usize;
let hidden_size = text.get("hidden_size")?.as_u64()? as usize;
let linear_num_key_heads = text.get("linear_num_key_heads")?.as_u64()? as usize;
let linear_num_value_heads = text.get("linear_num_value_heads")?.as_u64()? as usize;
let linear_key_head_dim = text.get("linear_key_head_dim")?.as_u64()? as usize;
let linear_value_head_dim = text.get("linear_value_head_dim")?.as_u64()? as usize;
let multimodal_wrapping = config
.get("architectures")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str())
.any(|s| s.ends_with("ForConditionalGeneration"))
})
.unwrap_or(false);
let drop_mtp = matches!(
std::env::var("HF2Q_QWEN35_DROP_MTP").as_deref(),
Ok("1") | Ok("true") | Ok("TRUE") | Ok("True")
);
if !drop_mtp {
let text_for_mtp_check = config.get("text_config").unwrap_or(config);
let n_mtp_raw = text_for_mtp_check
.get("mtp_num_hidden_layers")
.and_then(|v: &serde_json::Value| v.as_u64())
.unwrap_or(0u64);
if n_mtp_raw > 0 {
eprintln!(
"[hf2q convert] note: qwen35moe model has {n_mtp_raw} MTP block(s); \
the resulting GGUF will NOT load in stock llama.cpp (current stable \
lacks a qwen35 MTP loader). Set HF2Q_QWEN35_DROP_MTP=1 to strip \
the MTP block(s) for llama.cpp interop (loses MTP inference, but \
hf2q's own inference path handles MTP separately)."
);
}
}
Some(Qwen35MoeFullCtx {
num_hidden_layers,
num_experts,
moe_intermediate_size,
hidden_size,
linear_num_key_heads,
linear_num_value_heads,
linear_key_head_dim,
linear_value_head_dim,
multimodal_wrapping,
drop_mtp,
})
}
fn lift_gemma4_mapped(m: Option<Gemma4Mapped>) -> MapOutcome {
match m {
Some(Gemma4Mapped::Direct(s)) => MapOutcome::Direct(s),
Some(Gemma4Mapped::Drop) => MapOutcome::Drop,
None => MapOutcome::Unmapped,
}
}
fn lift_qwen_mapped(m: Option<QwenMapped>) -> MapOutcome {
match m {
Some(QwenMapped::Direct(s)) => MapOutcome::Direct(s),
Some(QwenMapped::ExpertGroup {
gguf_name,
layer,
expert_index,
kind,
}) => MapOutcome::Expert {
gguf_name,
layer,
expert_index,
kind,
},
Some(QwenMapped::Drop) => MapOutcome::Drop,
None => MapOutcome::Unmapped,
}
}
fn lift_minimax_mapped(m: Option<MiniMaxMapped>) -> MapOutcome {
match m {
Some(MiniMaxMapped::Dense { gguf, .. }) => MapOutcome::Direct(gguf),
Some(MiniMaxMapped::Router { gguf, .. }) => MapOutcome::Direct(gguf),
Some(MiniMaxMapped::ExpertWeight {
layer,
expert,
role,
gguf_stacked,
..
}) => MapOutcome::Expert {
gguf_name: gguf_stacked,
layer: layer as usize,
expert_index: expert as usize,
kind: expert_role_to_kind(role),
},
None => MapOutcome::Unmapped,
}
}
fn expert_role_to_kind(r: ExpertRole) -> ExpertKind {
match r {
ExpertRole::Gate => ExpertKind::Gate,
ExpertRole::Up => ExpertKind::Up,
ExpertRole::Down => ExpertKind::Down,
}
}
#[derive(Debug, Clone)]
enum PlanStep {
Direct {
hf_name: String,
gguf_name: String,
gguf_shape: Vec<usize>,
source_dtype: SourceDtype,
layer_index: Option<usize>,
bake: Option<BakeOp>,
},
Fused {
gguf_name: String,
gguf_shape_fused: Vec<usize>,
member_hf_names: Vec<String>,
per_expert_py_shape: Vec<usize>,
source_dtype: SourceDtype,
layer_index: Option<usize>,
},
Synthesized {
gguf_name: String,
gguf_shape: Vec<usize>,
source_dtype: SourceDtype,
layer_index: Option<usize>,
synth_idx: usize,
},
}
impl PlanStep {
fn plan_entry(&self) -> PlanEntry {
match self {
PlanStep::Direct {
gguf_name,
gguf_shape,
source_dtype,
layer_index,
..
} => PlanEntry {
name: gguf_name.clone(),
shape: gguf_shape.clone(),
source_dtype: *source_dtype,
layer_index: *layer_index,
},
PlanStep::Fused {
gguf_name,
gguf_shape_fused,
source_dtype,
layer_index,
..
} => PlanEntry {
name: gguf_name.clone(),
shape: gguf_shape_fused.clone(),
source_dtype: *source_dtype,
layer_index: *layer_index,
},
PlanStep::Synthesized {
gguf_name,
gguf_shape,
source_dtype,
layer_index,
..
} => PlanEntry {
name: gguf_name.clone(),
shape: gguf_shape.clone(),
source_dtype: *source_dtype,
layer_index: *layer_index,
},
}
}
fn materialize(
&self,
src: &HfModelSource,
synthesized: &[HfTensor],
) -> Result<Vec<f32>, ConvertError> {
match self {
PlanStep::Direct { hf_name, bake, .. } => {
let ht = src.materialize_tensor(hf_name)?;
match bake {
None => Ok(ht.data),
Some(op) => bake::apply_bake_op(ht.data, op).map_err(|e| {
ConvertError::Source(SourceError::Safetensors(format!(
"bake op failed on `{hf_name}`: {e}"
)))
}),
}
}
PlanStep::Fused { gguf_name, .. } => {
Err(ConvertError::Source(SourceError::Safetensors(format!(
"fused tensor `{gguf_name}` must use bounded chunk streaming"
))))
}
PlanStep::Synthesized { synth_idx, .. } => {
let t = synthesized.get(*synth_idx).ok_or_else(|| {
ConvertError::Source(SourceError::Safetensors(format!(
"synthesized tensor index {synth_idx} out of range"
)))
})?;
Ok(t.data.clone())
}
}
}
}
struct ConvertPlan {
steps: Vec<PlanStep>,
}
fn build_convert_plan(
arch: ArchName,
src: &HfModelSource,
synthesized: &[HfTensor],
) -> Result<ConvertPlan, ConvertError> {
let n_experts_cfg = effective_config(&src.config);
let n_experts = n_experts_cfg
.get("num_experts")
.or_else(|| n_experts_cfg.get("num_local_experts"))
.or_else(|| n_experts_cfg.get("n_routed_experts"))
.and_then(|v| v.as_u64())
.map(|x| x as usize);
let qwen35moe_full_ctx: Option<Qwen35MoeFullCtx> = match arch {
ArchName::Qwen35MoeFull => build_qwen35moe_full_ctx(&src.config),
_ => None,
};
let llama3_ctx: Option<Llama3Ctx> = match arch {
ArchName::Llama3 => build_llama3_ctx(&src.config),
_ => None,
};
let nomic_bert_ctx: nomic_bert::NomicBertCtx = match arch {
ArchName::NomicBert => build_nomic_bert_ctx(&src.config),
_ => nomic_bert::NomicBertCtx { num_experts: None },
};
let mut direct_steps: Vec<PlanStep> = Vec::new();
let mut moe_accum: HashMap<(usize, ExpertKindKey), MoePlanGroup> = HashMap::new();
for meta in src.tensor_metas() {
match map_tensor(
arch,
&meta.name,
&meta.shape,
qwen35moe_full_ctx.as_ref(),
llama3_ctx.as_ref(),
&nomic_bert_ctx,
) {
MapOutcome::Direct(gguf_name) => {
let gguf_shape: Vec<usize> = meta.shape.iter().rev().copied().collect();
let layer_index = gguf_name
.strip_prefix("blk.")
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse::<usize>().ok());
direct_steps.push(PlanStep::Direct {
hf_name: meta.name.clone(),
gguf_name,
gguf_shape,
source_dtype: meta.source_dtype,
layer_index,
bake: None,
});
}
MapOutcome::DirectWithBake { gguf_name, bake } => {
let mut gguf_shape: Vec<usize> = meta.shape.iter().rev().copied().collect();
fn contains_squeeze(op: &BakeOp) -> bool {
match op {
BakeOp::Squeeze => true,
BakeOp::Sequence(inner) => inner.iter().any(contains_squeeze),
_ => false,
}
}
if contains_squeeze(&bake) {
gguf_shape.retain(|d| *d != 1);
}
match &bake {
BakeOp::MoeExpertReshape {
n_experts,
n_inner,
n_embd,
} => {
gguf_shape = vec![*n_embd, *n_inner, *n_experts];
}
BakeOp::MoeExpertTranspose {
n_experts,
n_inner,
n_embd,
} => {
gguf_shape = vec![*n_inner, *n_embd, *n_experts];
}
BakeOp::PatchEmbedderReshape {
out_features,
patch_h,
patch_w,
channels,
} => {
gguf_shape = vec![*patch_w, *patch_h, *channels, *out_features];
}
_ => {}
}
let layer_index = gguf_name
.strip_prefix("blk.")
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse::<usize>().ok());
direct_steps.push(PlanStep::Direct {
hf_name: meta.name.clone(),
gguf_name,
gguf_shape,
source_dtype: meta.source_dtype,
layer_index,
bake: Some(bake),
});
}
MapOutcome::SplitInto(outputs) => {
if outputs.is_empty() {
return Err(ConvertError::UnmappedTensor {
hf_name: meta.name.clone(),
arch: arch.name().to_string(),
});
}
for out in outputs {
let layer_index = out
.gguf_name
.strip_prefix("blk.")
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse::<usize>().ok());
direct_steps.push(PlanStep::Direct {
hf_name: meta.name.clone(),
gguf_name: out.gguf_name,
gguf_shape: out.gguf_shape,
source_dtype: meta.source_dtype,
layer_index,
bake: Some(out.bake),
});
}
}
MapOutcome::Expert {
gguf_name,
layer,
expert_index,
kind,
} => {
let key = (layer, ExpertKindKey::from(kind));
let group = moe_accum.entry(key).or_insert_with(|| MoePlanGroup {
gguf_name: gguf_name.clone(),
kind,
members: Vec::with_capacity(n_experts.unwrap_or(0)),
per_expert_py_shape: meta.shape.clone(),
source_dtype: meta.source_dtype,
});
if group.members.iter().any(|m| m.expert_index == expert_index) {
return Err(ConvertError::DuplicateExpertIndex {
gguf_name,
layer,
kind_label: expert_kind_label(kind),
expert_index,
});
}
group.members.push(MoePlanMember {
hf_name: meta.name.clone(),
expert_index,
});
}
MapOutcome::Drop => {
tracing::debug!(
target: "convert",
arch = arch.name(),
tensor = %meta.name,
"convert: explicit drop per arch mapper"
);
}
MapOutcome::Unmapped => {
return Err(ConvertError::UnmappedTensor {
hf_name: meta.name.clone(),
arch: arch.name().to_string(),
});
}
}
}
let expected_n_experts = n_experts.unwrap_or(0);
let mut groups: Vec<((usize, ExpertKindKey), MoePlanGroup)> = moe_accum.into_iter().collect();
groups.sort_by_key(|(k, _)| (k.0, k.1 as u8));
let mut fused_steps: Vec<PlanStep> = Vec::with_capacity(groups.len());
for ((layer, _kind_key), group) in groups {
let MoePlanGroup {
gguf_name,
kind,
mut members,
per_expert_py_shape,
source_dtype,
} = group;
if expected_n_experts == 0 {
return Err(ConvertError::IncompleteExpertGroup {
gguf_name,
layer,
kind_label: expert_kind_label(kind),
present_count: members.len(),
n_experts_config: 0,
});
}
if members.len() != expected_n_experts {
return Err(ConvertError::IncompleteExpertGroup {
gguf_name,
layer,
kind_label: expert_kind_label(kind),
present_count: members.len(),
n_experts_config: expected_n_experts,
});
}
members.sort_by_key(|m| m.expert_index);
for (i, m) in members.iter().enumerate() {
if m.expert_index != i {
return Err(ConvertError::IncompleteExpertGroup {
gguf_name,
layer,
kind_label: expert_kind_label(kind),
present_count: members.len(),
n_experts_config: expected_n_experts,
});
}
}
let mut gguf_shape_fused: Vec<usize> = per_expert_py_shape.iter().rev().copied().collect();
gguf_shape_fused.push(expected_n_experts);
let member_hf_names: Vec<String> = members.into_iter().map(|m| m.hf_name).collect();
fused_steps.push(PlanStep::Fused {
gguf_name,
gguf_shape_fused,
member_hf_names,
per_expert_py_shape,
source_dtype,
layer_index: Some(layer),
});
}
let mut synth_steps: Vec<PlanStep> = Vec::new();
for (synth_idx, t) in synthesized.iter().enumerate() {
match map_tensor(
arch,
&t.name,
&t.shape,
qwen35moe_full_ctx.as_ref(),
llama3_ctx.as_ref(),
&nomic_bert_ctx,
) {
MapOutcome::Direct(gguf_name) => {
let gguf_shape: Vec<usize> = t.shape.iter().rev().copied().collect();
let layer_index = gguf_name
.strip_prefix("blk.")
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse::<usize>().ok());
synth_steps.push(PlanStep::Synthesized {
gguf_name,
gguf_shape,
source_dtype: t.source_dtype,
layer_index,
synth_idx,
});
}
MapOutcome::Drop => {
tracing::debug!(
target: "convert",
arch = arch.name(),
tensor = %t.name,
"convert: synthesized tensor explicit-drop per arch mapper"
);
}
MapOutcome::DirectWithBake { .. } | MapOutcome::SplitInto(_) => {
return Err(ConvertError::UnmappedTensor {
hf_name: t.name.clone(),
arch: arch.name().to_string(),
});
}
MapOutcome::Expert { .. } => {
return Err(ConvertError::UnmappedTensor {
hf_name: t.name.clone(),
arch: arch.name().to_string(),
});
}
MapOutcome::Unmapped => {
return Err(ConvertError::UnmappedTensor {
hf_name: t.name.clone(),
arch: arch.name().to_string(),
});
}
}
}
let mut steps: Vec<PlanStep> =
Vec::with_capacity(direct_steps.len() + fused_steps.len() + synth_steps.len());
steps.extend(direct_steps);
steps.extend(fused_steps);
steps.extend(synth_steps);
if matches!(arch, ArchName::Gemma4Mmproj | ArchName::Gemma4VisionMmproj) {
steps.sort_by(|a, b| {
let key_of = |step: &PlanStep| -> String {
match step {
PlanStep::Direct { hf_name, .. } => hf_name.clone(),
PlanStep::Fused { gguf_name, .. } => gguf_name.clone(),
PlanStep::Synthesized { gguf_name, .. } => gguf_name.clone(),
}
};
key_of(a).cmp(&key_of(b))
});
} else {
steps.sort_by(|a, b| {
canonical_tensor_name_cmp(a.plan_entry().name.as_str(), b.plan_entry().name.as_str())
});
}
Ok(ConvertPlan { steps })
}
fn canonical_tensor_name_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let a_layer = parse_blk_layer(a);
let b_layer = parse_blk_layer(b);
if a_layer != b_layer {
return a_layer.cmp(&b_layer);
}
a.cmp(b)
}
fn parse_blk_layer(name: &str) -> i32 {
let Some(rest) = name.strip_prefix("blk.") else {
return -1;
};
let mut end = 0;
for (i, c) in rest.char_indices() {
if c.is_ascii_digit() {
end = i + c.len_utf8();
} else {
break;
}
}
if end == 0 {
return -1;
}
if !rest[end..].starts_with('.') {
return -1;
}
rest[..end].parse::<i32>().unwrap_or(-1)
}
struct MoePlanMember {
hf_name: String,
expert_index: usize,
}
struct MoePlanGroup {
gguf_name: String,
kind: ExpertKind,
members: Vec<MoePlanMember>,
per_expert_py_shape: Vec<usize>,
source_dtype: SourceDtype,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
enum ExpertKindKey {
Gate = 0,
Up = 1,
Down = 2,
}
impl From<ExpertKind> for ExpertKindKey {
fn from(k: ExpertKind) -> Self {
match k {
ExpertKind::Gate => ExpertKindKey::Gate,
ExpertKind::Up => ExpertKindKey::Up,
ExpertKind::Down => ExpertKindKey::Down,
}
}
}
fn expert_kind_label(k: ExpertKind) -> &'static str {
match k {
ExpertKind::Gate => "gate",
ExpertKind::Up => "up",
ExpertKind::Down => "down",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::quantize::ggml_quants::apex::ApexTier;
use crate::quantize::ggml_quants::LlamaFtype;
use serde_json::json;
use std::io::Write;
#[test]
fn temporary_conversion_output_is_fail_safe_and_promotes_atomically() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("model.gguf");
std::fs::write(&output, b"previous-complete-artifact").unwrap();
let receipt = crate::convert::receipt::receipt_path(&output);
std::fs::write(&receipt, b"previous-valid-receipt").unwrap();
{
let mut interrupted = create_temporary_output(&output).unwrap();
interrupted.write_all(b"partial-new-artifact").unwrap();
}
assert_eq!(
std::fs::read(&output).unwrap(),
b"previous-complete-artifact",
"dropping an unpromoted temporary must preserve the prior artifact"
);
assert_eq!(
std::fs::read(&receipt).unwrap(),
b"previous-valid-receipt",
"a failed conversion must preserve the prior artifact receipt"
);
let mut complete = create_temporary_output(&output).unwrap();
complete.write_all(b"complete-new-artifact").unwrap();
promote_temporary_output(complete, &output).unwrap();
assert_eq!(std::fs::read(&output).unwrap(), b"complete-new-artifact");
}
#[test]
fn detect_arch_llama_from_model_type() {
let cfg = json!({ "model_type": "llama" });
assert_eq!(detect_arch(&cfg).unwrap(), ArchName::Llama3);
}
#[test]
fn detect_arch_gemma_from_model_type() {
assert_eq!(
detect_arch(&json!({ "model_type": "gemma3" })).unwrap(),
ArchName::Gemma4
);
assert_eq!(
detect_arch(&json!({ "model_type": "gemma" })).unwrap(),
ArchName::Gemma4
);
}
#[test]
fn detect_arch_gemma4_release_variants_real_model_2026_05_18() {
for mt in ["gemma4", "gemma4_text"] {
assert_eq!(
detect_arch(&json!({ "model_type": mt })).unwrap(),
ArchName::Gemma4,
"model_type={mt} should resolve to Gemma4"
);
}
for cls in ["Gemma4ForConditionalGeneration", "Gemma4ForCausalLM"] {
assert_eq!(
detect_arch(&json!({ "architectures": [cls] })).unwrap(),
ArchName::Gemma4,
"architectures=[{cls}] should resolve to Gemma4"
);
}
}
#[test]
fn detect_arch_qwen3moe() {
assert_eq!(
detect_arch(&json!({ "model_type": "qwen3_moe" })).unwrap(),
ArchName::Qwen35Moe
);
}
#[test]
fn detect_arch_qwen35moe_release_variants_codex_3b478164() {
for mt in ["qwen3_5_moe", "qwen3_5_moe_text"] {
assert_eq!(
detect_arch(&json!({ "model_type": mt })).unwrap(),
ArchName::Qwen35MoeFull,
"model_type={mt} should resolve to Qwen35MoeFull"
);
}
for cls in [
"Qwen3_5MoeForCausalLM",
"Qwen3_5MoeForConditionalGeneration",
] {
assert_eq!(
detect_arch(&json!({ "architectures": [cls] })).unwrap(),
ArchName::Qwen35MoeFull,
"architectures=[{cls}] should resolve to Qwen35MoeFull"
);
}
assert_eq!(
detect_arch(&json!({ "model_type": "qwen3_moe" })).unwrap(),
ArchName::Qwen35Moe
);
assert_eq!(
detect_arch(&json!({ "architectures": ["Qwen3MoeForCausalLM"] })).unwrap(),
ArchName::Qwen35Moe
);
}
#[test]
fn detect_arch_qwen3vl_flavors() {
for mt in ["qwen3_vl", "qwen3_vl_moe", "qwen3_vl_text"] {
assert_eq!(
detect_arch(&json!({ "model_type": mt })).unwrap(),
ArchName::Qwen3VlText,
"model_type={mt}"
);
}
}
#[test]
fn detect_arch_bert() {
assert_eq!(
detect_arch(&json!({ "model_type": "bert" })).unwrap(),
ArchName::Bert
);
assert_eq!(
detect_arch(&json!({ "architectures": ["BertForMaskedLM"] })).unwrap(),
ArchName::Bert
);
assert_eq!(
detect_arch(&json!({ "architectures": ["BertModel"] })).unwrap(),
ArchName::Bert
);
}
#[test]
fn detect_arch_nomic_bert() {
assert_eq!(
detect_arch(&json!({ "model_type": "nomic_bert" })).unwrap(),
ArchName::NomicBert
);
}
#[test]
fn detect_arch_minimax() {
assert_eq!(
detect_arch(&json!({ "model_type": "minimax_m2" })).unwrap(),
ArchName::MiniMaxM2
);
assert_eq!(
detect_arch(&json!({ "architectures": ["MiniMaxM2ForCausalLM"] })).unwrap(),
ArchName::MiniMaxM2
);
}
#[test]
fn detect_arch_via_architectures_fallback() {
assert_eq!(
detect_arch(&json!({ "architectures": ["LlamaForCausalLM"] })).unwrap(),
ArchName::Llama3
);
assert_eq!(
detect_arch(&json!({ "architectures": ["Qwen3MoeForCausalLM"] })).unwrap(),
ArchName::Qwen35Moe
);
}
#[test]
fn detect_arch_model_type_wins_over_architectures() {
let cfg = json!({
"model_type": "llama",
"architectures": ["Qwen3MoeForCausalLM"]
});
assert_eq!(detect_arch(&cfg).unwrap(), ArchName::Llama3);
}
#[test]
fn detect_arch_unsupported_errors() {
let cfg = json!({ "model_type": "mamba" });
match detect_arch(&cfg).expect_err("must error") {
ConvertError::UnsupportedArch { arch_name } => {
assert_eq!(arch_name, "mamba");
}
other => panic!("expected UnsupportedArch, got {other:?}"),
}
}
#[test]
fn detect_arch_completely_missing_errors() {
let cfg = json!({});
match detect_arch(&cfg).expect_err("must error") {
ConvertError::UnsupportedArch { arch_name } => {
assert!(arch_name.contains("missing"));
}
other => panic!("expected UnsupportedArch, got {other:?}"),
}
}
#[test]
fn build_hparams_defaults_kv_heads_to_head_count() {
let cfg = json!({ "num_attention_heads": 8, "num_hidden_layers": 16 });
let hp = build_hparams(&cfg).unwrap();
assert_eq!(hp.n_head, 8);
assert_eq!(hp.n_head_kv, 8);
assert_eq!(hp.n_expert, 0);
assert_eq!(hp.n_layer, 16);
}
#[test]
fn build_hparams_picks_up_moe_expert_count() {
let cfg_qwen = json!({
"num_attention_heads": 32,
"num_experts": 128,
"num_hidden_layers": 30,
});
let cfg_minimax = json!({
"num_attention_heads": 32,
"num_local_experts": 32,
"num_hidden_layers": 40,
});
assert_eq!(build_hparams(&cfg_qwen).unwrap().n_expert, 128);
assert_eq!(build_hparams(&cfg_minimax).unwrap().n_expert, 32);
assert_eq!(build_hparams(&cfg_qwen).unwrap().n_layer, 30);
}
#[test]
fn build_hparams_missing_n_layer_errors() {
let cfg = json!({ "num_attention_heads": 8 });
match build_hparams(&cfg).expect_err("must error") {
ConvertError::MissingHparam { key } => {
assert_eq!(key, "num_hidden_layers");
}
other => panic!("expected MissingHparam, got {other:?}"),
}
}
#[test]
fn build_hparams_missing_head_count_errors() {
let cfg = json!({});
match build_hparams(&cfg).expect_err("must error") {
ConvertError::MissingHparam { key } => {
assert_eq!(key, "num_attention_heads");
}
other => panic!("expected MissingHparam, got {other:?}"),
}
}
#[test]
fn parse_quant_selector_standard_round_trip() {
let sel = QuantSelector::from_name("q5_k_m").expect("must parse");
assert_eq!(sel, QuantSelector::Standard(LlamaFtype::MostlyQ5_K_M));
}
#[test]
fn parse_quant_selector_apex_round_trip() {
let sel = QuantSelector::from_name("apex-balanced").expect("must parse");
assert_eq!(sel, QuantSelector::Apex(ApexTier::Balanced));
}
#[test]
fn parse_quant_selector_apex_i_variant() {
let sel = QuantSelector::from_name("apex-i-quality").expect("must parse");
assert_eq!(sel, QuantSelector::Apex(ApexTier::IQuality));
}
#[test]
fn parse_quant_selector_apex_custom_errors() {
use crate::convert::quant_selector::QuantSelectorError;
let err = QuantSelector::from_name("apex-custom").expect_err("must error");
assert!(matches!(
err,
QuantSelectorError::ApexCustomRequiresTensorTypeFile
));
}
#[test]
fn parse_quant_selector_dwq_reserved() {
use crate::convert::quant_selector::QuantSelectorError;
let err = QuantSelector::from_name("dwq").expect_err("must error");
assert!(matches!(err, QuantSelectorError::DwqReserved));
}
#[test]
fn parse_quant_selector_apex_nano_out_of_scope() {
use crate::convert::quant_selector::QuantSelectorError;
let err = QuantSelector::from_name("apex-nano").expect_err("must error");
match err {
QuantSelectorError::ApexTierOutOfScope { tier } => assert_eq!(tier, "nano"),
other => panic!("expected ApexTierOutOfScope, got {other:?}"),
}
}
fn dummy_hf_dir() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/imatrix-test-unused")
}
#[test]
fn imatrix_required_for_i_tier_without_data() {
let err = super::resolve_imatrix_input(
&ApexTier::IBalanced,
None,
None,
&dummy_hf_dir(),
crate::quantize::ggml_quants::ArchName::Gemma4,
512,
)
.unwrap_err();
match err {
ConvertError::ImatrixRequiredForITier { tier } => {
assert_eq!(tier, "i-balanced")
}
other => panic!("expected ImatrixRequiredForITier, got {other:?}"),
}
}
#[test]
fn no_imatrix_required_for_non_i_tiers() {
for tier in [
ApexTier::Quality,
ApexTier::Balanced,
ApexTier::Compact,
ApexTier::Mini,
] {
let res = super::resolve_imatrix_input(
&tier,
None,
None,
&dummy_hf_dir(),
crate::quantize::ggml_quants::ArchName::Gemma4,
512,
)
.unwrap();
assert!(
res.is_none(),
"non-I tier {tier:?} should not require imatrix data"
);
}
}
#[test]
fn imatrix_corpus_drives_in_tree_and_errors_typed() {
let bogus_hf = std::path::PathBuf::from("/tmp/imatrix-corpus-driver-test-nonexistent");
let err = super::resolve_imatrix_input(
&ApexTier::IBalanced,
None,
Some("cdv3"),
&bogus_hf,
crate::quantize::ggml_quants::ArchName::Gemma4,
512,
)
.unwrap_err();
match err {
ConvertError::Imatrix(crate::quantize::imatrix::ImatrixError::ConvertFailed {
detail,
}) => {
assert!(
detail.contains("does not exist") || detail.contains("not a directory"),
"detail should describe missing hf_dir, got: {detail}"
);
}
other => panic!("expected ConvertFailed, got {other:?}"),
}
}
#[test]
fn imatrix_corpus_unsupported_arch_errors_typed() {
let err = super::resolve_imatrix_input(
&ApexTier::IBalanced,
None,
Some("cdv3"),
&std::path::PathBuf::from("/tmp"),
crate::quantize::ggml_quants::ArchName::MiniMaxM2,
512,
)
.unwrap_err();
match err {
ConvertError::Imatrix(
crate::quantize::imatrix::ImatrixError::UnsupportedArchForDriver {
arch,
supported,
},
) => {
assert_eq!(arch, "minimax-m2");
assert_eq!(supported, &["gemma4", "qwen35moe"]);
}
other => panic!("expected UnsupportedArchForDriver, got {other:?}"),
}
}
#[test]
fn imatrix_corpus_qwen35moe_passes_arch_gate() {
let err = super::resolve_imatrix_input(
&ApexTier::IQuality,
None,
Some("cdv3"),
&std::path::PathBuf::from("/tmp/non-existent-fixture-qwen35moe-cli"),
crate::quantize::ggml_quants::ArchName::Qwen35Moe,
512,
)
.unwrap_err();
match err {
ConvertError::Imatrix(
crate::quantize::imatrix::ImatrixError::UnsupportedArchForDriver { arch, .. },
) => panic!(
"Stage 3b.4 regression: Qwen35Moe should pass arch gate but got \
UnsupportedArchForDriver(arch={arch:?})"
),
_ => {}
}
}
#[test]
fn imatrix_corpus_unknown_name_errors_typed() {
let err = super::resolve_imatrix_input(
&ApexTier::IBalanced,
None,
Some("wikitext-9000"),
&dummy_hf_dir(),
crate::quantize::ggml_quants::ArchName::Gemma4,
512,
)
.unwrap_err();
match err {
ConvertError::Imatrix(crate::quantize::imatrix::ImatrixError::UnknownBakedCorpus {
name,
..
}) => assert_eq!(name, "wikitext-9000"),
other => panic!("expected UnknownBakedCorpus, got {other:?}"),
}
}
#[test]
fn imatrix_missing_file_errors_typed() {
let bogus = std::path::PathBuf::from("/nonexistent/path/imatrix.gguf");
let err = super::resolve_imatrix_input(
&ApexTier::IBalanced,
Some(bogus.as_path()),
None,
&dummy_hf_dir(),
crate::quantize::ggml_quants::ArchName::Gemma4,
512,
)
.unwrap_err();
match err {
ConvertError::Imatrix(_) => { }
other => panic!("expected ConvertError::Imatrix, got {other:?}"),
}
}
#[test]
fn imatrix_n_ctx_zero_errors_typed() {
let err = super::resolve_imatrix_input(
&ApexTier::IBalanced,
None,
Some("cdv3"),
&std::path::PathBuf::from("/tmp"),
crate::quantize::ggml_quants::ArchName::Gemma4,
0,
)
.unwrap_err();
match err {
ConvertError::ImatrixNCtxInvalid { n_ctx } => {
assert_eq!(n_ctx, 0);
let msg = err.to_string();
assert!(
msg.contains("must be > 0"),
"msg should explain the constraint: {msg}",
);
assert!(
msg.contains("512"),
"msg should mention the default for operator hint: {msg}",
);
}
other => panic!("expected ImatrixNCtxInvalid, got {other:?}"),
}
}
#[test]
fn imatrix_n_ctx_non_default_plumbs_through() {
let bogus_hf = std::path::PathBuf::from("/tmp/imatrix-n-ctx-plumbing-test-nonexistent");
let err = super::resolve_imatrix_input(
&ApexTier::IBalanced,
None,
Some("cdv3"),
&bogus_hf,
crate::quantize::ggml_quants::ArchName::Gemma4,
1024,
)
.unwrap_err();
match err {
ConvertError::Imatrix(crate::quantize::imatrix::ImatrixError::ConvertFailed {
..
}) => { }
other => panic!("expected ConvertFailed (n_ctx=1024 plumbed through), got {other:?}"),
}
}
#[test]
fn imatrix_file_loads_for_any_tier() {
use crate::quantize::imatrix::{write_imatrix_to_path, AccumulatorRegistry};
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut reg = AccumulatorRegistry::new();
let acc = reg.register("blk.0.attn_q.weight", 4, 1).unwrap();
acc.absorb_dense(&[1.0, 2.0, 3.0, 4.0]).unwrap();
write_imatrix_to_path(tmp.path(), ®, &["cdv3".to_string()], 1, 512).unwrap();
let data = super::resolve_imatrix_input(
&ApexTier::IBalanced,
Some(tmp.path()),
None,
&dummy_hf_dir(),
crate::quantize::ggml_quants::ArchName::Gemma4,
512,
)
.unwrap()
.unwrap();
assert_eq!(data.tensor_pair_count(), 1);
let data = super::resolve_imatrix_input(
&ApexTier::Balanced,
Some(tmp.path()),
None,
&dummy_hf_dir(),
crate::quantize::ggml_quants::ArchName::Gemma4,
512,
)
.unwrap()
.unwrap();
assert_eq!(data.tensor_pair_count(), 1);
}
#[test]
fn deepseek_detection_and_incomplete_expert_group_fail_closed() {
assert_eq!(
detect_arch(&json!({"model_type":"deepseek_v4"})).unwrap(),
ArchName::Deepseek4
);
assert_eq!(
detect_arch(&json!({"architectures":["DeepseekV4ForCausalLM"]})).unwrap(),
ArchName::Deepseek4
);
use safetensors::tensor::{Dtype, TensorView};
let dir = tempfile::tempdir().unwrap();
let expert_bytes = vec![0_u8; 16];
let scale_bytes = vec![127_u8];
let expert = TensorView::new(Dtype::I8, vec![1, 16], &expert_bytes).unwrap();
let scale = TensorView::new(Dtype::F8_E8M0, vec![1, 1], &scale_bytes).unwrap();
let tensors = vec![
("layers.0.ffn.experts.0.w1.weight".to_string(), &expert),
("layers.0.ffn.experts.0.w1.scale".to_string(), &scale),
];
std::fs::write(
dir.path().join("model.safetensors"),
safetensors::tensor::serialize(tensors, None).unwrap(),
)
.unwrap();
std::fs::write(
dir.path().join("config.json"),
serde_json::to_vec(&json!({
"model_type":"deepseek_v4", "n_routed_experts":2,
"quantization_config":{"quant_method":"fp8", "weight_block":[128,128]}
}))
.unwrap(),
)
.unwrap();
let source = HfModelSource::open(dir.path()).unwrap();
let err = match build_convert_plan(ArchName::Deepseek4, &source, &[]) {
Ok(_) => panic!("one of two experts must not form a complete group"),
Err(err) => err,
};
assert!(matches!(
err,
ConvertError::IncompleteExpertGroup {
present_count: 1,
n_experts_config: 2,
..
}
));
}
#[test]
fn deepseek_tiny_official_layout_converts_to_q2_k_s_end_to_end() {
use safetensors::tensor::{Dtype, TensorView};
let dir = tempfile::tempdir().unwrap();
let f16 = vec![0_u8; 32 * 256 * 2];
let packed = vec![0x21_u8; 256 * 128];
let scales = vec![127_u8; 256 * 8];
let mut owned: Vec<(String, Dtype, Vec<usize>, Vec<u8>)> = vec![
(
"embed.weight".into(),
Dtype::F16,
vec![32, 256],
f16.clone(),
),
("head.weight".into(), Dtype::F16, vec![32, 256], f16),
(
"norm.weight".into(),
Dtype::F32,
vec![256],
vec![0; 256 * 4],
),
];
for expert in 0..2 {
for proj in ["w1", "w2", "w3"] {
owned.push((
format!("layers.0.ffn.experts.{expert}.{proj}.weight"),
Dtype::I8,
vec![256, 128],
packed.clone(),
));
owned.push((
format!("layers.0.ffn.experts.{expert}.{proj}.scale"),
Dtype::U8,
vec![256, 8],
scales.clone(),
));
}
}
let views: Vec<(String, TensorView<'_>)> = owned
.iter()
.map(|(name, dtype, shape, bytes)| {
(
name.clone(),
TensorView::new(*dtype, shape.clone(), bytes).unwrap(),
)
})
.collect();
let refs: Vec<(String, &TensorView<'_>)> = views
.iter()
.map(|(name, view)| (name.clone(), view))
.collect();
std::fs::write(
dir.path().join("model.safetensors"),
safetensors::tensor::serialize(refs, None).unwrap(),
)
.unwrap();
let cfg = json!({
"_name_or_path":"deepseek-ai/DeepSeek-V4-Flash-0731", "model_type":"deepseek_v4",
"architectures":["DeepseekV4ForCausalLM"], "hidden_size":256,
"num_hidden_layers":1, "num_attention_heads":4, "num_key_value_heads":1, "head_dim":64,
"max_position_embeddings":1024, "rms_norm_eps":1e-6, "vocab_size":32,
"n_routed_experts":2, "num_experts_per_tok":1, "n_shared_experts":1,
"moe_intermediate_size":256, "routed_scaling_factor":1.5,
"norm_topk_prob":true, "scoring_func":"sqrtsoftplus", "swiglu_limit":10.0,
"qk_rope_head_dim":64, "q_lora_rank":64, "sliding_window":128,
"index_n_heads":4, "index_head_dim":32, "index_topk":16,
"o_groups":2, "o_lora_rank":64, "compress_ratios":[0],
"compress_rope_theta":160000.0, "hc_mult":4, "hc_sinkhorn_iters":20,
"hc_eps":1e-6, "num_hash_layers":0,
"quantization_config":{"quant_method":"fp8", "weight_block":[128,128]}
});
std::fs::write(
dir.path().join("config.json"),
serde_json::to_vec(&cfg).unwrap(),
)
.unwrap();
let mut vocab = serde_json::Map::new();
for i in 0..28 {
vocab.insert(format!("tok{i}"), json!(i));
}
std::fs::write(
dir.path().join("tokenizer.json"),
serde_json::to_vec(&json!({
"model":{"type":"BPE", "byte_fallback":true, "vocab":vocab, "merges":[]},
"added_tokens":[
{"id":28,"content":"<bos>","special":true},
{"id":29,"content":"<eos>","special":true},
{"id":30,"content":"<pad>","special":true},
{"id":31,"content":"<unk>","special":true}
]
}))
.unwrap(),
)
.unwrap();
std::fs::write(
dir.path().join("tokenizer_config.json"),
serde_json::to_vec(&json!({
"bos_token":"<bos>", "eos_token":"<eos>", "pad_token":"<pad>",
"unk_token":"<unk>", "add_bos_token":true, "add_eos_token":false
}))
.unwrap(),
)
.unwrap();
let output = dir.path().join("tiny-q2-k-s.gguf");
run_convert(ConvertArgs {
hf_dir: dir.path().to_path_buf(),
selector: QuantSelector::Standard(LlamaFtype::MostlyQ2_K_S),
output: output.clone(),
dry_run: false,
imatrix: None,
imatrix_corpus: None,
imatrix_out: None,
imatrix_n_ctx: None,
mmproj: false,
remote_source: Some(RemoteConversionSource {
repo: "deepseek-ai/DeepSeek-V4-Flash-0731".into(),
revision: "a".repeat(40),
source_sha256: "b".repeat(64),
files: Vec::new(),
}),
})
.unwrap();
let bytes = std::fs::read(&output).unwrap();
assert_eq!(&bytes[..4], b"GGUF");
assert!(bytes
.windows(b"blk.0.ffn_gate_exps.weight".len())
.any(|w| w == b"blk.0.ffn_gate_exps.weight"));
assert!(bytes.len() > 32 * 1024);
let producer = concat!("hf2q ", env!("CARGO_PKG_VERSION")).as_bytes();
assert!(bytes
.windows(producer.len())
.any(|window| window == producer));
let expected_source_sha = "b".repeat(64);
assert!(bytes
.windows(expected_source_sha.len())
.any(|window| window == expected_source_sha.as_bytes()));
let receipt: crate::convert::receipt::ConversionReceipt = serde_json::from_slice(
&std::fs::read(crate::convert::receipt::receipt_path(&output)).unwrap(),
)
.unwrap();
assert_eq!(receipt.source.revision, "a".repeat(40));
assert_eq!(receipt.quant_selector, "q2_k_s");
assert_eq!(receipt.output.size, bytes.len() as u64);
}
}