use frink_gguf::{ShardedGguf, TensorSource};
use crate::loader::LoadError;
pub const NEXTN_READERS: &[(&str, &str)] = &[
("bailingmoe2", "src/models/bailingmoe2.cpp"),
("cohere2moe", "src/models/cohere2moe.cpp"),
("deepseek2", "src/models/deepseek2.cpp"),
("deepseek32", "src/models/deepseek32.cpp"),
("deepseek4", "src/models/deepseek4.cpp"),
("exaone-moe", "src/models/exaone-moe.cpp:23"),
("exaone4", "src/models/exaone4.cpp:18"),
("gemma4-assistant", "src/models/gemma4-assistant.cpp"),
("glm-dsa", "src/models/glm-dsa.cpp"),
("glm4moe", "src/models/glm4-moe.cpp"),
("glm4", "src/models/glm4.cpp"),
("hy-v3", "src/models/hy-v3.cpp"),
("mimo2", "src/models/mimo2.cpp:19"),
("qwen35", "src/models/qwen35.cpp"),
("qwen35moe", "src/models/qwen35moe.cpp"),
("qwen3next", "src/models/qwen3next.cpp"),
("step35", "src/models/step35.cpp:32"),
];
pub fn reads_nextn(arch: &str) -> bool {
NEXTN_READERS.iter().any(|(a, _)| *a == arch)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TrunkLayers {
pub block_count: usize,
pub n_layers: usize,
pub n_mtp_blocks: usize,
}
impl TrunkLayers {
pub fn all(block_count: usize) -> Self {
Self {
block_count,
n_layers: block_count,
n_mtp_blocks: 0,
}
}
}
pub fn trunk_layers(
file: &impl TensorSource,
arch: &str,
block_count: usize,
) -> Result<TrunkLayers, LoadError> {
let key = format!("{arch}.nextn_predict_layers");
let n_mtp_blocks = match file.metadata(&key) {
None => 0,
Some(v) => v.as_u64().ok_or_else(|| {
LoadError::UnsupportedFeature(
arch.to_string(),
format!("{key} is not an unsigned integer: {v:?}"),
)
})? as usize,
};
if n_mtp_blocks == 0 {
return Ok(TrunkLayers::all(block_count));
}
if !reads_nextn(arch) {
return Err(LoadError::UnsupportedFeature(
arch.to_string(),
format!(
"NextN/MTP prediction layers are counted in block_count and llama.cpp skips \
them (n_layer = n_layer_all - n_layer_nextn) only for the graphs that read \
the key; `{arch}` is not one of them (metadata {key}={n_mtp_blocks}), so \
upstream would run every block and fail on the unread `nextn.*` tensors, \
and the generic decoder would run the speculative head as ordinary \
decoder layers"
),
));
}
if n_mtp_blocks >= block_count {
return Err(LoadError::UnsupportedFeature(
arch.to_string(),
format!(
"{key}={n_mtp_blocks} is not below block_count={block_count}; llama.cpp \
asserts `n_layer_nextn < n_layer_all` and aborts on this file"
),
));
}
Ok(TrunkLayers {
block_count,
n_layers: block_count - n_mtp_blocks,
n_mtp_blocks,
})
}
pub fn is_mtp_block_tensor(name: &str, trunk: &TrunkLayers) -> bool {
let Some(rest) = name.strip_prefix("blk.") else {
return false;
};
let Some((idx, _)) = rest.split_once('.') else {
return false;
};
idx.parse::<usize>()
.is_ok_and(|n| n >= trunk.n_layers && n < trunk.block_count)
}
pub fn note_mtp_blocks_skipped(file: &ShardedGguf, trunk: &TrunkLayers) -> usize {
if trunk.n_mtp_blocks == 0 {
return 0;
}
let mut marked = 0;
for (_, info) in file.tensors() {
if is_mtp_block_tensor(&info.name, trunk) {
file.note_consumed(&info.name);
marked += 1;
}
}
marked
}
#[cfg(test)]
mod tests {
use super::*;
use frink_gguf::GgufValue;
use std::sync::Arc;
struct Meta(Vec<(String, GgufValue)>);
impl TensorSource for Meta {
fn metadata(&self, key: &str) -> Option<&GgufValue> {
self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
fn find_tensor(&self, _: &str) -> Option<&frink_gguf::TensorInfo> {
None
}
fn tensor_bytes(&self, name: &str) -> Result<&[u8], frink_gguf::GgufError> {
Err(frink_gguf::GgufError::TensorNotFound(name.to_string()))
}
fn tensor_mapped_range(
&self,
name: &str,
) -> Result<(Arc<frink_gguf::MmapHandle>, std::ops::Range<usize>), frink_gguf::GgufError>
{
Err(frink_gguf::GgufError::TensorNotFound(name.to_string()))
}
}
fn nextn(arch: &str, n: Option<u32>) -> Meta {
Meta(
n.into_iter()
.map(|n| (format!("{arch}.nextn_predict_layers"), GgufValue::U32(n)))
.collect(),
)
}
#[test]
fn a_reader_subtracts_the_blocks_from_its_layer_count() {
assert_eq!(
trunk_layers(&nextn("exaone-moe", Some(1)), "exaone-moe", 5).unwrap(),
TrunkLayers {
block_count: 5,
n_layers: 4,
n_mtp_blocks: 1
}
);
assert_eq!(
trunk_layers(&nextn("mimo2", Some(3)), "mimo2", 51).unwrap(),
TrunkLayers {
block_count: 51,
n_layers: 48,
n_mtp_blocks: 3
}
);
for file in [nextn("exaone-moe", None), nextn("exaone-moe", Some(0))] {
assert_eq!(
trunk_layers(&file, "exaone-moe", 4).unwrap(),
TrunkLayers::all(4)
);
}
}
#[test]
fn a_non_reader_with_a_nonzero_count_is_refused_and_zero_is_not() {
match trunk_layers(&nextn("grok", Some(1)), "grok", 4) {
Err(LoadError::UnsupportedFeature(arch, msg)) => {
assert_eq!(arch, "grok");
assert!(msg.contains("NextN/MTP"), "{msg}");
assert!(msg.contains("grok.nextn_predict_layers=1"), "{msg}");
}
other => panic!("expected a refusal, got {other:?}"),
}
assert_eq!(
trunk_layers(&nextn("grok", Some(0)), "grok", 4).unwrap(),
TrunkLayers::all(4)
);
}
#[test]
fn a_count_not_below_block_count_is_refused() {
for n in [4, 5] {
assert!(matches!(
trunk_layers(&nextn("mimo2", Some(n)), "mimo2", 4),
Err(LoadError::UnsupportedFeature(a, m)) if a == "mimo2" && m.contains("n_layer_nextn < n_layer_all")
));
}
}
#[test]
fn the_reader_census_is_the_predicate() {
for (arch, _) in NEXTN_READERS {
assert!(reads_nextn(arch), "{arch}");
}
for arch in [
"mimo2",
"step35",
"exaone4",
"exaone-moe",
"glm4moe",
"deepseek2",
] {
assert!(reads_nextn(arch), "{arch}");
}
for arch in ["llama", "grok", "gemma4", "qwen3moe", "mistral4"] {
assert!(!reads_nextn(arch), "{arch}");
}
assert_eq!(NEXTN_READERS.len(), 17, "measured on 2026-09-11");
}
#[test]
fn only_tensors_of_the_skipped_blocks_are_mtp_tensors() {
let trunk = TrunkLayers {
block_count: 6,
n_layers: 4,
n_mtp_blocks: 2,
};
assert!(!is_mtp_block_tensor("blk.3.attn_norm.weight", &trunk));
assert!(is_mtp_block_tensor("blk.4.attn_norm.weight", &trunk));
assert!(is_mtp_block_tensor("blk.4.nextn.eh_proj.weight", &trunk));
assert!(is_mtp_block_tensor("blk.5.ffn_down_exps.weight", &trunk));
assert!(!is_mtp_block_tensor("blk.6.attn_norm.weight", &trunk));
assert!(!is_mtp_block_tensor("output.weight", &trunk));
assert!(!is_mtp_block_tensor("blk.x.attn_norm.weight", &trunk));
assert!(!is_mtp_block_tensor("blk.4", &trunk));
}
}