use anyhow::{Context, Result};
use mlx_native::gguf::GgufFile;
use mlx_native::MlxDevice;
use tokenizers::Tokenizer;
use crate::inference::glp::bind::BoundGlp;
use crate::inference::glp::reader::GlpVector;
use crate::inference::models::qwen35::kv_cache::HybridKvCache;
use crate::inference::models::qwen35::model::Qwen35Model;
use crate::inference::models::qwen35::tokenizer as qwen35_tokenizer;
use crate::inference::models::qwen35::Qwen35Config;
use crate::serve::multi_seq_kv::SlotId;
use super::family::{
bound_glp_debug_line, CalibrationFamily, CaptureSite, CapturedState, FamilyFacts,
FAMILY_HOOK_SET,
};
const QWEN_GLP49_SPAN: (usize, usize) = (10, 58);
fn text_positions(seq_len: usize) -> Vec<i32> {
let mut flat = Vec::with_capacity(4 * seq_len);
for _ in 0..4 {
for i in 0..seq_len {
flat.push(i as i32);
}
}
flat
}
pub struct Qwen35Family {
model: Qwen35Model,
tokenizer: Tokenizer,
facts: FamilyFacts,
chat_template: String,
eos_token_ids: Vec<u32>,
add_bos_token: Option<u32>,
device: MlxDevice,
}
impl Qwen35Family {
pub(super) fn facts_from_gguf(gguf: &GgufFile) -> Result<FamilyFacts> {
let architecture = gguf
.metadata_string("general.architecture")
.filter(|value| !value.is_empty())
.context("Qwen calibration: missing general.architecture")?;
let config = Qwen35Model::load_config_only(gguf)
.context("load Qwen3.5/3.6/3.8 config for calibration")?;
Self::facts_from_config(architecture, &config)
}
fn facts_from_config(architecture: &str, config: &Qwen35Config) -> Result<FamilyFacts> {
let family: &'static str = match architecture {
"qwen35moe" => "qwen35moe",
_ => "qwen35",
};
let default_layer = QWEN_GLP49_SPAN.0 / 2 + QWEN_GLP49_SPAN.1 / 2;
let default_layer = default_layer
.max(1)
.min(config.num_hidden_layers.saturating_sub(1) as usize);
Ok(FamilyFacts {
family,
num_layers: config.num_hidden_layers,
hidden: config.hidden_size,
hc: None,
capture_sites: &[CaptureSite::ResidualStreamPostLayer],
default_hook: CaptureSite::ResidualStreamPostLayer.as_hook_point(),
default_derived_at: CaptureSite::ResidualStreamPostLayer.as_spec_str(),
default_layer,
})
}
pub fn load(gguf: &GgufFile) -> Result<Self> {
let architecture = gguf
.metadata_string("general.architecture")
.filter(|value| !value.is_empty())
.context("Qwen calibration: missing general.architecture")?;
let config = Qwen35Model::load_config_only(gguf)
.context("load Qwen3.5/3.6/3.8 config for calibration")?;
let facts = Self::facts_from_config(architecture, &config)?;
let tokenizer = qwen35_tokenizer::build_tokenizer_from_gguf(gguf)
.context("build Qwen tokenizer from model GGUF")?;
let mut progress =
crate::serve::header::LoadProgress::new(false, 1, config.num_hidden_layers as usize);
let model = Qwen35Model::load_from_gguf(gguf, &mut progress)
.context("load Qwen3.5/3.6/3.8 model for calibration")?;
let device = MlxDevice::new().context("MlxDevice for Qwen calibration")?;
let chat_template = gguf
.metadata_string("tokenizer.chat_template")
.unwrap_or(crate::core::chat_templates::QWEN3_CHATML)
.to_string();
let mut eos_token_ids: Vec<u32> = Vec::new();
for key in ["tokenizer.ggml.eos_token_id", "tokenizer.ggml.eot_token_id"] {
if let Some(id) = gguf.metadata_u32(key) {
if !eos_token_ids.contains(&id) {
eos_token_ids.push(id);
}
}
}
if let Some(mlx_native::gguf::MetadataValue::Array(elems)) =
gguf.metadata("tokenizer.ggml.tokens")
{
for (index, element) in elems.iter().enumerate() {
if let mlx_native::gguf::MetadataValue::String(text) = element {
if text == "<|im_end|>" || text == "<|endoftext|>" {
let id = index as u32;
if !eos_token_ids.contains(&id) {
eos_token_ids.push(id);
}
}
}
}
}
if eos_token_ids.is_empty() {
eos_token_ids.push(151_645);
}
let add_bos_token = match gguf.metadata("tokenizer.ggml.add_bos_token") {
Some(mlx_native::gguf::MetadataValue::Bool(true)) => gguf
.metadata_u32("tokenizer.ggml.bos_token_id")
.filter(|id| tokenizer.id_to_token(*id).is_some()),
_ => None,
};
Ok(Self {
model,
tokenizer,
facts,
chat_template,
eos_token_ids,
add_bos_token,
device,
})
}
fn tokenize_rendered(&self, rendered: &str) -> Result<Vec<u32>> {
let encoding = self
.tokenizer
.encode(rendered, false)
.map_err(|error| anyhow::anyhow!("Qwen calibration tokenize: {error}"))?;
let mut tokens = encoding.get_ids().to_vec();
if let Some(bos) = self.add_bos_token {
if tokens.first() != Some(&bos) {
tokens.insert(0, bos);
}
}
Ok(tokens)
}
}
impl CalibrationFamily for Qwen35Family {
fn facts(&self) -> &FamilyFacts {
&self.facts
}
fn encode(&mut self, text: &str) -> Result<Vec<u32>> {
let encoding = self
.tokenizer
.encode(text, true)
.map_err(|error| anyhow::anyhow!("Qwen calibration tokenize: {error}"))?;
Ok(encoding.get_ids().to_vec())
}
fn capture(
&mut self,
tokens: &[u32],
site: CaptureSite,
layer: usize,
) -> Result<CapturedState> {
self.facts.ensure_capture_site(site)?;
anyhow::ensure!(!tokens.is_empty(), "Qwen calibration prompt is empty");
let seq_len = tokens.len() as u32;
let positions = text_positions(tokens.len());
let mut cache = HybridKvCache::new(&self.model.cfg, &self.device, seq_len.max(1), 1)
.context("allocate Qwen calibration cache")?;
let row = self
.model
.forward_gpu_capture_residual_layer(tokens, &positions, &mut cache, layer, SlotId(0))
.context("Qwen calibration residual capture")?;
Ok(CapturedState::Dense(row))
}
fn probe_logits(&mut self, tokens: &[u32]) -> Result<Vec<f32>> {
anyhow::ensure!(!tokens.is_empty(), "Qwen calibration probe is empty");
let positions = text_positions(tokens.len());
let mut cache = HybridKvCache::new(&self.model.cfg, &self.device, tokens.len() as u32, 1)
.context("allocate Qwen probe cache")?;
self.model
.forward_gpu_last_logits(tokens, &positions, &mut cache, SlotId(0))
.context("Qwen calibration probe prefill")
}
fn bind_glp(&mut self, vector: GlpVector, alpha: Option<f32>) -> Result<()> {
let bound = BoundGlp::bind(
vector,
alpha,
&self.device,
&FAMILY_HOOK_SET,
self.facts.num_layers,
self.facts.hidden,
)
.map_err(|error| anyhow::anyhow!("Qwen calibration GLP bind: {error}"))?;
eprintln!("{}", bound_glp_debug_line(&bound));
self.model.glp = Some(bound);
Ok(())
}
fn clear_glp(&mut self) {
self.model.glp = None;
}
fn generate_openings(&mut self, probes: &[String], max_tokens: usize) -> Result<Vec<String>> {
anyhow::ensure!(!probes.is_empty(), "register probe set is empty");
let mut openings = Vec::with_capacity(probes.len());
for (index, probe) in probes.iter().enumerate() {
let rendered =
crate::serve::render_jinja_template(&self.chat_template, probe, Some(false))
.with_context(|| format!("render Qwen register probe {index}"))?;
let prompt = self.tokenize_rendered(&rendered)?;
anyhow::ensure!(
!prompt.is_empty(),
"Qwen register probe encoded to zero tokens"
);
let prompt_len = prompt.len();
let max_seq = (prompt_len + max_tokens + 64)
.max(128)
.min(self.model.cfg.max_position_embeddings as usize);
let positions = text_positions(prompt_len);
let mut cache = HybridKvCache::new(&self.model.cfg, &self.device, max_seq as u32, 1)
.context("allocate Qwen register-probe cache")?;
let logits = self
.model
.forward_gpu_last_logits(&prompt, &positions, &mut cache, SlotId(0))
.context("Qwen register-probe prefill")?;
let mut tokens: Vec<u32> = Vec::with_capacity(max_tokens);
let mut next = crate::inference::models::qwen35::io_heads::greedy_argmax_last_token(
&logits,
self.model.cfg.vocab_size,
);
let mut pos = prompt_len as i32;
for _ in 0..max_tokens {
if self.eos_token_ids.contains(&next) {
break;
}
tokens.push(next);
if tokens.len() >= max_tokens {
break;
}
next = self
.model
.forward_gpu_greedy(&[next], &[pos; 4], &mut cache, SlotId(0))
.with_context(|| format!("Qwen register-probe decode at pos {pos}"))?;
pos += 1;
}
let text = self.tokenizer.decode(&tokens, false).unwrap_or_default();
openings.push(text);
}
Ok(openings)
}
}