pub(crate) fn infer_num_layers_from_tensor_names(names: &[&str]) -> Option<usize> {
let mut max_idx: Option<usize> = None;
for name in names {
let stripped = name
.strip_prefix("blk.")
.or_else(|| name.strip_prefix("model.layers."));
if let Some(rest) = stripped {
if let Some(dot) = rest.find('.') {
if let Ok(idx) = rest[..dot].parse::<usize>() {
max_idx = Some(max_idx.map_or(idx, |m| m.max(idx)));
}
}
}
}
max_idx.map(|i| i + 1)
}
fn missing_dim_err(field: &str) -> AprenderError {
AprenderError::FormatError {
message: format!(
"C-07: {field} required for GGUF export (missing in APR metadata). \
Re-stamp the APR file with `apr stamp` populating model dimensions, \
or convert from the original GGUF/SafeTensors source."
),
}
}
fn missing_num_heads_err() -> AprenderError {
AprenderError::FormatError {
message:
"C-07: num_heads required for GGUF export and NOT inferable from tensor shapes \
alone — q_dim = num_heads × head_dim has no unique factorization without head_dim \
(a 1536-wide projection is 12×128 OR 24×64 OR 16×96, all valid). \
To supply the missing dimension, populate the APR metadata header so the export \
can derive it exactly: set an explicit `head_dim` (then num_heads = q_dim / head_dim, \
num_kv_heads = kv_dim / head_dim) or an explicit `num_heads`/`num_kv_heads` via \
`apr stamp`, or re-`apr convert` from the original GGUF/SafeTensors source whose \
config.json carries head_dim / num_attention_heads. \
Refusing to guess head_dim (the old [64,128,96,80] first-divisor guess silently \
mis-stamped models like Qwen2-1.5B as 24 heads instead of 12 — a wrong-but-valid \
GGUF is worse than an honest failure)."
.to_string(),
}
}
fn projection_dim_from_shapes(
reader: &crate::format::v2::AprV2Reader,
name_patterns: &[&str],
) -> Option<usize> {
for name in reader.tensor_names() {
if name_patterns.iter().any(|p| name.contains(p)) {
if let Some(entry) = reader.get_tensor(name) {
if entry.shape.len() == 2 {
return Some(entry.shape[0].min(entry.shape[1]));
}
}
}
}
None
}
fn infer_missing_gguf_dims_from_shapes(
reader: &crate::format::v2::AprV2Reader,
apr_metadata: &mut crate::format::v2::AprV2Metadata,
) {
fill_head_counts_from_explicit_head_dim(reader, apr_metadata);
let needs_shape_inference = apr_metadata.hidden_size.is_none()
|| apr_metadata.vocab_size.is_none()
|| apr_metadata.intermediate_size.is_none();
if !needs_shape_inference {
return;
}
let mut shape_map: BTreeMap<String, (Vec<f32>, Vec<usize>)> = BTreeMap::new();
for name in reader.tensor_names() {
if let Some(entry) = reader.get_tensor(name) {
shape_map.insert(name.to_string(), (Vec::new(), entry.shape.clone()));
}
}
let Some(inferred) = super::import::infer_model_config_from_tensors(&shape_map) else {
return;
};
if apr_metadata.hidden_size.is_none() {
if let Some(v) = inferred.hidden_size {
eprintln!("[PMAT-920] hidden_size missing from APR metadata — inferred {v} from embedding tensor shape");
apr_metadata.hidden_size = Some(v);
}
}
if apr_metadata.vocab_size.is_none() {
if let Some(v) = inferred.vocab_size {
eprintln!("[PMAT-920] vocab_size missing from APR metadata — inferred {v} from embedding tensor shape");
apr_metadata.vocab_size = Some(v);
}
}
if apr_metadata.intermediate_size.is_none() {
if let Some(v) = inferred.intermediate_size {
eprintln!("[PMAT-920] intermediate_size missing from APR metadata — inferred {v} from FFN tensor shapes");
apr_metadata.intermediate_size = Some(v);
}
}
}
fn fill_head_counts_from_explicit_head_dim(
reader: &crate::format::v2::AprV2Reader,
apr_metadata: &mut crate::format::v2::AprV2Metadata,
) {
if apr_metadata.num_heads.is_some() && apr_metadata.num_kv_heads.is_some() {
return;
}
let Some(head_dim) = apr_metadata.head_dim else {
return;
};
if head_dim == 0 {
return;
}
let q_dim =
projection_dim_from_shapes(reader, &["q_proj.weight", "query.weight", "attn_q.weight"]);
let kv_dim =
projection_dim_from_shapes(reader, &["k_proj.weight", "key.weight", "attn_k.weight"]);
if apr_metadata.num_heads.is_none() {
if let Some(q) = q_dim {
if q.is_multiple_of(head_dim) {
let n = q / head_dim;
eprintln!(
"[PMAT-920] num_heads missing — derived {n} = q_dim({q}) / explicit head_dim({head_dim})"
);
apr_metadata.num_heads = Some(n);
}
}
}
if apr_metadata.num_kv_heads.is_none() {
if let Some(kv) = kv_dim {
if kv.is_multiple_of(head_dim) {
let n = kv / head_dim;
eprintln!(
"[PMAT-920] num_kv_heads missing — derived {n} = kv_dim({kv}) / explicit head_dim({head_dim})"
);
apr_metadata.num_kv_heads = Some(n);
}
}
}
}
fn build_gguf_arch_metadata(
apr_metadata: &crate::format::v2::AprV2Metadata,
) -> Result<Vec<(String, crate::format::gguf::GgufValue)>> {
use crate::format::gguf::GgufValue;
let arch = resolve_architecture(apr_metadata);
let hidden_size = apr_metadata.hidden_size.ok_or_else(|| missing_dim_err("hidden_size"))?;
let num_layers = apr_metadata.num_layers.ok_or_else(|| missing_dim_err("num_layers"))?;
let num_heads = apr_metadata.num_heads.ok_or_else(missing_num_heads_err)?;
let num_kv_heads = apr_metadata.num_kv_heads.unwrap_or(num_heads);
let vocab_size = apr_metadata.vocab_size.ok_or_else(|| missing_dim_err("vocab_size"))?;
let intermediate_size =
apr_metadata.intermediate_size.ok_or_else(|| missing_dim_err("intermediate_size"))?;
let max_pos = apr_metadata.max_position_embeddings.unwrap_or(0);
let rope_theta = apr_metadata.rope_theta.unwrap_or_else(||
super::export::default_rope_theta_for_architecture(arch));
let rms_norm_eps = apr_metadata.rms_norm_eps.unwrap_or(1e-6);
let head_dim = if num_heads > 0 {
hidden_size / num_heads
} else {
0
};
let model_name = apr_metadata
.name
.clone()
.unwrap_or_else(|| "model".to_string());
let mut metadata = vec![
(
"general.architecture".to_string(),
GgufValue::String(arch.to_string()),
),
("general.name".to_string(), GgufValue::String(model_name)),
(
"general.quantization_version".to_string(),
GgufValue::Uint32(2),
),
("general.file_type".to_string(), GgufValue::Uint32(0)),
(
format!("{arch}.context_length"),
GgufValue::Uint32(max_pos as u32),
),
(
format!("{arch}.embedding_length"),
GgufValue::Uint32(hidden_size as u32),
),
(
format!("{arch}.block_count"),
GgufValue::Uint32(num_layers as u32),
),
(
format!("{arch}.feed_forward_length"),
GgufValue::Uint32(intermediate_size as u32),
),
(
format!("{arch}.attention.head_count"),
GgufValue::Uint32(num_heads as u32),
),
(
format!("{arch}.attention.head_count_kv"),
GgufValue::Uint32(num_kv_heads as u32),
),
];
if arch == "gpt2" {
metadata.push((
format!("{arch}.attention.layer_norm_epsilon"),
GgufValue::Float32(rms_norm_eps),
));
} else {
metadata.push((
format!("{arch}.attention.layer_norm_rms_epsilon"),
GgufValue::Float32(rms_norm_eps),
));
}
if uses_rope(arch) {
metadata.push((
format!("{arch}.rope.dimension_count"),
GgufValue::Uint32(head_dim as u32),
));
metadata.push((
format!("{arch}.rope.freq_base"),
GgufValue::Float32(rope_theta),
));
}
metadata.push((
format!("{arch}.vocab_size"),
GgufValue::Uint32(vocab_size as u32),
));
Ok(metadata)
}
fn push_string_array(
entries: &mut Vec<(String, crate::format::gguf::GgufValue)>,
custom: &std::collections::HashMap<String, serde_json::Value>,
src_key: &str,
gguf_key: &str,
) {
let arr = custom.get(src_key).and_then(|v| v.as_array());
let Some(arr) = arr else { return };
let strings: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
if !strings.is_empty() {
entries.push((
gguf_key.to_string(),
crate::format::gguf::GgufValue::ArrayString(strings),
));
}
}
fn push_u32_field(
entries: &mut Vec<(String, crate::format::gguf::GgufValue)>,
custom: &std::collections::HashMap<String, serde_json::Value>,
src_key: &str,
gguf_key: &str,
) {
if let Some(val) = custom.get(src_key).and_then(|v| v.as_u64()) {
entries.push((
gguf_key.to_string(),
crate::format::gguf::GgufValue::Uint32(val as u32),
));
}
}
fn push_i32_array(
entries: &mut Vec<(String, crate::format::gguf::GgufValue)>,
custom: &std::collections::HashMap<String, serde_json::Value>,
src_key: &str,
gguf_key: &str,
) {
let arr = custom.get(src_key).and_then(|v| v.as_array());
let Some(arr) = arr else { return };
let types: Vec<i32> = arr
.iter()
.filter_map(|v| v.as_i64().map(|n| n as i32))
.collect();
if !types.is_empty() {
entries.push((
gguf_key.to_string(),
crate::format::gguf::GgufValue::ArrayInt32(types),
));
}
}
fn extract_apr_tokenizer_for_gguf(
apr_metadata: &crate::format::v2::AprV2Metadata,
vocab_size: usize,
) -> Vec<(String, crate::format::gguf::GgufValue)> {
use crate::format::gguf::GgufValue;
let mut entries = Vec::new();
let custom = &apr_metadata.custom;
let arch = resolve_architecture(apr_metadata);
let raw_model_type = custom
.get("tokenizer.model")
.and_then(|v| v.as_str())
.unwrap_or("gpt2");
let model_type = match raw_model_type {
"bpe" => "gpt2",
other => other,
};
entries.push((
"tokenizer.ggml.model".to_string(),
GgufValue::String(model_type.to_string()),
));
let model_name = apr_metadata.name.as_deref().unwrap_or("");
let pre_type = custom
.get("tokenizer.pre_type")
.and_then(|v| v.as_str())
.unwrap_or_else(|| resolve_pre_tokenizer_type(arch, model_name));
entries.push((
"tokenizer.ggml.pre".to_string(),
GgufValue::String(pre_type.to_string()),
));
push_string_array(
&mut entries,
custom,
"tokenizer.vocabulary",
"tokenizer.ggml.tokens",
);
if vocab_size > 0 {
if let Some(tokens) = entries.iter_mut().find_map(|(k, v)| match v {
GgufValue::ArrayString(arr) if k == "tokenizer.ggml.tokens" => Some(arr),
_ => None,
}) {
if tokens.len() < vocab_size {
let pad_count = vocab_size - tokens.len();
eprintln!(
"[P0-G] Padding APR-fallback tokenizer.ggml.tokens: {} + {} placeholders = {}",
tokens.len(),
pad_count,
vocab_size
);
for i in tokens.len()..vocab_size {
tokens.push(format!("<|pad_{i}|>"));
}
}
}
}
push_string_array(
&mut entries,
custom,
"tokenizer.merges",
"tokenizer.ggml.merges",
);
push_u32_field(
&mut entries,
custom,
"tokenizer.bos_token_id",
"tokenizer.ggml.bos_token_id",
);
push_u32_field(
&mut entries,
custom,
"tokenizer.eos_token_id",
"tokenizer.ggml.eos_token_id",
);
push_i32_array(
&mut entries,
custom,
"tokenizer.token_type",
"tokenizer.ggml.token_type",
);
push_u32_field(
&mut entries,
custom,
"tokenizer.padding_token_id",
"tokenizer.ggml.padding_token_id",
);
if let Some(add_bos) = custom
.get("tokenizer.add_bos_token")
.and_then(|v| v.as_bool())
{
entries.push((
"tokenizer.ggml.add_bos_token".to_string(),
GgufValue::Bool(add_bos),
));
}
let chat_tmpl = apr_metadata.chat_template.as_deref().or_else(|| {
custom
.get("tokenizer.chat_template")
.and_then(|v| v.as_str())
});
if let Some(tmpl) = chat_tmpl {
entries.push((
"tokenizer.chat_template".to_string(),
GgufValue::String(tmpl.to_string()),
));
}
entries
}
fn export_mlx(
tensors: &BTreeMap<String, (Vec<f32>, Vec<usize>)>,
input_path: &Path,
output_path: &Path,
options: &ExportOptions,
) -> Result<()> {
fs::create_dir_all(output_path).map_err(|e| AprenderError::FormatError {
message: format!("Failed to create MLX output directory: {e}"),
})?;
let weights_path = output_path.join("model.safetensors");
let user_metadata = extract_user_metadata(input_path);
if user_metadata.is_empty() {
save_safetensors(&weights_path, tensors).map_err(|e| AprenderError::FormatError {
message: format!("Failed to write MLX weights: {e}"),
})?;
} else {
save_safetensors_with_metadata(&weights_path, tensors, &user_metadata).map_err(|e| {
AprenderError::FormatError {
message: format!("Failed to write MLX weights: {e}"),
}
})?;
}
let config = infer_model_config(tensors);
let config_path = output_path.join("config.json");
fs::write(&config_path, config).map_err(|e| AprenderError::FormatError {
message: format!("Failed to write MLX config.json: {e}"),
})?;
if options.include_tokenizer {
let tokenizer_json = infer_tokenizer_json(input_path);
if !tokenizer_json.is_empty() {
let tokenizer_path = output_path.join("tokenizer.json");
if let Err(e) = fs::write(&tokenizer_path, &tokenizer_json) {
eprintln!("[GH-246] Warning: Failed to write tokenizer.json: {e}");
}
}
}
Ok(())
}
fn export_apr_to_gguf_raw(input: &Path, output: &Path) -> Result<ExportReport> {
use crate::format::gguf::{export_tensors_to_gguf, GgmlType, GgufTensor};
use crate::format::v2::{AprV2Reader, TensorDType};
use std::fs::File;
use std::io::BufWriter;
let data = fs::read(input).map_err(|e| AprenderError::FormatError {
message: format!("Failed to read APR file: {e}"),
})?;
let original_size = data.len();
let reader = AprV2Reader::from_bytes(&data).map_err(|e| AprenderError::FormatError {
message: format!("Failed to parse APR file: {e:?}"),
})?;
let mut apr_metadata = reader.metadata().clone();
if apr_metadata.num_layers.is_none() {
let names = reader.tensor_names();
if let Some(inferred) = infer_num_layers_from_tensor_names(&names) {
eprintln!(
"[#1865] num_layers missing from APR metadata — inferred {} from blk.N.* tensor names",
inferred
);
apr_metadata.num_layers = Some(inferred);
}
}
infer_missing_gguf_dims_from_shapes(&reader, &mut apr_metadata);
let arch = resolve_architecture(&apr_metadata);
let num_layers = apr_metadata.num_layers.ok_or_else(|| missing_dim_err("num_layers"))?;
let num_heads = apr_metadata.num_heads.ok_or_else(missing_num_heads_err)?;
let num_kv_heads = apr_metadata.num_kv_heads.unwrap_or(num_heads);
let hidden_size = apr_metadata.hidden_size.ok_or_else(|| missing_dim_err("hidden_size"))?;
let mut metadata = build_gguf_arch_metadata(&apr_metadata)?;
let vocab_size = apr_metadata.vocab_size.unwrap_or(0);
metadata.extend(extract_apr_tokenizer_for_gguf(&apr_metadata, vocab_size));
let validated = ValidatedGgufMetadata::validate(metadata)?;
eprintln!(
"[PMAT-252] Writing {} metadata keys (arch={}, layers={}, heads={}/{}kv, hidden={})",
validated.as_slice().len(),
arch,
num_layers,
num_heads,
num_kv_heads,
hidden_size
);
let mapper = build_gguf_mapper(arch);
let tensor_names = reader.tensor_names();
let mut gguf_tensors = Vec::with_capacity(tensor_names.len());
for name in &tensor_names {
let Some(gguf_name) = mapper.map_name(name) else {
eprintln!("[GH-277] Skipping tensor '{}' (not in GGUF contract)", name);
continue;
};
let entry = reader
.get_tensor(name)
.ok_or_else(|| AprenderError::FormatError {
message: format!("Tensor '{}' missing from index", name),
})?;
let raw_bytes = reader
.get_tensor_data(name)
.ok_or_else(|| AprenderError::FormatError {
message: format!("Tensor '{}' data not found", name),
})?;
let gguf_dtype = match entry.dtype {
TensorDType::F32 => GgmlType::F32,
TensorDType::F16 => GgmlType::F16,
TensorDType::Q4K => GgmlType::Q4K,
TensorDType::Q6K => GgmlType::Q6K,
TensorDType::AprQ8 => {
return Err(AprenderError::FormatError {
message: format!(
"Tensor '{}' has dtype AprQ8 (APR-native single-scale 8-bit, \
NOT GGML Q8_0) which has no GGUF equivalent. \
Convert to F32/F16 first with `apr convert`.",
name
),
});
}
TensorDType::BF16 | TensorDType::F64 | TensorDType::I32
| TensorDType::I64 | TensorDType::I8 | TensorDType::U8
| TensorDType::AprQ4 => {
return Err(AprenderError::FormatError {
message: format!(
"Tensor '{}' has dtype {:?} which has no GGUF equivalent. \
Convert to F32/F16 first with `apr convert`.",
name, entry.dtype
),
});
}
};
let gguf_shape = if entry.shape.len() == 2 {
vec![entry.shape[1] as u64, entry.shape[0] as u64]
} else {
entry.shape.iter().map(|&d| d as u64).collect()
};
eprintln!(
"[PMAT-252] '{}': {} bytes (dtype={:?})",
gguf_name,
raw_bytes.len(),
entry.dtype
);
gguf_tensors.push(GgufTensor {
name: gguf_name,
shape: gguf_shape,
dtype: gguf_dtype,
data: raw_bytes.to_vec(),
});
}
let fused = build_fused_tensors_raw(&mapper, &reader);
gguf_tensors.extend(fused);
let file = File::create(output).map_err(|e| AprenderError::FormatError {
message: format!("Failed to create output file: {e}"),
})?;
let mut writer = BufWriter::new(file);
export_tensors_to_gguf(&mut writer, &gguf_tensors, validated.as_slice())?;
let exported_size = fs::metadata(output).map(|m| m.len() as usize).unwrap_or(0);
Ok(ExportReport {
original_size,
exported_size,
tensor_count: gguf_tensors.len(),
format: ExportFormat::Gguf,
quantization: Some(QuantizationType::Q4K),
})
}
#[cfg(test)]
fn hf_to_gguf_name(name: &str) -> String {
let mapper = build_legacy_mapper();
mapper.map_name(name).unwrap_or_else(|| name.to_string())
}