hf2q 0.1.16

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
//! Pure-Rust mmproj (vision-tower) emitter — ADR-012 Decision 18 / P10.
//!
//! Converts HF `model.vision_tower.*` + `model.multi_modal_projector.*`
//! safetensors tensors to a standalone `mmproj-<slug>-F16.gguf` file
//! carrying the ViT weights and the cross-modal projector. Sovereignty:
//! the peer's clip-model headers are the
//! spec sources; produce all code + tests natively.
//!
//! # Layer decomposition (P10's four-layer defense)
//!
//! - **Layer A (structural)**: synthetic tiny-ViT convert → read back via
//!   hf2q's own GGUF reader; assert every expected tensor name + metadata
//!   key is present with correct shape / dtype. Tests in
//!   `tests/convert_vision_tower_integration.rs`.
//!
//! - **Layer B (ADR-005 round-trip)**: synthetic + real-model load via
//!   `src/inference/vision/mmproj.rs`. `tests/convert_vision_tower_adr005_roundtrip.rs`.
//!
//! - **Layer C (spec-driven layout)**: hand-authored bytes on the four
//!   highest-risk mappings (fc1↔fc2, linear_1↔linear_2, patch-embd
//!   transpose, pos-embd dtype). Unit tests in `src/models/vit/convert.rs`.
//!
//! Layer D (external oracle) was removed per the 2026-04-24 sovereignty
//! audit — using an external mmproj output to prove our correctness is
//! exactly the pattern `feedback_hf2q_sovereignty.md` rejects.
//!
//! # Silent-skip semantics
//!
//! `convert_vision_tower` returns `Ok(None)` when the HF config has no
//! `vision_config` field. Gemma4 (no vision_config) and Qwen3.6-35B-A3B
//! MoE (vision_config dropped by the publisher) both silently skip;
//! only Qwen3.6-27B dense emits.

pub mod config;
pub mod convert;
pub mod gguf_emit;

use std::path::{Path, PathBuf};

pub use config::{VisionConfig, VisionConfigError};

/// Top-level errors the convert-vision-tower pipeline may surface.
///
/// Distinct from `VisionConfigError` so callers can choose between
/// "config was bad" (don't emit, surface a user-facing error) and
/// "emission pipeline bug" (panic-worthy for tests).
#[derive(Debug, thiserror::Error)]
pub enum VitConvertError {
    #[error("vision config parse error: {0}")]
    Config(#[from] VisionConfigError),

    #[error("safetensors read error: {0}")]
    Safetensors(String),

    #[error("GGUF emit error: {0}")]
    GgufEmit(String),

    #[error("tensor {name}: expected shape {expected:?}, got {actual:?}")]
    ShapeMismatch {
        name: String,
        expected: Vec<usize>,
        actual: Vec<usize>,
    },

    #[error("i/o error: {0}")]
    Io(#[from] std::io::Error),
}

/// Convert the vision tower of `hf_repo_dir` into an mmproj GGUF under
/// `output_dir`. Returns:
///
///   - `Ok(Some(path))` on successful emission.
///   - `Ok(None)` when the HF config has no `vision_config` — silent skip
///     path per Decision 18 §3. Gemma4 and Qwen3.6-35B-A3B MoE both hit
///     this branch (no regression on shipped dense-only arches).
///   - `Err(...)` on any conversion-side failure.
///
/// The output filename is `mmproj-<slug>-F16.gguf` where `<slug>` is
/// derived from the HF repo's `config.json::_name_or_path` or falls
/// back to the last segment of `hf_repo_dir`.
pub fn convert_vision_tower(
    hf_repo_dir: &Path,
    output_dir: &Path,
) -> Result<Option<PathBuf>, VitConvertError> {
    let config_path = hf_repo_dir.join("config.json");
    if !config_path.exists() {
        return Err(VitConvertError::Config(VisionConfigError::NoConfigJson));
    }

    // Silent-skip check: parse config.json as JSON and look for
    // vision_config key. No vision_config → return Ok(None).
    let raw = std::fs::read_to_string(&config_path)
        .map_err(|e| VitConvertError::Config(VisionConfigError::Io(e.to_string())))?;
    let root: serde_json::Value = serde_json::from_str(&raw)
        .map_err(|e| VitConvertError::Config(VisionConfigError::BadJson(e.to_string())))?;
    if root.get("vision_config").is_none() {
        return Ok(None);
    }

    // Compute output slug and path.
    let slug = compute_slug(&root, hf_repo_dir);
    let output = output_dir.join(format!("mmproj-{}-F16.gguf", slug));
    convert_vision_tower_to_path(hf_repo_dir, &output)?;

    Ok(Some(output))
}

/// Convert a multimodal projector to an exact caller-selected output path.
/// The complete sidecar is written and synced in the destination directory,
/// then atomically promoted so cancellation or conversion failure cannot
/// replace a previously valid artifact with partial bytes.
pub fn convert_vision_tower_to_path(
    hf_repo_dir: &Path,
    output: &Path,
) -> Result<(), VitConvertError> {
    convert_vision_tower_to_path_with_source(hf_repo_dir, output, None)
}

/// Convert a vision tower and optionally stamp the exact source-bundle
/// identity shared with its text artifact.
pub fn convert_vision_tower_to_path_with_source(
    hf_repo_dir: &Path,
    output: &Path,
    source_sha256: Option<&str>,
) -> Result<(), VitConvertError> {
    convert_vision_tower_to_path_with_source_and_pair(hf_repo_dir, output, source_sha256, None)
}

/// Convert a vision tower with optional source and paired-generation metadata.
pub fn convert_vision_tower_to_path_with_source_and_pair(
    hf_repo_dir: &Path,
    output: &Path,
    source_sha256: Option<&str>,
    pair_generation: Option<&str>,
) -> Result<(), VitConvertError> {
    let (vision_config, tensors) = load_vision_conversion_inputs(hf_repo_dir)?;

    let output_dir = output.parent().unwrap_or_else(|| Path::new("."));
    std::fs::create_dir_all(output_dir)
        .map_err(|e| VitConvertError::GgufEmit(format!("mkdir output_dir: {e}")))?;

    let temporary = tempfile::NamedTempFile::new_in(output_dir)?;
    let temporary_path = temporary.into_temp_path();
    gguf_emit::write_mmproj_gguf_with_provenance_and_pair(
        &temporary_path,
        &vision_config,
        &tensors,
        source_sha256,
        pair_generation,
    )?;
    std::fs::File::open(&temporary_path)?.sync_all()?;
    temporary_path
        .persist(output)
        .map_err(|error| VitConvertError::Io(error.error))?;

    Ok(())
}

pub(crate) fn planned_vision_tower_output_bytes(
    hf_repo_dir: &Path,
    source_sha256: Option<&str>,
    pair_generation: Option<&str>,
) -> Result<u64, VitConvertError> {
    let vision_config = load_vision_config(hf_repo_dir)?;
    let tensors = convert::plan_vision_tensors(hf_repo_dir, &vision_config)?;
    gguf_emit::planned_mmproj_gguf_bytes_from_layout(
        &vision_config,
        &tensors,
        source_sha256,
        pair_generation,
    )
}

fn load_vision_conversion_inputs(
    hf_repo_dir: &Path,
) -> Result<
    (
        VisionConfig,
        std::collections::HashMap<String, convert::VitTensor>,
    ),
    VitConvertError,
> {
    let vision_config = load_vision_config(hf_repo_dir)?;
    let tensors = convert::load_vision_tensors(hf_repo_dir, &vision_config)?;
    Ok((vision_config, tensors))
}

fn load_vision_config(hf_repo_dir: &Path) -> Result<VisionConfig, VitConvertError> {
    let config_path = hf_repo_dir.join("config.json");
    let raw = std::fs::read_to_string(&config_path)
        .map_err(|e| VitConvertError::Config(VisionConfigError::Io(e.to_string())))?;
    let root: serde_json::Value = serde_json::from_str(&raw)
        .map_err(|e| VitConvertError::Config(VisionConfigError::BadJson(e.to_string())))?;
    let mut vision_config = VisionConfig::from_hf_config(&root)?;
    let processor_path = hf_repo_dir.join("preprocessor_config.json");
    let requires_processor_config = vision_config.is_qwen3vl();
    if processor_path.exists() {
        let processor_raw = std::fs::read_to_string(&processor_path)?;
        let processor: serde_json::Value = serde_json::from_str(&processor_raw)
            .map_err(|e| VitConvertError::Config(VisionConfigError::BadJson(e.to_string())))?;
        vision_config.apply_preprocessor_config(&processor)?;
        if requires_processor_config
            && (vision_config.image_min_pixels.is_none()
                || vision_config.image_max_pixels.is_none())
        {
            return Err(VitConvertError::Config(VisionConfigError::InvalidField {
                field: "processor.size",
                value: "Qwen vision conversion requires positive shortest_edge and longest_edge pixel bounds"
                    .to_string(),
            }));
        }
    } else if requires_processor_config {
        return Err(VitConvertError::Io(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            format!(
                "Qwen vision conversion requires {}",
                processor_path.display()
            ),
        )));
    }

    Ok(vision_config)
}

/// Derive a filesystem-safe slug for the mmproj filename.
/// Priority: config.json::_name_or_path → last segment of hf_repo_dir.
pub fn compute_slug(config_root: &serde_json::Value, hf_repo_dir: &Path) -> String {
    if let Some(name) = config_root.get("_name_or_path").and_then(|v| v.as_str()) {
        return sanitize_slug(name);
    }
    hf_repo_dir
        .file_name()
        .and_then(|s| s.to_str())
        .map(sanitize_slug)
        .unwrap_or_else(|| "model".to_string())
}

fn sanitize_slug(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '.' {
                c.to_ascii_lowercase()
            } else {
                '-'
            }
        })
        .collect::<String>()
        .trim_matches('-')
        .to_string()
}

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

    #[test]
    fn no_vision_config_returns_none() {
        let tmp = tempfile::tempdir().unwrap();
        let input = tmp.path().join("no-vision");
        fs::create_dir_all(&input).unwrap();
        fs::write(
            input.join("config.json"),
            r#"{"architectures":["Qwen3_5MoeForCausalLM"],"hidden_size":64}"#,
        )
        .unwrap();
        let out_dir = tmp.path().join("out");

        let result = convert_vision_tower(&input, &out_dir).expect("no error");
        assert!(result.is_none(), "no vision_config → Ok(None)");
    }

    #[test]
    fn missing_config_json_errors() {
        let tmp = tempfile::tempdir().unwrap();
        let input = tmp.path().join("no-config");
        fs::create_dir_all(&input).unwrap();
        let out_dir = tmp.path().join("out");

        let err = convert_vision_tower(&input, &out_dir).unwrap_err();
        assert!(matches!(
            err,
            VitConvertError::Config(VisionConfigError::NoConfigJson)
        ));
    }

    #[test]
    fn malformed_json_errors() {
        let tmp = tempfile::tempdir().unwrap();
        let input = tmp.path().join("bad-json");
        fs::create_dir_all(&input).unwrap();
        fs::write(input.join("config.json"), "not json").unwrap();
        let out_dir = tmp.path().join("out");

        let err = convert_vision_tower(&input, &out_dir).unwrap_err();
        assert!(matches!(
            err,
            VitConvertError::Config(VisionConfigError::BadJson(_))
        ));
    }

    #[test]
    fn compute_slug_from_name_or_path() {
        let root = serde_json::json!({"_name_or_path": "Qwen/Qwen3.6-27B"});
        let slug = compute_slug(&root, Path::new("/tmp/ignored"));
        assert_eq!(slug, "qwen-qwen3.6-27b");
    }

    #[test]
    fn compute_slug_from_directory_when_no_name() {
        let root = serde_json::json!({});
        let slug = compute_slug(&root, Path::new("/tmp/qwen3.6-27B-apex"));
        assert_eq!(slug, "qwen3.6-27b-apex");
    }

    #[test]
    fn sanitize_slug_strips_bad_chars() {
        assert_eq!(sanitize_slug("Foo/Bar_Baz.V2"), "foo-bar-baz.v2");
        assert_eq!(sanitize_slug("---leading"), "leading");
    }

    #[test]
    fn gemma4_config_returns_none_silent_regression_gate() {
        // Gemma4's config.json has no vision_config. This is the
        // regression gate from Decision 18 §4 — --emit-vision-tower
        // against Gemma4 must NOT emit a file and must NOT error.
        let tmp = tempfile::tempdir().unwrap();
        let input = tmp.path().join("gemma4-fixture");
        fs::create_dir_all(&input).unwrap();
        fs::write(
            input.join("config.json"),
            r#"{
                "architectures": ["Gemma4ForCausalLM"],
                "hidden_size": 2048,
                "num_hidden_layers": 26
            }"#,
        )
        .unwrap();
        let out_dir = tmp.path().join("out");

        let result = convert_vision_tower(&input, &out_dir).expect("gemma4 must not error");
        assert!(
            result.is_none(),
            "gemma4 has no vision_config — must silently skip"
        );
        // Out-dir is not even created (no reason to mkdir).
        assert!(!out_dir.exists(), "no output dir created on silent-skip");
    }

    #[test]
    fn qwen35moe_without_vision_config_silently_skips() {
        // The Robert-named 35B-A3B MoE target's config.json dropped
        // vision_config — --emit-vision-tower must silent-skip, not
        // emit a mmproj, not error.
        let tmp = tempfile::tempdir().unwrap();
        let input = tmp.path().join("qwen35moe-no-vc");
        fs::create_dir_all(&input).unwrap();
        fs::write(
            input.join("config.json"),
            r#"{
                "architectures": ["Qwen3_5MoeForCausalLM"],
                "hidden_size": 2048,
                "num_hidden_layers": 40,
                "num_experts": 256
            }"#,
        )
        .unwrap();
        let out_dir = tmp.path().join("out");

        let result = convert_vision_tower(&input, &out_dir).unwrap();
        assert!(
            result.is_none(),
            "MoE without vision_config must silent-skip"
        );
    }

    #[test]
    fn qwen_vision_conversion_requires_processor_config() {
        let tmp = tempfile::tempdir().unwrap();
        let input = tmp.path().join("qwen-vision");
        fs::create_dir_all(&input).unwrap();
        fs::write(
            input.join("config.json"),
            include_str!("../../../tests/fixtures/qwen38/config.json"),
        )
        .unwrap();

        let err = convert_vision_tower_to_path(&input, &tmp.path().join("out.gguf"))
            .expect_err("missing processor config must fail before tensor loading");
        assert!(format!("{err}").contains("preprocessor_config.json"));
    }

    #[test]
    fn qwen_vision_conversion_rejects_processor_without_pixel_bounds() {
        let tmp = tempfile::tempdir().unwrap();
        let input = tmp.path().join("qwen-vision");
        fs::create_dir_all(&input).unwrap();
        fs::write(
            input.join("config.json"),
            include_str!("../../../tests/fixtures/qwen38/config.json"),
        )
        .unwrap();
        fs::write(input.join("preprocessor_config.json"), r#"{"size":{}}"#).unwrap();

        let err = convert_vision_tower_to_path(&input, &tmp.path().join("out.gguf"))
            .expect_err("missing pixel bounds must fail before tensor loading");
        assert!(format!("{err}").contains("processor.size"));
    }
}