use std::path::{Path, PathBuf};
use thiserror::Error;
use mlx_native::gguf::GgufFile;
use mlx_native::MlxError;
use crate::inference::models::qwen35::forward_cpu::text_positions;
use crate::inference::models::qwen35::model::Qwen35Model;
use crate::quality::perplexity::{compute_perplexity, PerplexityError};
#[derive(Debug, Error)]
pub enum PplDriverError {
#[error("failed to read GGUF at {path}: {source}")]
Gguf {
path: PathBuf,
#[source]
source: MlxError,
},
#[error("model load failed: {0}")]
Load(String),
#[error("forward pass failed at chunk {chunk}: {cause}")]
Forward { chunk: usize, cause: String },
#[error("compute_perplexity failed: {0}")]
Perplexity(#[from] PerplexityError),
#[error("invalid input: {0}")]
Invalid(String),
}
pub fn chunk_count(n_tokens: usize, seq_len: usize) -> usize {
if n_tokens == 0 || seq_len == 0 {
return 0;
}
n_tokens.div_ceil(seq_len)
}
pub fn measure_ppl_qwen35(
model: &Path,
tokens: &[u32],
seq_len: Option<usize>,
) -> Result<f32, PplDriverError> {
if tokens.len() < 2 {
return Err(PplDriverError::Invalid(format!(
"tokens.len() = {} < 2; need at least one prediction (one logits row + one target) to compute PPL",
tokens.len()
)));
}
if let Some(0) = seq_len {
return Err(PplDriverError::Invalid(
"seq_len override cannot be 0; pass None for the model default or a positive value"
.to_string(),
));
}
let gguf = GgufFile::open(model).map_err(|source| PplDriverError::Gguf {
path: model.to_path_buf(),
source,
})?;
let mut progress = crate::serve::header::LoadProgress::new(false, 1, 0);
let qwen = Qwen35Model::load_from_gguf(&gguf, &mut progress)
.map_err(|e| PplDriverError::Load(format!("{e:#}")))?;
let vocab_size = qwen.cfg.vocab_size as usize;
if vocab_size == 0 {
return Err(PplDriverError::Invalid(
"model.cfg.vocab_size is 0; cannot reshape logits".to_string(),
));
}
let n_tokens = tokens.len();
let effective_seq_len = match seq_len {
Some(n) => n,
None => {
let ctx = qwen.cfg.max_position_embeddings as usize;
if ctx == 0 {
n_tokens
} else {
ctx.min(n_tokens)
}
}
};
if effective_seq_len == 0 {
return Err(PplDriverError::Invalid(
"resolved effective seq_len is 0".to_string(),
));
}
let total_chunks = chunk_count(n_tokens, effective_seq_len);
debug_assert!(
total_chunks >= 1,
"n_tokens >= 2 and seq_len >= 1 ⇒ at least one chunk"
);
let pairs_capacity = n_tokens.saturating_sub(total_chunks);
let mut all_logits: Vec<Vec<f32>> = Vec::with_capacity(pairs_capacity);
let mut all_targets: Vec<u32> = Vec::with_capacity(pairs_capacity);
for chunk_idx in 0..total_chunks {
let start = chunk_idx * effective_seq_len;
let end = (start + effective_seq_len).min(n_tokens);
if start >= end {
continue;
}
let window = &tokens[start..end];
let window_len = window.len();
if window_len < 2 {
continue;
}
let positions = text_positions(window_len as u32);
let chunk_logits =
qwen.forward_cpu(window, &positions)
.map_err(|e| PplDriverError::Forward {
chunk: chunk_idx,
cause: format!("{e:#}"),
})?;
let expected_logits_len = window_len * vocab_size;
if chunk_logits.len() != expected_logits_len {
return Err(PplDriverError::Forward {
chunk: chunk_idx,
cause: format!(
"forward_cpu returned {} logits; expected {} ({} tokens × {} vocab)",
chunk_logits.len(),
expected_logits_len,
window_len,
vocab_size,
),
});
}
for row_idx in 0..window_len - 1 {
let row_start = row_idx * vocab_size;
let row_end = row_start + vocab_size;
all_logits.push(chunk_logits[row_start..row_end].to_vec());
all_targets.push(tokens[start + row_idx + 1]);
}
drop(chunk_logits);
}
if all_logits.is_empty() {
return Err(PplDriverError::Invalid(
"no (logits, target) pairs produced from corpus + windowing; \
tokens.len() may be smaller than expected for the chosen seq_len"
.to_string(),
));
}
debug_assert_eq!(
all_logits.len(),
all_targets.len(),
"internal invariant: logits and targets must be the same length"
);
let ppl_f64 = compute_perplexity(&all_logits, &all_targets)?;
Ok(ppl_f64 as f32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chunk_count_zero_n_tokens_is_zero() {
assert_eq!(chunk_count(0, 4), 0);
}
#[test]
fn chunk_count_zero_seq_len_is_zero() {
assert_eq!(chunk_count(100, 0), 0);
}
#[test]
fn chunk_count_exact_multiple() {
assert_eq!(chunk_count(512, 128), 4);
}
#[test]
fn chunk_count_rounds_up_partial_window() {
assert_eq!(chunk_count(513, 128), 5);
}
#[test]
fn chunk_count_corpus_smaller_than_window() {
assert_eq!(chunk_count(100, 1024), 1);
}
#[test]
fn chunk_count_window_one() {
assert_eq!(chunk_count(7, 1), 7);
}
#[test]
fn measure_ppl_returns_invalid_on_short_input() {
let result = measure_ppl_qwen35(std::path::Path::new("/nonexistent/model.gguf"), &[], None);
assert!(matches!(result, Err(PplDriverError::Invalid(_))));
let result = measure_ppl_qwen35(
std::path::Path::new("/nonexistent/model.gguf"),
&[42u32],
None,
);
assert!(matches!(result, Err(PplDriverError::Invalid(_))));
}
#[test]
fn measure_ppl_returns_invalid_on_zero_seq_len_override() {
let result = measure_ppl_qwen35(
std::path::Path::new("/nonexistent/model.gguf"),
&[1u32, 2, 3, 4],
Some(0),
);
match result {
Err(PplDriverError::Invalid(msg)) => assert!(msg.contains("seq_len")),
other => panic!("expected Invalid(seq_len ...), got {other:?}"),
}
}
#[test]
fn measure_ppl_returns_gguf_on_missing_path() {
let missing = std::path::Path::new("/nonexistent/path/that/cannot/exist/qwen35-dense.gguf");
let result = measure_ppl_qwen35(missing, &[1u32, 2, 3, 4], Some(2));
match result {
Err(PplDriverError::Gguf { path, source: _ }) => {
assert_eq!(path, missing.to_path_buf());
}
other => panic!("expected Gguf {{ ... }}, got {other:?}"),
}
}
}