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
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
//! ViT `vision_config` parser + validator — ADR-012 Decision 18 §1.
//!
//! Parses `config.json::vision_config` into a typed `VisionConfig`.
//! Required fields for Qwen3.6-27B are validated explicitly with
//! named errors (same pattern as ADR-012 P1's config.json parser).
//!
//! References (read-only spec sources per sovereignty):
//!   - `/opt/llama.cpp/tools/mtmd/clip-model.h` — GGUF metadata key conventions
//!   - `/opt/llama.cpp/tools/mtmd/clip.cpp` — projector type string table
//!   - HF `transformers/src/transformers/models/clip/configuration_clip.py`

use serde_json::Value;

/// Errors produced when parsing `config.json::vision_config`.
#[derive(Debug, thiserror::Error)]
pub enum VisionConfigError {
    #[error("config.json not found")]
    NoConfigJson,

    #[error("config.json i/o error: {0}")]
    Io(String),

    #[error("config.json is not valid JSON: {0}")]
    BadJson(String),

    #[error("vision_config is not a JSON object")]
    VisionConfigNotObject,

    #[error("vision_config.{field} missing or not a {expected_type}")]
    MissingField {
        field: &'static str,
        expected_type: &'static str,
    },

    #[error("vision_config.{field}: invalid value {value}")]
    InvalidField { field: &'static str, value: String },
}

/// Parsed ViT configuration.
///
/// All fields from `clip-model.h`'s `clip.vision.*` metadata. Extensible
/// via `Option<T>` for known-optional keys (e.g. layer_norm_eps has a
/// documented default in llama.cpp).
#[derive(Debug, Clone, PartialEq)]
pub struct VisionConfig {
    /// ViT hidden (embedding) dim — GGUF `clip.vision.embedding_length`.
    pub hidden_size: u32,
    /// ViT encoder layer count — GGUF `clip.vision.block_count`.
    pub num_hidden_layers: u32,
    /// ViT attention head count — GGUF `clip.vision.attention.head_count`.
    pub num_attention_heads: u32,
    /// Patch edge in pixels — GGUF `clip.vision.patch_size`.
    pub patch_size: u32,
    /// Square input image edge in pixels — GGUF `clip.vision.image_size`.
    pub image_size: u32,
    /// FFN intermediate size — GGUF `clip.vision.feed_forward_length`.
    pub intermediate_size: u32,
    /// LayerNorm epsilon — GGUF `clip.vision.attention.layer_norm_epsilon`.
    pub layer_norm_eps: f32,
    /// Projector type string — GGUF `clip.projector_type`. Usually `"mlp"`.
    pub projector_type: String,
    /// Cross-modal projector output dim (matches text hidden_size).
    /// Optional in HF configs; when absent, derived from the text
    /// hidden_size by the caller.
    pub projection_dim: Option<u32>,
    /// Image normalization mean, `[R, G, B]`. Default `[0.5, 0.5, 0.5]`.
    pub image_mean: [f32; 3],
    /// Image normalization std, `[R, G, B]`. Default `[0.5, 0.5, 0.5]`.
    pub image_std: [f32; 3],
    // ----- Qwen3-VL extensions (iter-224 Wedge-4f) ---------------------
    /// `clip.vision.spatial_merge_size` — Qwen3-VL spatial-merger
    /// degree (typically `2`, giving 2×2 patch fold + 4× token
    /// reduction). `None` for non-Qwen3-VL profiles. Source HF key:
    /// `vision_config.spatial_merge_size`. Source GGUF key:
    /// `clip.vision.spatial_merge_size` (Keys.ClipVision.SPATIAL_MERGE_SIZE
    /// at `/opt/llama.cpp/gguf-py/gguf/constants.py:315`). Writer ref:
    /// `add_vision_spatial_merge_size` at gguf_writer.py:1178-1179.
    pub spatial_merge_size: Option<u32>,
    /// `vision_config.deepstack_visual_indexes` — sorted ascending list
    /// of layer indexes (0-based) whose ViT hidden state is fed into
    /// the LM as DeepStack augmentation. Qwen3-VL-2B-Instruct uses
    /// `[5, 11, 17]`. `None` when the HF config has no
    /// `deepstack_visual_indexes`. Empty `Vec` when the key is present
    /// but no layer is flagged. Emitted to GGUF as a length-`block_count`
    /// `Bool[]` array under `clip.vision.is_deepstack_layers`
    /// (Keys.ClipVision.IS_DEEPSTACK_LAYERS at constants.py:320), the
    /// SAME format llama.cpp's `Qwen3VLVisionModel.set_gguf_parameters`
    /// emits at convert_hf_to_gguf.py:4895-4896:
    ///
    /// ```python
    /// if self.is_deepstack_layers:
    ///     self.gguf_writer.add_vision_is_deepstack_layers(self.is_deepstack_layers)
    /// ```
    ///
    /// where `self.is_deepstack_layers` is a `[False] * num_hidden_layers`
    /// list with `True` set at every index in
    /// `vision_config.deepstack_visual_indexes`.
    pub deepstack_visual_indexes: Option<Vec<u32>>,
    /// `vision_config.temporal_patch_size` — Qwen3-VL's dual-conv
    /// patch stem produces TWO separate patch_embd weights (one per
    /// temporal frame) when `temporal_patch_size == 2`. Defaults to
    /// `2` for Qwen3-VL family per
    /// `/opt/llama.cpp/convert_hf_to_gguf.py:4959-4960`. `None` for
    /// CLIP-classic / Gemma 4 (single-frame patch stem).
    pub temporal_patch_size: Option<u32>,
}

impl VisionConfig {
    /// Parse from a loaded `config.json` root `Value`. Reads
    /// `vision_config` sub-object; top-level `_name_or_path` etc. are
    /// ignored here (handled by `super::compute_slug`).
    pub fn from_hf_config(root: &Value) -> Result<Self, VisionConfigError> {
        let vc = root
            .get("vision_config")
            .ok_or(VisionConfigError::MissingField {
                field: "vision_config",
                expected_type: "object",
            })?;
        if !vc.is_object() {
            return Err(VisionConfigError::VisionConfigNotObject);
        }

        let u32_req = |k: &'static str| -> Result<u32, VisionConfigError> {
            vc.get(k).and_then(|v| v.as_u64()).map(|n| n as u32).ok_or(
                VisionConfigError::MissingField {
                    field: k,
                    expected_type: "u32",
                },
            )
        };
        let f32_def = |k: &str, default: f32| -> f32 {
            vc.get(k)
                .and_then(|v| v.as_f64())
                .map(|x| x as f32)
                .unwrap_or(default)
        };
        let str_def = |k: &str, default: &str| -> String {
            vc.get(k)
                .and_then(|v| v.as_str())
                .map(|s| s.to_string())
                .unwrap_or_else(|| default.to_string())
        };
        let read_triple = |k: &str, default: [f32; 3]| -> [f32; 3] {
            vc.get(k)
                .and_then(|v| v.as_array())
                .and_then(|arr| {
                    if arr.len() != 3 {
                        return None;
                    }
                    let mut out = [0f32; 3];
                    for (i, v) in arr.iter().enumerate() {
                        out[i] = v.as_f64()? as f32;
                    }
                    Some(out)
                })
                .unwrap_or(default)
        };

        // ADR-012 P9b real-model finding: Qwen3.6 vision_config uses
        // different field names than the Gemma-style schema this parser
        // was originally written for. Accept both forms via fallback:
        //   Gemma            Qwen3.6
        //   num_hidden_layers depth
        //   num_attention_heads  num_heads
        //   image_size       (derived from num_position_embeddings)
        let u32_req_alt =
            |primary: &'static str, fallback: &'static str| -> Result<u32, VisionConfigError> {
                if let Some(v) = vc.get(primary).and_then(|v| v.as_u64()) {
                    return Ok(v as u32);
                }
                if let Some(v) = vc.get(fallback).and_then(|v| v.as_u64()) {
                    return Ok(v as u32);
                }
                Err(VisionConfigError::MissingField {
                    field: primary,
                    expected_type: "u32",
                })
            };

        let hidden_size = u32_req("hidden_size")?;
        let num_hidden_layers = u32_req_alt("num_hidden_layers", "depth")?;
        let num_attention_heads = u32_req_alt("num_attention_heads", "num_heads")?;
        let patch_size = u32_req("patch_size")?;
        let intermediate_size = u32_req("intermediate_size")?;
        // image_size: prefer explicit; otherwise derive from
        // num_position_embeddings = (image_size/patch_size)^2 for ViT.
        let image_size = if let Some(v) = vc.get("image_size").and_then(|v| v.as_u64()) {
            v as u32
        } else if let Some(npe) = vc.get("num_position_embeddings").and_then(|v| v.as_u64()) {
            let patches_per_side = (npe as f64).sqrt() as u32;
            patches_per_side * patch_size
        } else {
            return Err(VisionConfigError::MissingField {
                field: "image_size",
                expected_type: "u32 (or num_position_embeddings to derive)",
            });
        };

        if patch_size == 0 {
            return Err(VisionConfigError::InvalidField {
                field: "patch_size",
                value: "0".into(),
            });
        }
        if image_size % patch_size != 0 {
            return Err(VisionConfigError::InvalidField {
                field: "image_size",
                value: format!(
                    "{} (not divisible by patch_size {})",
                    image_size, patch_size
                ),
            });
        }

        // ----- Qwen3-VL extension fields -----
        // `vision_config.deepstack_visual_indexes` is a list of layer
        // indexes (0-based). Validate that every entry fits in
        // num_hidden_layers BEFORE we accept the config — a mis-sized
        // index would silently produce a length-mismatched
        // `is_deepstack_layers` GGUF array that the loader rejects at
        // load time anyway, but failing here surfaces the producer bug
        // earlier with the offending index named.
        let deepstack_visual_indexes: Option<Vec<u32>> = match vc.get("deepstack_visual_indexes") {
            None => None,
            Some(v) => match v.as_array() {
                None => {
                    return Err(VisionConfigError::InvalidField {
                        field: "deepstack_visual_indexes",
                        value: format!("expected array, got {}", v),
                    });
                }
                Some(arr) => {
                    let mut indexes: Vec<u32> = Vec::with_capacity(arr.len());
                    for entry in arr {
                        let idx = entry
                            .as_u64()
                            .ok_or_else(|| VisionConfigError::InvalidField {
                                field: "deepstack_visual_indexes",
                                value: format!("non-u64 entry {}", entry),
                            })? as u32;
                        if idx >= num_hidden_layers {
                            return Err(VisionConfigError::InvalidField {
                                field: "deepstack_visual_indexes",
                                value: format!(
                                    "entry {} >= num_hidden_layers {} (out of range)",
                                    idx, num_hidden_layers
                                ),
                            });
                        }
                        indexes.push(idx);
                    }
                    // Wedge-4f Phase-2c (Codex review of 9e9e262, finding #2,
                    // medium): preserve HF config order. Peer implementations
                    // (llama.cpp, vLLM, Candle) all enumerate
                    // `deepstack_visual_indexes` in HF order to attach
                    // DeepStack heads at the correct absolute layers.
                    // Sorting silently breaks `visual.deepstack_merger_list.{rel_idx}`
                    // → absolute-layer remap for any unsorted producer
                    // configuration. Current Qwen3-VL-2B [5, 11, 17] is
                    // already sorted so the bug was latent, but config
                    // schema imposes no ordering invariant.
                    Some(indexes)
                }
            },
        };

        let spatial_merge_size: Option<u32> = vc
            .get("spatial_merge_size")
            .and_then(|v| v.as_u64())
            .map(|n| n as u32);
        let temporal_patch_size: Option<u32> = vc
            .get("temporal_patch_size")
            .and_then(|v| v.as_u64())
            .map(|n| n as u32);

        // Wedge-4f Phase-2c (Codex review of 9e9e262, finding #1, BLOCKER):
        // Real Qwen3-VL HF configs do NOT carry vision_config.projector_type
        // (verified against Qwen/Qwen3-VL-2B-Instruct/config.json 2026-04
        // snapshot — uses model_type="qwen3_vl" + deepstack_visual_indexes
        // and out_hidden_size, but no projector_type field). The previous
        // landing defaulted to "mlp" + never derived projection_dim, so
        // hf2q would emit clip.projector_type="mlp" and miss
        // clip.vision.projection_dim — preventing downstream Qwen3-VL
        // dispatch and causing Qwen3VlViTConfig::from_mmproj to reject.
        //
        // Detect Qwen3-VL family via TWO independent upstream signals
        // mirroring llama.cpp's Qwen3VLVisionModel gate:
        //   (a) vision_config.model_type == "qwen3_vl" (canonical HF
        //       Qwen3-VL), or
        //   (b) deepstack_visual_indexes presence (the unique-to-Qwen3-VL
        //       config key — same fallback used by `is_qwen3vl()`).
        // When either fires, force projector_type to "qwen3vl_merger"
        // (the canonical GGUF projector_type string per
        // /opt/llama.cpp/tools/mtmd/clip.cpp:865-867) regardless of
        // whether the HF config carries a (different) projector_type
        // string.
        let model_type = vc
            .get("model_type")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());
        let raw_projector_type = str_def("projector_type", "mlp");
        let is_qwen3vl_via_model_type = model_type
            .as_deref()
            .map(|s| s == "qwen3_vl")
            .unwrap_or(false);
        let is_qwen3vl_via_deepstack = deepstack_visual_indexes.is_some();
        let projector_type = if is_qwen3vl_via_model_type || is_qwen3vl_via_deepstack {
            "qwen3vl_merger".to_string()
        } else {
            raw_projector_type
        };

        // projection_dim resolution order matches what llama.cpp's
        // MmprojModel.set_gguf_parameters does — read from the FIRST
        // available source: vision_config.projection_dim → vision_config
        // .out_hidden_size (Qwen3-VL canonical, e.g. 2048 for Qwen3-VL-2B)
        // → text_config.hidden_size at the root config level (the LM
        // hidden size — projection MUST match LM input embedding dim).
        // Without this, the converter omits clip.vision.projection_dim
        // and the Qwen3VlViTConfig::from_mmproj loader rejects.
        let projection_dim = vc
            .get("projection_dim")
            .and_then(|v| v.as_u64())
            .map(|n| n as u32)
            .or_else(|| {
                vc.get("out_hidden_size")
                    .and_then(|v| v.as_u64())
                    .map(|n| n as u32)
            })
            .or_else(|| {
                root.get("text_config")
                    .and_then(|tc| tc.get("hidden_size"))
                    .and_then(|v| v.as_u64())
                    .map(|n| n as u32)
            })
            .or_else(|| {
                root.get("hidden_size")
                    .and_then(|v| v.as_u64())
                    .map(|n| n as u32)
            });

        Ok(VisionConfig {
            hidden_size,
            num_hidden_layers,
            num_attention_heads,
            patch_size,
            image_size,
            intermediate_size,
            layer_norm_eps: f32_def("layer_norm_eps", 1e-6),
            projector_type,
            projection_dim,
            image_mean: read_triple("image_mean", [0.5, 0.5, 0.5]),
            image_std: read_triple("image_std", [0.5, 0.5, 0.5]),
            spatial_merge_size,
            deepstack_visual_indexes,
            temporal_patch_size,
        })
    }

    /// True when this `VisionConfig` describes a Qwen3-VL family vision
    /// tower. Detected via either:
    ///   - `projector_type == "qwen3vl_merger"` (HF native marker), or
    ///   - presence of `deepstack_visual_indexes` (Qwen3-VL is the only
    ///     family that ships this key).
    /// The `OR` accommodates HF configs that omit `projector_type` but
    /// carry the deepstack marker (verified for the 2026-04 snapshot of
    /// `Qwen/Qwen3-VL-2B-Instruct/config.json`'s `vision_config`).
    pub fn is_qwen3vl(&self) -> bool {
        self.projector_type == "qwen3vl_merger" || self.deepstack_visual_indexes.is_some()
    }

    /// Build the length-`num_hidden_layers` `Vec<bool>` for emission as
    /// `clip.vision.is_deepstack_layers`. Each entry is `true` iff the
    /// matching layer index appears in `deepstack_visual_indexes`.
    /// Returns `None` when `deepstack_visual_indexes` is `None` (the
    /// non-Qwen3-VL case — the GGUF key is then OMITTED entirely, NOT
    /// emitted as all-false, matching llama.cpp's
    /// `Qwen3VLVisionModel.set_gguf_parameters` at
    /// `/opt/llama.cpp/convert_hf_to_gguf.py:4895-4896`:
    ///
    ///     if self.is_deepstack_layers:
    ///         self.gguf_writer.add_vision_is_deepstack_layers(...)
    pub fn build_is_deepstack_layers(&self) -> Option<Vec<bool>> {
        let indexes = self.deepstack_visual_indexes.as_ref()?;
        let mut bools = vec![false; self.num_hidden_layers as usize];
        for &idx in indexes {
            if (idx as usize) < bools.len() {
                bools[idx as usize] = true;
            }
        }
        Some(bools)
    }

    /// Patches per side (image_size / patch_size).
    pub fn num_patches_side(&self) -> u32 {
        self.image_size / self.patch_size
    }

    /// Total patches per image.
    pub fn num_patches(&self) -> u32 {
        let s = self.num_patches_side();
        s * s
    }
}

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

    fn valid_config() -> Value {
        serde_json::json!({
            "vision_config": {
                "hidden_size": 384,
                "num_hidden_layers": 4,
                "num_attention_heads": 8,
                "patch_size": 4,
                "image_size": 32,
                "intermediate_size": 1536,
                "layer_norm_eps": 1e-5,
                "projector_type": "mlp"
            }
        })
    }

    #[test]
    fn parses_valid_vision_config() {
        let vc = VisionConfig::from_hf_config(&valid_config()).unwrap();
        assert_eq!(vc.hidden_size, 384);
        assert_eq!(vc.num_hidden_layers, 4);
        assert_eq!(vc.num_attention_heads, 8);
        assert_eq!(vc.patch_size, 4);
        assert_eq!(vc.image_size, 32);
        assert_eq!(vc.intermediate_size, 1536);
        assert_eq!(vc.projector_type, "mlp");
        assert_eq!(vc.num_patches_side(), 8);
        assert_eq!(vc.num_patches(), 64);
    }

    #[test]
    fn missing_vision_config_is_missing_field() {
        let root = serde_json::json!({});
        let err = VisionConfig::from_hf_config(&root).unwrap_err();
        assert!(matches!(
            err,
            VisionConfigError::MissingField {
                field: "vision_config",
                ..
            }
        ));
    }

    #[test]
    fn non_object_vision_config_rejected() {
        let root = serde_json::json!({"vision_config": "invalid"});
        let err = VisionConfig::from_hf_config(&root).unwrap_err();
        assert!(matches!(err, VisionConfigError::VisionConfigNotObject));
    }

    #[test]
    fn missing_required_field_named_in_error() {
        let mut cfg = valid_config();
        cfg["vision_config"]
            .as_object_mut()
            .unwrap()
            .remove("hidden_size");
        let err = VisionConfig::from_hf_config(&cfg).unwrap_err();
        match err {
            VisionConfigError::MissingField { field, .. } => {
                assert_eq!(field, "hidden_size");
            }
            other => panic!("expected MissingField(hidden_size), got {:?}", other),
        }
    }

    #[test]
    fn patch_size_zero_rejected() {
        let mut cfg = valid_config();
        cfg["vision_config"]
            .as_object_mut()
            .unwrap()
            .insert("patch_size".into(), serde_json::json!(0));
        let err = VisionConfig::from_hf_config(&cfg).unwrap_err();
        assert!(matches!(
            err,
            VisionConfigError::InvalidField {
                field: "patch_size",
                ..
            }
        ));
    }

    #[test]
    fn image_size_not_divisible_by_patch_rejected() {
        let mut cfg = valid_config();
        cfg["vision_config"]
            .as_object_mut()
            .unwrap()
            .insert("image_size".into(), serde_json::json!(31));
        let err = VisionConfig::from_hf_config(&cfg).unwrap_err();
        match err {
            VisionConfigError::InvalidField { field, value } => {
                assert_eq!(field, "image_size");
                assert!(value.contains("31") && value.contains("4"));
            }
            other => panic!("expected InvalidField, got {:?}", other),
        }
    }

    #[test]
    fn defaults_layer_norm_eps_projector_and_mean_std() {
        let mut cfg = valid_config();
        let vc_obj = cfg["vision_config"].as_object_mut().unwrap();
        vc_obj.remove("layer_norm_eps");
        vc_obj.remove("projector_type");
        let parsed = VisionConfig::from_hf_config(&cfg).unwrap();
        assert_eq!(parsed.projector_type, "mlp");
        assert!((parsed.layer_norm_eps - 1e-6).abs() < 1e-9);
        assert_eq!(parsed.image_mean, [0.5, 0.5, 0.5]);
        assert_eq!(parsed.image_std, [0.5, 0.5, 0.5]);
    }

    #[test]
    fn custom_mean_std_triples_honored() {
        let mut cfg = valid_config();
        let vc_obj = cfg["vision_config"].as_object_mut().unwrap();
        vc_obj.insert("image_mean".into(), serde_json::json!([0.48, 0.45, 0.40]));
        vc_obj.insert("image_std".into(), serde_json::json!([0.26, 0.26, 0.27]));
        let parsed = VisionConfig::from_hf_config(&cfg).unwrap();
        assert_eq!(parsed.image_mean, [0.48, 0.45, 0.40]);
        assert_eq!(parsed.image_std, [0.26, 0.26, 0.27]);
    }

    #[test]
    fn projection_dim_optional() {
        let cfg = valid_config();
        let parsed = VisionConfig::from_hf_config(&cfg).unwrap();
        assert_eq!(parsed.projection_dim, None);

        let mut cfg_with = cfg.clone();
        cfg_with["vision_config"]
            .as_object_mut()
            .unwrap()
            .insert("projection_dim".into(), serde_json::json!(2048));
        let parsed2 = VisionConfig::from_hf_config(&cfg_with).unwrap();
        assert_eq!(parsed2.projection_dim, Some(2048));
    }

    // ----- Wedge-4f (iter-224 row 6) — Qwen3-VL extension fields -----

    #[test]
    fn parses_qwen3vl_extension_fields() {
        // Mirrors a real Qwen3-VL-2B-Instruct vision_config snippet.
        let root = serde_json::json!({
            "vision_config": {
                "hidden_size": 384,
                "depth": 24,
                "num_heads": 8,
                "patch_size": 16,
                "image_size": 224,
                "intermediate_size": 1536,
                "spatial_merge_size": 2,
                "temporal_patch_size": 2,
                "deepstack_visual_indexes": [5, 11, 17],
                "projector_type": "qwen3vl_merger"
            }
        });
        let vc = VisionConfig::from_hf_config(&root).unwrap();
        assert_eq!(vc.spatial_merge_size, Some(2));
        assert_eq!(vc.temporal_patch_size, Some(2));
        assert_eq!(vc.deepstack_visual_indexes, Some(vec![5, 11, 17]));
        assert_eq!(vc.projector_type, "qwen3vl_merger");
        assert!(vc.is_qwen3vl());
    }

    #[test]
    fn deepstack_indexes_preserve_hf_order_unsorted_input() {
        // RENAMED + SEMANTICS FLIPPED for Wedge-4f Phase-2c (Codex Phase-2b
        // finding #2, medium): peer implementations (llama.cpp converter,
        // vLLM, Candle) all preserve HF config order when remapping
        // `visual.deepstack_merger_list.{rel_idx}` → absolute layer
        // indexes. Sorting silently breaks the remap for any unsorted
        // producer config. The pre-Phase-2c `sort_unstable()` was
        // landing-time wrong (latent because Qwen3-VL-2B [5, 11, 17]
        // happens to be sorted, but nothing in HF config schema
        // requires it). This test now pins the corrected behavior.
        let root = serde_json::json!({
            "vision_config": {
                "hidden_size": 64,
                "num_hidden_layers": 24,
                "num_attention_heads": 8,
                "patch_size": 16,
                "image_size": 224,
                "intermediate_size": 256,
                "deepstack_visual_indexes": [17, 5, 11]
            }
        });
        let vc = VisionConfig::from_hf_config(&root).unwrap();
        assert_eq!(
            vc.deepstack_visual_indexes,
            Some(vec![17, 5, 11]),
            "HF order MUST be preserved (peer impls index by enumerate() / \
             by .index() — sorting silently breaks the rel_idx → \
             absolute-layer remap for unsorted producer configs)"
        );
    }

    #[test]
    fn deepstack_index_out_of_range_rejected() {
        // Defense in depth — reject invalid index now so we don't
        // emit a length-mismatched is_deepstack_layers GGUF array
        // that the loader would reject anyway.
        let root = serde_json::json!({
            "vision_config": {
                "hidden_size": 64,
                "num_hidden_layers": 4,
                "num_attention_heads": 8,
                "patch_size": 16,
                "image_size": 224,
                "intermediate_size": 256,
                "deepstack_visual_indexes": [2, 5]   // 5 >= 4
            }
        });
        let err = VisionConfig::from_hf_config(&root).unwrap_err();
        match err {
            VisionConfigError::InvalidField { field, value } => {
                assert_eq!(field, "deepstack_visual_indexes");
                assert!(value.contains("5") && value.contains("4"));
            }
            other => panic!("expected InvalidField, got {:?}", other),
        }
    }

    #[test]
    fn build_is_deepstack_layers_emits_correct_bool_array() {
        // [5, 11, 17] in a 24-layer ViT → length-24 array with `true`
        // at exactly those three positions.
        let vc = VisionConfig {
            hidden_size: 64,
            num_hidden_layers: 24,
            num_attention_heads: 8,
            patch_size: 16,
            image_size: 224,
            intermediate_size: 256,
            layer_norm_eps: 1e-6,
            projector_type: "qwen3vl_merger".into(),
            projection_dim: None,
            image_mean: [0.5, 0.5, 0.5],
            image_std: [0.5, 0.5, 0.5],
            spatial_merge_size: Some(2),
            deepstack_visual_indexes: Some(vec![5, 11, 17]),
            temporal_patch_size: Some(2),
        };
        let bools = vc.build_is_deepstack_layers().expect("Some(bools)");
        assert_eq!(bools.len(), 24);
        let true_positions: Vec<usize> = bools
            .iter()
            .enumerate()
            .filter_map(|(i, &b)| if b { Some(i) } else { None })
            .collect();
        assert_eq!(true_positions, vec![5, 11, 17]);
    }

    #[test]
    fn build_is_deepstack_layers_returns_none_when_indexes_none() {
        let vc = VisionConfig {
            hidden_size: 64,
            num_hidden_layers: 4,
            num_attention_heads: 8,
            patch_size: 16,
            image_size: 224,
            intermediate_size: 256,
            layer_norm_eps: 1e-6,
            projector_type: "mlp".into(),
            projection_dim: None,
            image_mean: [0.5, 0.5, 0.5],
            image_std: [0.5, 0.5, 0.5],
            spatial_merge_size: None,
            deepstack_visual_indexes: None,
            temporal_patch_size: None,
        };
        assert!(vc.build_is_deepstack_layers().is_none());
        assert!(!vc.is_qwen3vl());
    }

    #[test]
    fn is_qwen3vl_detects_via_projector_type() {
        // Family detection via projector_type alone (no deepstack).
        let root = serde_json::json!({
            "vision_config": {
                "hidden_size": 64,
                "num_hidden_layers": 2,
                "num_attention_heads": 8,
                "patch_size": 16,
                "image_size": 224,
                "intermediate_size": 256,
                "projector_type": "qwen3vl_merger"
            }
        });
        let vc = VisionConfig::from_hf_config(&root).unwrap();
        assert!(vc.is_qwen3vl());
    }

    #[test]
    fn is_qwen3vl_detects_via_deepstack_indexes_alone() {
        // A vision_config that omits projector_type but ships
        // deepstack_visual_indexes is unambiguously Qwen3-VL.
        let root = serde_json::json!({
            "vision_config": {
                "hidden_size": 64,
                "num_hidden_layers": 8,
                "num_attention_heads": 8,
                "patch_size": 16,
                "image_size": 224,
                "intermediate_size": 256,
                "deepstack_visual_indexes": [3, 5]
            }
        });
        let vc = VisionConfig::from_hf_config(&root).unwrap();
        assert!(vc.is_qwen3vl());
    }

    #[test]
    fn from_hf_config_canonical_qwen3vl_no_projector_type_with_out_hidden_size() {
        // Phase-2c regression for Codex Phase-2b finding #1 BLOCKER on
        // Wedge-4f 9e9e262: real Qwen3-VL HF configs (verified against
        // Qwen/Qwen3-VL-2B-Instruct/config.json 2026-04 snapshot) carry
        // model_type="qwen3_vl" + deepstack_visual_indexes + out_hidden_size,
        // but NO projector_type field. Pre-Phase-2c the converter
        // defaulted projector_type to "mlp" and never derived
        // projection_dim — silently emitting a wrong mmproj.
        //
        // This test pins both halves of the fix:
        //   (a) projector_type forced to "qwen3vl_merger" via model_type
        //       OR via deepstack_visual_indexes presence;
        //   (b) projection_dim populated from out_hidden_size when
        //       projection_dim is absent.
        let root = serde_json::json!({
            "text_config": {"hidden_size": 1024},  // present but not consulted because vc has out_hidden_size
            "vision_config": {
                "model_type": "qwen3_vl",
                "hidden_size": 64,
                "num_hidden_layers": 24,
                "num_attention_heads": 8,
                "patch_size": 16,
                "image_size": 768,
                "intermediate_size": 256,
                "spatial_merge_size": 2,
                "temporal_patch_size": 2,
                "deepstack_visual_indexes": [5, 11, 17],
                "out_hidden_size": 2048
                // NOTE: NO projector_type, NO projection_dim
            }
        });
        let vc = VisionConfig::from_hf_config(&root).unwrap();
        // Pin (a): projector_type forced to canonical Qwen3-VL string.
        assert_eq!(
            vc.projector_type, "qwen3vl_merger",
            "Real HF Qwen3-VL configs lack projector_type — Phase-2c \
             must force it to qwen3vl_merger via model_type='qwen3_vl' \
             OR deepstack_visual_indexes presence. Got '{}'",
            vc.projector_type
        );
        assert!(vc.is_qwen3vl());
        // Pin (b): projection_dim derived from out_hidden_size (NOT
        // text_config.hidden_size — out_hidden_size has higher priority).
        assert_eq!(
            vc.projection_dim,
            Some(2048),
            "projection_dim must derive from vision_config.out_hidden_size \
             (= 2048) when explicit projection_dim is absent. Got {:?}",
            vc.projection_dim
        );
    }

    #[test]
    fn from_hf_config_qwen3vl_falls_back_to_text_config_hidden_size_when_no_out_hidden_size() {
        // Edge case: vision_config has NEITHER projection_dim NOR
        // out_hidden_size; converter must fall back to
        // text_config.hidden_size (the LM input embedding dim — the
        // projection MUST match it for the augmented embed contract
        // to land at the right shape per Wedge-4c.5 contract).
        let root = serde_json::json!({
            "text_config": {"hidden_size": 1536},
            "vision_config": {
                "model_type": "qwen3_vl",
                "hidden_size": 64,
                "num_hidden_layers": 8,
                "num_attention_heads": 8,
                "patch_size": 16,
                "image_size": 224,
                "intermediate_size": 256,
                "deepstack_visual_indexes": [3, 5]
                // NO out_hidden_size, NO projection_dim
            }
        });
        let vc = VisionConfig::from_hf_config(&root).unwrap();
        assert_eq!(vc.projector_type, "qwen3vl_merger");
        assert_eq!(
            vc.projection_dim,
            Some(1536),
            "Fallback: text_config.hidden_size when both \
             projection_dim and out_hidden_size are absent"
        );
    }

    #[test]
    fn from_hf_config_preserves_deepstack_index_hf_order() {
        // Phase-2c regression for Codex Phase-2b finding #2 (medium):
        // deepstack_visual_indexes was being sort_unstable()'d which
        // would silently mis-attach DeepStack heads for any unsorted
        // producer config (peer impls preserve HF order).
        let root = serde_json::json!({
            "vision_config": {
                "model_type": "qwen3_vl",
                "hidden_size": 64,
                "num_hidden_layers": 24,
                "num_attention_heads": 8,
                "patch_size": 16,
                "image_size": 224,
                "intermediate_size": 256,
                // Deliberately UNSORTED — sort_unstable() would have
                // turned this into [3, 7, 11] and silently broken the
                // merger_list.{rel_idx} → absolute remap.
                "deepstack_visual_indexes": [11, 3, 7]
            }
        });
        let vc = VisionConfig::from_hf_config(&root).unwrap();
        assert_eq!(
            vc.deepstack_visual_indexes,
            Some(vec![11, 3, 7]),
            "Phase-2c: HF order must be preserved (peer impls index by \
             enumerate() / by .index() — sorting silently breaks the \
             relative-to-absolute remap for unsorted producers)"
        );
    }
}