hf2q 0.1.3

Pure Rust CLI for converting HuggingFace models to hardware-optimized formats and serving them over an OpenAI-compatible API on Apple Silicon
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
//! ADR-014 P10 iter-2c — hf2q-side PPL driver for Qwen3.5 GGUFs.
//!
//! Wraps the EXISTING public API of [`Qwen35Model`] (`load_from_gguf`
//! plus `forward_cpu`) and routes the produced logits through
//! [`crate::quality::perplexity::compute_perplexity`]. The driver lives
//! in `src/quality/` (not `src/inference/models/qwen35/`) so the
//! parallel ADR-013 session that owns the engine can keep its file
//! fence intact: ppl_driver only READS the engine's public API; never
//! modifies it.
//!
//! # Variant-agnostic by construction
//!
//! Iter-2c (rename + wire) renamed `measure_ppl_qwen35_dense` →
//! [`measure_ppl_qwen35`] after a Chesterton's-fence audit confirmed
//! the dense-only gate at iter-2b was a forward-looking guard, NOT a
//! correctness invariant. The driver's API surface
//! (`Path → tokens → f32 PPL`) is variant-agnostic because:
//!
//! 1. [`Qwen35Model::load_from_gguf`]
//!    (`src/inference/models/qwen35/model.rs:172`) inspects
//!    `cfg.variant` (`model.rs:265`) and constructs the appropriate
//!    layer weights internally — `Variant::Moe` ⇒
//!    `load_moe_ffn_quantized` returning `Qwen35FfnWeights::MoeQ`
//!    (`model.rs:282-307`); `Variant::Dense` ⇒ `load_layer`
//!    (`model.rs:308-311`).
//! 2. [`Qwen35Model::forward_cpu`]
//!    (`src/inference/models/qwen35/forward_cpu.rs:75`) dispatches on
//!    `Qwen35FfnWeights::{Dense, Moe, DenseQ, MoeQ}` (`forward_cpu.rs:152-192`)
//!    inside the per-layer FFN block — a single function handles
//!    every variant the GGUF loader can produce.
//!
//! Therefore one driver entry point handles both `Variant::Dense` and
//! `Variant::Moe` GGUFs. Quantized-FFN GGUFs (`DenseQ` and `MoeQ`,
//! both produced by `load_from_gguf` for production GGUFs) error
//! honestly through `forward_cpu` regardless of variant; the driver
//! surfaces those as [`PplDriverError::Forward`] without silently
//! falling back. P11 wires `forward_gpu` for the real-model path.
//!
//! # Public surface
//!
//! - [`measure_ppl_qwen35`] — the entry point (variant-agnostic).
//! - [`PplDriverError`] — the typed error enum.
//! - [`chunk_count`] — small pure helper exposed for unit tests in
//!   [`crate::quality::ppl_driver`] and in the integration crate at
//!   `tests/ppl_driver.rs`. Same arithmetic that drives the chunking
//!   loop, surfaced so the integration tests can assert it without
//!   loading a model.
//!
//! # Algorithm
//!
//! 1. `GgufFile::open(model)` → typed `Gguf` error if the path is
//!    missing, the magic is wrong, or the file is otherwise unreadable.
//! 2. `Qwen35Model::load_from_gguf(&gguf)` → typed `Load` error.
//! 3. Compute `seq_len = caller_override.unwrap_or(model.cfg.max_position_embeddings)`,
//!    clamped to `tokens.len()` so the largest possible single chunk
//!    is the whole corpus when the user doesn't pass an explicit
//!    window.
//! 4. Walk `tokens` in non-overlapping windows of `seq_len`. For each
//!    window:
//!    - Build text-convention `[i, i, i, i]` positions.
//!    - Call `forward_cpu` (typed `Forward { chunk, source }` error
//!      with the chunk index so a regression at, say, the 5th window
//!      surfaces with the right index in the message).
//!    - Reshape the returned `[L × vocab]` flat `Vec<f32>` into a
//!      `Vec<Vec<f32>>` of `L` rows.
//! 5. Drop the last row of every window's logits AND the first token
//!    of every window's targets. Standard "predict next token"
//!    alignment for PPL: `logits[i]` predicts `tokens[i+1]`. This also
//!    matches `compute_perplexity`'s contract that
//!    `logits_sequence.len() == targets.len()`.
//! 6. Concatenate all per-window `(logits, targets)` pairs into one
//!    `(Vec<Vec<f32>>, Vec<u32>)` and call `compute_perplexity`.
//! 7. Cast the resulting `f64` PPL to `f32` (the
//!    `tests/peer_parity_gates.rs` `CellResult::ppl_hf2q` field is
//!    `Option<f32>`; the f64 → f32 boundary lives here so the
//!    public driver type matches the consumer).
//!
//! # Determinism
//!
//! `forward_cpu` is deterministic for the same model + same tokens
//! (the engine's own `forward_cpu_deterministic` test pins this).
//! `compute_perplexity` is a pure reduction over the logits. Therefore
//! [`measure_ppl_qwen35`] is deterministic for the same model file +
//! same token slice + same `seq_len`.

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};

/// Errors surfaced by [`measure_ppl_qwen35`]. Every failure mode —
/// IO, GGUF parse, model load, forward pass, perplexity compute,
/// invalid input — lands in a discriminated variant so callers
/// (`tests/peer_parity_gates.rs::run_cell` for all 8 Decision-15
/// cells, both dense and MoE) can route on the cause without
/// inspecting strings.
#[derive(Debug, Error)]
pub enum PplDriverError {
    /// `GgufFile::open` failed (file missing, bad magic, truncated
    /// header, unsupported version, …). The `path` field carries the
    /// caller-supplied path verbatim so the markdown table / log line
    /// surfaces *which* GGUF triggered the failure.
    #[error("failed to read GGUF at {path}: {source}")]
    Gguf {
        path: PathBuf,
        #[source]
        source: MlxError,
    },

    /// `Qwen35Model::load_from_gguf` returned a non-Gguf error
    /// (config parse, unexpected dtype, weight-loader complaint).
    /// We carry the formatted message rather than the `anyhow::Error`
    /// so `PplDriverError` stays `Send + Sync + 'static` without
    /// transitively requiring the same of every internal source.
    #[error("model load failed: {0}")]
    Load(String),

    /// `forward_cpu` failed at chunk index `chunk` (0-indexed). The
    /// chunk index lets a regression at the Nth window surface in the
    /// error rather than blending into the first window's failure.
    /// `cause` carries the formatted source message — we render the
    /// `anyhow::Error` chain into a `String` at the catch site so
    /// `PplDriverError` stays `Send + Sync + 'static` without
    /// transitively requiring the same of the qwen35 forward path.
    #[error("forward pass failed at chunk {chunk}: {cause}")]
    Forward { chunk: usize, cause: String },

    /// `compute_perplexity` rejected the `(logits, targets)` pair
    /// (count mismatch, NaN logits, OOB target, empty input). Should
    /// be unreachable in production because the driver constructs the
    /// inputs itself; surfaced explicitly so a future regression
    /// can't be mistaken for a `Forward` or `Invalid` error.
    #[error("compute_perplexity failed: {0}")]
    Perplexity(#[from] PerplexityError),

    /// Caller-supplied input violates the driver's preconditions:
    /// fewer than 2 tokens (no prediction possible) or an explicit
    /// `seq_len = Some(0)`. As of iter-2c the variant gate (formerly
    /// rejecting `Variant::Moe`) is removed — the driver is
    /// variant-agnostic and dispatches both dense and MoE GGUFs
    /// through the same code path; see the module-level doc for the
    /// audit that established this.
    #[error("invalid input: {0}")]
    Invalid(String),
}

/// Number of non-overlapping `seq_len`-sized windows required to
/// cover `n_tokens`. Pure arithmetic — no model load. Used both
/// internally (the chunking loop driver) and by the integration test
/// crate at `tests/ppl_driver.rs` to lock the chunking math without
/// needing a model.
///
/// Returns 0 if either input is 0; otherwise rounds up
/// (`(n_tokens + seq_len - 1) / seq_len`).
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)
}

/// Measure the perplexity of a Qwen3.5 GGUF model against a `u32`
/// token corpus.
///
/// **Variant-agnostic**: handles both `Qwen35Variant::Dense` and
/// `Qwen35Variant::Moe`. [`Qwen35Model::load_from_gguf`]
/// (`src/inference/models/qwen35/model.rs:172`) inspects
/// `cfg.variant` (`model.rs:265`) and constructs the appropriate
/// per-layer FFN weights internally; [`Qwen35Model::forward_cpu`]
/// (`src/inference/models/qwen35/forward_cpu.rs:75`) dispatches on
/// `Qwen35FfnWeights::{Dense, Moe, DenseQ, MoeQ}` (`forward_cpu.rs:152-192`).
/// One driver, both variants — see the module-level doc for the
/// Chesterton's-fence audit that justified removing the iter-2b
/// `Variant::Dense`-only gate.
///
/// # Arguments
///
/// * `model` — path to a Qwen3.5 GGUF (architecture `qwen35`).
///   Quantized-FFN GGUFs (`DenseQ` for dense, `MoeQ` for MoE — what
///   real production GGUFs always load to) are loadable but
///   `forward_cpu` errors on them; that error flows through
///   [`PplDriverError::Forward`] and is surfaced honestly to the
///   caller (the driver does NOT silently fall back to a different
///   path). P11 wires `forward_gpu` for the real-model path.
/// * `tokens` — corpus tokens. Must contain at least 2 tokens
///   (one prediction = one logits row + one target).
/// * `seq_len` — optional override for the per-chunk window size.
///   `None` uses `cfg.max_position_embeddings` clamped to
///   `tokens.len()` (so a 512-token corpus + a 32k-context model
///   runs as a single chunk). Pass `Some(N)` to force `N`-token
///   windows (useful for tests with synthetic corpora < the model's
///   training context).
///
/// # Returns
///
/// The corpus perplexity as `f32`. The internal `compute_perplexity`
/// returns `f64`; we narrow at the boundary because
/// `tests/peer_parity_gates.rs::CellResult::ppl_hf2q` is
/// `Option<f32>`.
///
/// # Errors
///
/// See [`PplDriverError`]. Each variant identifies a distinct
/// failure surface so the caller can route without string-matching.
///
/// # Determinism
///
/// Same `model` + same `tokens` + same `seq_len` ⇒ identical f32
/// PPL bit pattern. Inherits determinism from `forward_cpu`
/// (validated by the engine's `forward_cpu_deterministic` test) and
/// the pure-reduction nature of `compute_perplexity`.
pub fn measure_ppl_qwen35(
    model: &Path,
    tokens: &[u32],
    seq_len: Option<usize>,
) -> Result<f32, PplDriverError> {
    // --- 1. Input validation (cheap, no IO) -----------------------
    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(),
        ));
    }

    // --- 2. Open the GGUF -----------------------------------------
    let gguf = GgufFile::open(model).map_err(|source| PplDriverError::Gguf {
        path: model.to_path_buf(),
        source,
    })?;

    // --- 3. Load the model ----------------------------------------
    //
    // Variant-agnostic: `Qwen35Model::load_from_gguf` inspects
    // `cfg.variant` and constructs the appropriate per-layer FFN
    // weights internally (model.rs:265-312). The iter-2b
    // `Variant::Dense`-only gate that lived here was removed at
    // iter-2c after the Chesterton's-fence audit confirmed it was a
    // forward-looking guard, not a correctness invariant — see the
    // module-level doc.
    // Silent progress: ppl_driver is a quality-eval batch tool; the
    // `\r`-overwrite progress line would interleave with the per-chunk
    // perplexity log. Mirrors the SERVE-side silent-progress pattern.
    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(),
        ));
    }

    // --- 4. Resolve effective seq_len ------------------------------
    // Default: model's training context, clamped down to the corpus
    // length so a small corpus runs as a single chunk. Caller
    // override wins outright (subject to the > 0 check above).
    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;
            // Defensive: if a GGUF reports max_position_embeddings = 0
            // (malformed metadata), fall back to the corpus length so
            // we still produce a single chunk — `forward_cpu` will
            // surface any actual capacity issue.
            if ctx == 0 {
                n_tokens
            } else {
                ctx.min(n_tokens)
            }
        }
    };
    if effective_seq_len == 0 {
        // Unreachable given the validations above (n_tokens >= 2 and
        // seq_len > 0 if Some), but the explicit check keeps the
        // chunking loop's invariant local.
        return Err(PplDriverError::Invalid(
            "resolved effective seq_len is 0".to_string(),
        ));
    }

    // --- 5. Chunked forward pass + logits accumulation -------------
    //
    // Standard non-overlapping PPL evaluation:
    //
    //   chunk i covers tokens[s..s+L] (s = i * effective_seq_len, L = chunk len)
    //   forward_cpu produces logits[0..L] of shape [L, vocab]
    //   prediction alignment: logits[j] predicts tokens[s + j + 1]
    //   ⇒ contributions = (logits[0..L-1], tokens[s+1..s+L])
    //
    // The very last token of the very last window has no target
    // (nothing comes after it), so we drop one row per window
    // including the last — this aligns with the standard convention
    // and matches `compute_perplexity`'s `logits.len() == targets.len()`
    // contract.
    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"
    );

    // Pre-allocate the accumulators once. Capacity = number of
    // (logits, target) PAIRS = n_tokens - total_chunks (we drop the
    // last row of every window's logits + the first token of every
    // window's targets).
    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;
        // Last chunk may be shorter than effective_seq_len when the
        // corpus length isn't a multiple of the window. We honor
        // forward_cpu's contract that tokens is non-empty by skipping
        // any chunk of length 0 (which can't happen with chunk_count
        // > 0 anyway, but is a defensive belt).
        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 {
            // A chunk with only 1 token contributes 0 prediction pairs.
            // `forward_cpu` accepts it (it requires non-empty input)
            // but the alignment step below would discard everything.
            // Skip the call entirely to avoid a spurious GPU dispatch
            // and to keep the loop invariant simple.
            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:#}"),
                })?;

        // Shape sanity. `forward_cpu` returns `[seq_len * vocab_size]`.
        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,
                ),
            });
        }

        // Reshape into per-position rows. Drop the last row (no
        // target after it within this window) and pair with
        // tokens[start+1..end] as targets.
        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 the chunk's logits buffer once we've copied the rows
        // we needed; for a 27B-dense model + 8k vocab this is a
        // sizable allocation and we want it freed before the next
        // forward pass.
        drop(chunk_logits);
    }

    if all_logits.is_empty() {
        // Defensive: should be unreachable given tokens.len() >= 2
        // and effective_seq_len >= 1, but explicitly surfacing this
        // is better than handing compute_perplexity an empty input
        // and getting a less-informative `EmptySequence` back.
        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"
    );

    // --- 6. Compute perplexity ------------------------------------
    let ppl_f64 = compute_perplexity(&all_logits, &all_targets)?;

    // --- 7. Narrow to f32 at the type boundary --------------------
    Ok(ppl_f64 as f32)
}

#[cfg(test)]
mod tests {
    use super::*;

    // Pure-arithmetic chunking tests. No model load, no IO. The
    // integration crate at `tests/ppl_driver.rs` re-exercises these
    // via the public `chunk_count` API to lock the math from outside
    // the crate; the in-crate tests below also cover edge cases that
    // don't merit a full integration test.

    #[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() {
        // 512 / 128 = 4 chunks exactly.
        assert_eq!(chunk_count(512, 128), 4);
    }

    #[test]
    fn chunk_count_rounds_up_partial_window() {
        // 513 / 128 = 4.x ⇒ 5 chunks (the 5th has 1 token).
        assert_eq!(chunk_count(513, 128), 5);
    }

    #[test]
    fn chunk_count_corpus_smaller_than_window() {
        // 100 tokens with 1024-window ⇒ 1 chunk of length 100.
        assert_eq!(chunk_count(100, 1024), 1);
    }

    #[test]
    fn chunk_count_window_one() {
        // Degenerate but well-defined.
        assert_eq!(chunk_count(7, 1), 7);
    }

    #[test]
    fn measure_ppl_returns_invalid_on_short_input() {
        // < 2 tokens ⇒ Invalid (no prediction possible).
        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:?}"),
        }
    }
}