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::qwen35_dense::Qwen35DenseCtx;
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, qwen35_dense, qwen35moe, qwen35moe_full, qwen3vl_text,
};
use crate::convert::evidence_source::VerifiedEvidenceSource;
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, TensorMeta};
use crate::convert::tensor_lineage::{
apply_qwen35_dense_bake_with_evidence, build_verified_source_to_stored_conversion,
clear_stale_tensor_conversion_receipt, prepare_tensor_conversion_receipt,
promote_tensor_conversion_receipt, verify_complete_conversion_source,
verify_open_artifact_identity, verify_promoted_stored_catalog,
verify_source_to_stored_continuity, verify_source_weight_artifacts,
verify_written_tensor_evidence_file, verify_written_tensor_evidence_open,
MaterializedDirectTensorEvidence, MaterializedDirectTensorRecord,
VerifiedConversionEvidenceContext, VerifiedSourceToStoredConversion,
};
use crate::convert::tokenizer::TokenizerError;
use crate::convert::{
build_tokenizer_metadata, ConvertOrchestrator, HfModelSource, HfTensor, OrchestratorError,
};
use crate::core::paired_artifact::{
KEY_PAIR_GENERATION, KEY_PAIR_SCHEMA_VERSION, PAIR_METADATA_SCHEMA_VERSION,
};
use crate::core::provenance::{KEY_MMPROJ_SHA256, KEY_PRODUCER_VERSION, KEY_SOURCE_SHA256};
use crate::input::integrity::VerifiedSourceManifest;
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::SourceDtype;
use crate::quantize::ggml_quants::{ArchName, GgufFtype};
#[path = "cli_driver/paired.rs"]
mod paired;
#[path = "cli_driver/paired_transaction.rs"]
mod paired_transaction;
#[path = "cli_driver/stored_evidence.rs"]
mod stored_evidence;
#[cfg(test)]
pub(crate) use stored_evidence::verify_persisted_stored_evidence;
#[allow(unused_imports)] pub(crate) use stored_evidence::{
run_convert_with_stored_evidence, verify_persisted_stored_artifact, RetainedQwenArtifactLoad,
VerifiedStoredQwenArtifact,
};
#[cfg(test)]
#[path = "cli_driver/stored_evidence_tests.rs"]
mod stored_evidence_tests;
#[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 mode: ConvertMode,
pub remote_source: Option<RemoteConversionSource>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConvertMode {
TextOnly,
ProjectorOnly,
Paired { projector_output: Option<PathBuf> },
}
#[derive(Debug)]
pub enum ConvertError {
Source(SourceError),
Orchestrator(OrchestratorError),
Io(std::io::Error),
Integrity(crate::core::integrity::IntegrityError),
Receipt(ReceiptError),
Vision(crate::models::vit::VitConvertError),
Pair { detail: String },
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,
MissingInput,
RevisionRequiresRemote,
HfReference(crate::input::hf_reference::HfReferenceError),
HfDownload(crate::input::hf_download::DownloadError),
}
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::Vision(e) => write!(f, "convert/vision: {e}"),
ConvertError::Pair { detail } => write!(f, "convert/pair: {detail}"),
ConvertError::UnsupportedArch { arch_name } => {
write!(
f,
"convert: unsupported architecture `{arch_name}` \
(supported: llama, gemma3, bert, nomic_bert, qwen3_moe, qwen3_5, 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::MissingInput => write!(
f,
"convert: provide a local model directory or Hugging Face model reference"
),
ConvertError::RevisionRequiresRemote => write!(
f,
"convert: `--revision` is valid only with a remote Hugging Face reference; explicit local directories are used as supplied"
),
ConvertError::HfReference(e) => write!(f, "convert/reference: {e}"),
ConvertError::HfDownload(e) => write!(f, "convert/download: {e}"),
}
}
}
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::Vision(e) => Some(e),
ConvertError::Apex(e) => Some(e),
ConvertError::Tokenizer(e) => Some(e),
ConvertError::Imatrix(e) => Some(e),
ConvertError::HfReference(e) => Some(e),
ConvertError::HfDownload(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<crate::input::hf_reference::HfReferenceError> for ConvertError {
fn from(e: crate::input::hf_reference::HfReferenceError) -> Self {
ConvertError::HfReference(e)
}
}
impl From<crate::input::hf_download::DownloadError> for ConvertError {
fn from(e: crate::input::hf_download::DownloadError) -> Self {
ConvertError::HfDownload(e)
}
}
impl From<crate::models::vit::VitConvertError> for ConvertError {
fn from(e: crate::models::vit::VitConvertError) -> Self {
ConvertError::Vision(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> {
paired::run(args)
}
struct StoredEvidenceRequest {
verified_source: VerifiedSourceManifest,
context: VerifiedConversionEvidenceContext,
source_artifacts: Vec<crate::core::provenance::tensor_execution::ArtifactEvidence>,
converter_git_commit: String,
}
#[derive(Debug, Clone, Copy, Default)]
struct PairBinding<'a> {
projector_sha256: Option<&'a str>,
generation: Option<&'a str>,
}
fn run_convert_internal(
args: ConvertArgs,
evidence_request: Option<StoredEvidenceRequest>,
pair_binding: PairBinding<'_>,
) -> Result<Option<VerifiedSourceToStoredConversion>, ConvertError> {
let src = HfModelSource::open(&args.hf_dir)?;
let evidence_source = evidence_request
.as_ref()
.map(|request| {
VerifiedEvidenceSource::open(
&args.hf_dir,
&request.verified_source,
&request.context.source().model_id,
&request.context.source().revision,
&request.context.source().config_sha256,
)
})
.transpose()?;
let config = evidence_source
.as_ref()
.map(|source| &source.config)
.unwrap_or(&src.config);
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(config)?;
if evidence_request.is_some() && detected_arch != ArchName::Qwen35 {
return Err(ConvertError::UnsupportedArch {
arch_name: format!(
"stored conversion evidence v1 requires dense Qwen3.8, detected {detected_arch:?}"
),
});
}
if matches!(args.mode, ConvertMode::ProjectorOnly)
&& matches!(
detected_arch,
ArchName::Qwen35 | ArchName::Qwen35MoeFull | ArchName::Qwen3VlText
)
{
if config.get("vision_config").is_none() {
return Err(ConvertError::UnsupportedArch {
arch_name: format!(
"{detected_arch:?} (--mmproj requires a vision_config sub-object)"
),
});
}
if args.dry_run {
return Err(ConvertError::UnsupportedArch {
arch_name: format!(
"{detected_arch:?} (--mmproj dry-run is not yet available for this projector)"
),
});
}
clear_stale_conversion_receipts_before_replacement(&args.output)?;
crate::models::vit::convert_vision_tower_to_path_with_source_and_pair(
&args.hf_dir,
&args.output,
args.remote_source
.as_ref()
.map(RemoteConversionSource::source_sha256),
pair_binding.generation,
)?;
if let Some(remote) = args.remote_source.as_ref() {
let converter_git_commit = require_converter_git_commit()?;
let peak = PeakChunkBoundReceipt {
strategy: "lazy_source_index_projector_only".into(),
scope: "multimodal_projector_tensors".into(),
..PeakChunkBoundReceipt::default()
};
let prepared = prepare_success_receipt(
&args.output,
&args.output,
remote,
&converter_git_commit,
"f16-mmproj",
excluded_dspark_count,
peak,
)?;
promote_success_receipt(prepared)?;
} else {
clear_conversion_receipt(&args.output)?;
}
return Ok(None);
}
let arch = if matches!(args.mode, ConvertMode::ProjectorOnly) {
if 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(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::GgufFtype::MostlyQ2_K)
}
QuantSelector::Apex(tier) => {
let n_layers = config_n_layers(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(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> = conversion_model_basename(&args);
let size_label = compute_size_label_for_arch(arch, &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(|| 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,
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().to_owned()),
);
}
if let Some(projector_sha256) = pair_binding.projector_sha256 {
orch.add_metadata(
KEY_MMPROJ_SHA256.to_string(),
MetaValue::String(projector_sha256.to_owned()),
);
}
if let Some(generation) = pair_binding.generation {
orch.add_metadata(
KEY_PAIR_SCHEMA_VERSION.to_string(),
MetaValue::String(PAIR_METADATA_SCHEMA_VERSION.to_owned()),
);
orch.add_metadata(
KEY_PAIR_GENERATION.to_string(),
MetaValue::String(generation.to_owned()),
);
}
let synthesized: Vec<HfTensor> = synthesized_tensors_for_arch(arch, config);
let plan = if let Some(source) = evidence_source.as_ref() {
build_convert_plan(arch, config, source.tensor_metas(), &synthesized)?
} else {
build_convert_plan(arch, config, src.tensor_metas(), &synthesized)?
};
if let Some(request) = evidence_request.as_ref() {
if !synthesized.is_empty() {
return Err(ConvertError::Source(SourceError::Safetensors(
"stored conversion evidence does not admit synthesized tensors".into(),
)));
}
let mapped_source_names: Result<Vec<&str>, ConvertError> = plan
.steps
.iter()
.map(|step| match step {
PlanStep::Direct { hf_name, .. } => Ok(hf_name.as_str()),
_ => Err(ConvertError::Source(SourceError::Safetensors(
"stored conversion evidence admits only one-to-one dense Qwen mappings".into(),
))),
})
.collect();
request
.context
.validate_dense_qwen_source_coverage(mapped_source_names?, &plan.dropped_source_names)
.map_err(|error| {
ConvertError::Source(SourceError::Safetensors(format!(
"dense Qwen source coverage failed: {error}"
)))
})?;
}
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(None);
}
let converter_git_commit = if let Some(request) = evidence_request.as_ref() {
Some(request.converter_git_commit.clone())
} else {
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 = if evidence_request.is_some() {
orch.begin_write_with_evidence(bw)?
} else {
orch.begin_write(bw)?
};
let mut peak_chunk_bound = PeakChunkBoundReceipt::default();
let mut materialized_evidence = Vec::new();
for (idx, step) in plan.steps.iter().enumerate() {
if let (Some(source), Some(request)) = (evidence_source.as_ref(), evidence_request.as_ref())
{
let materialized = step.materialize_with_evidence(idx, source, &request.context)?;
let MaterializedDirectTensorEvidence { data, record } = materialized;
let stats = sw.stream_tensor(idx, &data)?;
peak_chunk_bound.observe(stats);
drop(data);
materialized_evidence.push(record);
continue;
}
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);
}
}
}
let write_evidence = if evidence_request.is_some() {
Some(sw.finalize_with_evidence()?)
} else {
sw.finalize()?;
None
};
temporary_output.as_file().sync_all()?;
let stored_catalog = write_evidence
.as_deref()
.map(|evidence| {
verify_written_tensor_evidence_file(temporary_output.as_file(), evidence).map_err(
|error| {
ConvertError::Source(SourceError::Safetensors(format!(
"finalized GGUF evidence verification failed: {error}"
)))
},
)
})
.transpose()?;
let mut verified_conversion = None;
if let (Some(request), Some(write_evidence), Some(stored_catalog)) = (
evidence_request.as_ref(),
write_evidence.as_deref(),
stored_catalog.as_ref(),
) {
verify_source_to_stored_continuity(&materialized_evidence, write_evidence, stored_catalog)
.map_err(|error| {
ConvertError::Source(SourceError::Safetensors(format!(
"source-to-stored continuity failed: {error}"
)))
})?;
let final_source_artifacts = verify_source_weight_artifacts(
&args.hf_dir,
&request.verified_source,
&request.context,
)
.map_err(|error| {
ConvertError::Source(SourceError::Safetensors(format!(
"source artifact changed during evidence conversion: {error}"
)))
})?;
if final_source_artifacts != request.source_artifacts {
return Err(ConvertError::Source(SourceError::Safetensors(
"source artifact evidence changed during conversion".into(),
)));
}
verify_complete_conversion_source(&args.hf_dir, &request.verified_source, &request.context)
.map_err(|error| {
ConvertError::Source(SourceError::Safetensors(format!(
"conversion metadata source changed during evidence conversion: {error}"
)))
})?;
verified_conversion = Some(
build_verified_source_to_stored_conversion(
&request.context,
&final_source_artifacts,
&materialized_evidence,
write_evidence,
stored_catalog,
&request.converter_git_commit,
&args.selector.receipt_name(),
)
.map_err(|error| {
ConvertError::Source(SourceError::Safetensors(format!(
"stored conversion receipt verification failed: {error}"
)))
})?,
);
}
let prepared_receipt = args
.remote_source
.as_ref()
.zip(converter_git_commit.as_deref())
.map(|(remote, commit)| {
let quant_selector = if matches!(args.mode, ConvertMode::ProjectorOnly) {
"f16-mmproj".to_owned()
} else {
args.selector.receipt_name()
};
prepare_success_receipt(
temporary_output.path(),
&args.output,
remote,
commit,
&quant_selector,
excluded_dspark_count,
peak_chunk_bound,
)
})
.transpose()?;
let prepared_tensor_receipt = verified_conversion
.as_ref()
.map(|verified| prepare_tensor_conversion_receipt(&args.output, verified))
.transpose()
.map_err(|error| {
ConvertError::Source(SourceError::Safetensors(format!(
"prepare stored conversion receipt failed: {error}"
)))
})?;
clear_stale_conversion_receipts_before_replacement(&args.output)?;
promote_temporary_output(temporary_output, &args.output)?;
if let Some(catalog) = stored_catalog.as_ref() {
if let Err(error) = verify_promoted_stored_catalog(&args.output, catalog) {
clear_stale_conversion_receipts_before_replacement(&args.output)?;
return Err(ConvertError::Source(SourceError::Safetensors(format!(
"promoted GGUF evidence verification failed: {error}"
))));
}
}
if let Some(receipt) = prepared_tensor_receipt {
if let Err(error) = promote_tensor_conversion_receipt(receipt) {
clear_stale_conversion_receipts_before_replacement(&args.output)?;
return Err(ConvertError::Source(SourceError::Safetensors(format!(
"promote stored conversion receipt failed: {error}"
))));
}
}
if let Some(receipt) = prepared_receipt {
if let Err(error) = promote_success_receipt(receipt) {
clear_stale_conversion_receipts_before_replacement(&args.output)?;
return Err(error.into());
}
} else {
clear_conversion_receipt(&args.output)?;
}
Ok(verified_conversion)
}
fn clear_stale_stored_conversion_receipt(output: &Path) -> Result<(), ConvertError> {
clear_stale_tensor_conversion_receipt(output).map_err(|error| {
ConvertError::Source(SourceError::Safetensors(format!(
"clear stale stored conversion receipt failed: {error}"
)))
})
}
fn clear_stale_conversion_receipts_before_replacement(output: &Path) -> Result<(), ConvertError> {
clear_conversion_receipt(output)?;
clear_stale_stored_conversion_receipt(output)
}
fn clear_conversion_receipt(output: &Path) -> Result<(), ReceiptError> {
clear_stale_receipt(output)
}
fn conversion_model_basename(args: &ConvertArgs) -> Option<String> {
args.remote_source
.as_ref()
.and_then(|source| {
source
.reference()
.repo_id()
.rsplit('/')
.find(|part| !part.is_empty())
})
.map(String::from)
.or_else(|| {
args.hf_dir
.file_name()
.and_then(|s| s.to_str())
.map(String::from)
})
}
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> {
crate::core::model_arch::detect_model_arch(config).map_err(|error| {
ConvertError::UnsupportedArch {
arch_name: error.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)> {
let wrapper_config = config;
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(wrapper_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::Qwen35 => qwen35_dense::build_metadata(
wrapper_config,
ftype,
model_card,
sampling,
model_dir_basename,
size_label,
),
ArchName::Qwen35Moe => qwen35moe::build_metadata(config, ftype),
ArchName::Qwen35MoeFull => match build_qwen35moe_full_ctx(wrapper_config) {
Some(ctx) => qwen35moe_full::build_metadata(
&ctx,
wrapper_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],
qwen35_dense_ctx: Option<&Qwen35DenseCtx>,
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::Qwen35 => match qwen35_dense_ctx {
Some(ctx) => {
lift_qwen35moe_full_mapped(qwen35_dense::map_tensor_name(hf_name, hf_shape, ctx))
}
None => MapOutcome::Unmapped,
},
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 peer engines (current \
stable lacks a qwen35 MTP loader). Set HF2Q_QWEN35_DROP_MTP=1 to \
strip the MTP block(s) for peer 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 build_qwen35_dense_ctx(config: &serde_json::Value) -> Option<Qwen35DenseCtx> {
crate::convert::arch::qwen35_dense::context_from_config(config)
}
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())
}
}
}
fn materialize_with_evidence(
&self,
plan_index: usize,
src: &VerifiedEvidenceSource,
context: &VerifiedConversionEvidenceContext,
) -> Result<MaterializedDirectTensorEvidence, ConvertError> {
let PlanStep::Direct {
hf_name,
gguf_name,
gguf_shape,
bake,
..
} = self
else {
return Err(ConvertError::Source(SourceError::Safetensors(
"D2b conversion evidence admits only direct dense-Qwen tensor mappings".into(),
)));
};
let tensor = src.materialize_tensor_with_evidence(hf_name)?;
let source = context
.verify_materialized_source(&tensor)
.map_err(|error| {
ConvertError::Source(SourceError::Safetensors(format!(
"source evidence failed on `{hf_name}`: {error}"
)))
})?;
if source.disposition
== crate::convert::tensor_lineage::ConversionSourceDisposition::Excluded
{
return Err(ConvertError::Source(SourceError::Safetensors(format!(
"excluded source tensor `{hf_name}` cannot be materialized into GGUF evidence"
))));
}
let mut output_shape_outermost_first: Vec<u64> = gguf_shape
.iter()
.rev()
.map(|dimension| u64::try_from(*dimension))
.collect::<Result<_, _>>()
.map_err(|_| {
ConvertError::Source(SourceError::Safetensors(format!(
"planned shape for `{hf_name}` is not representable"
)))
})?;
let (data, bake_steps) = match bake {
None => {
if source.shape_outermost_first != output_shape_outermost_first {
return Err(ConvertError::Source(SourceError::Safetensors(format!(
"direct tensor `{hf_name}` source shape {:?} != planned output {:?}",
source.shape_outermost_first, output_shape_outermost_first
))));
}
(tensor.data, Vec::new())
}
Some(operation) => {
let result = apply_qwen35_dense_bake_with_evidence(
tensor.data,
&source.shape_outermost_first,
&output_shape_outermost_first,
operation,
)
.map_err(|error| {
ConvertError::Source(SourceError::Safetensors(format!(
"bake evidence failed on `{hf_name}`: {error}"
)))
})?;
output_shape_outermost_first = result.output_shape_outermost_first;
(result.data, result.steps)
}
};
Ok(MaterializedDirectTensorEvidence {
data,
record: MaterializedDirectTensorRecord {
plan_index,
gguf_tensor_name: gguf_name.clone(),
output_shape_outermost_first,
source,
bake_steps,
},
})
}
}
struct ConvertPlan {
steps: Vec<PlanStep>,
dropped_source_names: Vec<String>,
}
fn build_convert_plan<'a>(
arch: ArchName,
config: &serde_json::Value,
tensor_metas: impl IntoIterator<Item = &'a TensorMeta>,
synthesized: &[HfTensor],
) -> Result<ConvertPlan, ConvertError> {
let n_experts_cfg = effective_config(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 qwen35_dense_ctx: Option<Qwen35DenseCtx> = match arch {
ArchName::Qwen35 => build_qwen35_dense_ctx(config),
_ => None,
};
let qwen35moe_full_ctx: Option<Qwen35MoeFullCtx> = match arch {
ArchName::Qwen35MoeFull => build_qwen35moe_full_ctx(config),
_ => None,
};
let llama3_ctx: Option<Llama3Ctx> = match arch {
ArchName::Llama3 => build_llama3_ctx(config),
_ => None,
};
let nomic_bert_ctx: nomic_bert::NomicBertCtx = match arch {
ArchName::NomicBert => build_nomic_bert_ctx(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();
let mut dropped_source_names = Vec::new();
for meta in tensor_metas {
match map_tensor(
arch,
&meta.name,
&meta.shape,
qwen35_dense_ctx.as_ref(),
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"
);
dropped_source_names.push(meta.name.clone());
}
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,
qwen35_dense_ctx.as_ref(),
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())
});
}
dropped_source_names.sort();
Ok(ConvertPlan {
steps,
dropped_source_names,
})
}
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::GgufFtype;
use serde_json::json;
use std::io::Write;
fn remote_source(repo: &str) -> RemoteConversionSource {
RemoteConversionSource::for_test(
crate::input::hf_reference::HfModelReference::parse(repo, None)
.unwrap()
.resolve(&"a".repeat(40))
.unwrap(),
"b".repeat(64),
)
}
fn basename_test_args(hf_dir: &str, repo: Option<&str>) -> ConvertArgs {
ConvertArgs {
hf_dir: PathBuf::from(hf_dir),
selector: QuantSelector::Standard(GgufFtype::MostlyQ8_0),
output: PathBuf::from("out.gguf"),
dry_run: true,
imatrix: None,
imatrix_corpus: None,
imatrix_out: None,
imatrix_n_ctx: None,
mode: ConvertMode::TextOnly,
remote_source: repo.map(remote_source),
}
}
#[test]
fn remote_model_name_uses_repository_not_content_addressed_cache_directory() {
let args = basename_test_args(
"/cache/Qwen__Qwen3.8-27B/1d4bf0f2ff6012fd82039f2fa52739d0dd7c60c0",
Some("Qwen/Qwen3.8-27B"),
);
assert_eq!(
conversion_model_basename(&args).as_deref(),
Some("Qwen3.8-27B")
);
}
#[test]
fn local_model_name_remains_directory_basename() {
let args = basename_test_args("/models/custom-qwen38", None);
assert_eq!(
conversion_model_basename(&args).as_deref(),
Some("custom-qwen38")
);
}
#[test]
fn qwen_conditional_metadata_dispatch_preserves_wrapper_vision_contract() {
let config: serde_json::Value =
serde_json::from_str(include_str!("../../tests/fixtures/qwen38/config.json"))
.expect("Qwen3.8 fixture");
let metadata = build_metadata_for_arch(
ArchName::Qwen35,
&config,
15,
None,
None,
None,
Some("Qwen3.8-27B"),
None,
None,
);
let map: std::collections::HashMap<_, _> = metadata.into_iter().collect();
assert_eq!(
map["hf2q.vision.projector_profile"],
MetaValue::String("qwen3vl_siglip".into())
);
assert_eq!(map["hf2q.vision.deepstack_output_count"], MetaValue::U32(0));
}
#[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 artifact_replacement_clears_every_stale_conversion_sidecar() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("projector.gguf");
let success_receipt = crate::convert::receipt::receipt_path(&output);
let tensor_receipt =
crate::convert::tensor_lineage::tensor_conversion_receipt_path(&output);
std::fs::write(&success_receipt, b"stale-general-receipt").unwrap();
std::fs::write(&tensor_receipt, b"stale-dense-text-lineage").unwrap();
clear_stale_conversion_receipts_before_replacement(&output).unwrap();
assert!(!success_receipt.exists());
assert!(!tensor_receipt.exists());
}
#[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 qwen38_official_config_detects_dense_qwen35() {
let config: serde_json::Value =
serde_json::from_str(include_str!("../../tests/fixtures/qwen38/config.json"))
.expect("official Qwen3.8 config fixture");
assert_eq!(detect_arch(&config).expect("detect"), ArchName::Qwen35);
let text_only = serde_json::json!({
"model_type": "qwen3_5_text",
"architectures": ["Qwen3_5ForCausalLM"]
});
assert_eq!(
detect_arch(&text_only).expect("detect text-only"),
ArchName::Qwen35
);
}
#[test]
fn qwen38_dense_context_rejects_zero_linear_dimensions() {
let mut config: serde_json::Value =
serde_json::from_str(include_str!("../../tests/fixtures/qwen38/config.json"))
.expect("official Qwen3.8 config fixture");
config["text_config"]["linear_key_head_dim"] = serde_json::json!(0);
assert!(build_qwen35_dense_ctx(&config).is_none());
config["text_config"]["linear_key_head_dim"] = serde_json::json!(128);
config["text_config"]["linear_num_key_heads"] = serde_json::json!(0);
assert!(build_qwen35_dense_ctx(&config).is_none());
}
#[test]
fn qwen38_dense_context_rejects_an_irregular_layer_schedule() {
let mut config: serde_json::Value =
serde_json::from_str(include_str!("../../tests/fixtures/qwen38/config.json"))
.expect("official Qwen3.8 config fixture");
config["text_config"]["layer_types"] =
serde_json::Value::Array(vec![serde_json::json!("full_attention"); 64]);
assert!(build_qwen35_dense_ctx(&config).is_none());
config["text_config"]["layer_types"] = serde_json::json!("linear_attention");
assert!(build_qwen35_dense_ctx(&config).is_none());
config["text_config"]
.as_object_mut()
.unwrap()
.remove("layer_types");
config["text_config"]["full_attention_interval"] = serde_json::json!("4");
assert!(build_qwen35_dense_ctx(&config).is_none());
}
#[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(GgufFtype::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", "qwen35", "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.config,
source.tensor_metas(),
&[],
) {
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(GgufFtype::MostlyQ2_K_S),
output: output.clone(),
dry_run: false,
imatrix: None,
imatrix_corpus: None,
imatrix_out: None,
imatrix_n_ctx: None,
mode: ConvertMode::TextOnly,
remote_source: Some(remote_source("deepseek-ai/DeepSeek-V4-Flash-0731")),
})
.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 = format!("hf2q {}", env!("CARGO_PKG_VERSION"));
assert!(bytes
.windows(producer.len())
.any(|window| window == producer.as_bytes()));
let expected_source_sha = "b".repeat(64);
assert!(bytes
.windows(expected_source_sha.len())
.any(|window| window == expected_source_sha.as_bytes()));
let receipt_path = crate::convert::receipt::receipt_path(&output);
let receipt: crate::convert::receipt::ConversionReceipt =
serde_json::from_slice(&std::fs::read(&receipt_path).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);
}
}