Skip to main content

lattice_embed/
vision.rs

1//! Image (and image+text) embedding through the Qwen3.5 vision-language
2//! pooled-embedding pipeline (ADR-069 S5, #1007).
3//!
4//! This is a wire-through: [`VisionEmbeddingModel::embed_image`] and
5//! [`VisionEmbeddingModel::embed_text`] call straight into
6//! `lattice_inference::vision::embed_image_from_bytes_f16` /
7//! `lattice_inference::forward::cpu_f16::embed_text_vlm_f16`, the same
8//! pooling + L2-normalization contract #1007 established. No new math lives
9//! here — only checkpoint loading (mirroring the directory-loading pattern
10//! `service::native` uses for the BERT/Qwen text models) and error mapping.
11//!
12//! [`VisionEmbeddingModel::from_directory`] supports only checkpoints that
13//! carry a `model.safetensors.index.json` (or `quantize_index.json`)
14//! manifest naming exactly one decoder shard (matches the Qwen3.5-0.8B
15//! checkpoint shape) — the vision-tensor loader requires that manifest and
16//! runs before decoder-shard resolution, so a directory with only a plain
17//! `model.safetensors` and no manifest is rejected. Callers with pre-loaded
18//! components, or a multi-shard checkpoint, can assemble their own weights
19//! and call [`VisionEmbeddingModel::new`] directly.
20
21use crate::error::{EmbedError, Result};
22use lattice_inference::InferenceError;
23use lattice_inference::model::qwen35_config::Qwen35Config;
24use lattice_inference::tokenizer::bpe::BpeTokenizer;
25use lattice_inference::vision::checkpoint::{Qwen35VisionWeights, load_qwen35_vision_weights};
26use lattice_inference::vision::embed_image_from_bytes_f16;
27use lattice_inference::weights::SafetensorsFile;
28use lattice_inference::weights::f16_weights::{F16ModelWeights, load_f16_weights};
29use std::path::Path;
30
31pub use lattice_inference::forward::cpu_f16::PoolingStrategy;
32
33/// A loaded Qwen3.5 vision-language checkpoint, ready to pool image (and
34/// image+text) embeddings.
35///
36/// See [`docs/model.md`](../docs/model.md) for the general model-loading design; this type
37/// follows the same "load once, reuse" shape as `NativeEmbeddingService`'s wrapped models.
38pub struct VisionEmbeddingModel {
39    weights: F16ModelWeights,
40    config: Qwen35Config,
41    vision_weights: Qwen35VisionWeights,
42    tokenizer: BpeTokenizer,
43}
44
45impl VisionEmbeddingModel {
46    /// Compose a model from already-loaded components (no I/O).
47    ///
48    /// Use this when the checkpoint spans multiple safetensors shards (not
49    /// supported by [`Self::from_directory`]) or when components are shared
50    /// across other in-process model instances.
51    pub fn new(
52        weights: F16ModelWeights,
53        config: Qwen35Config,
54        vision_weights: Qwen35VisionWeights,
55        tokenizer: BpeTokenizer,
56    ) -> Self {
57        Self {
58            weights,
59            config,
60            vision_weights,
61            tokenizer,
62        }
63    }
64
65    /// Load a Qwen3.5 vision-language checkpoint directory: `config.json`,
66    /// `tokenizer.json`, the `model.visual.*` vision-encoder tensors, and a
67    /// single-shard decoder checkpoint. The directory must carry a
68    /// `model.safetensors.index.json` (or `quantize_index.json`) manifest
69    /// naming exactly one decoder shard file — the vision-tensor loader
70    /// requires one of those manifests and runs before decoder-shard
71    /// resolution, so a plain `model.safetensors` alone (with no manifest)
72    /// is not sufficient. The canonical Qwen3.5-0.8B HF layout (a one-shard
73    /// index) satisfies this.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`EmbedError::ModelInitialization`] if `config.json` is
78    /// missing or invalid, if the checkpoint has no `vision_config`, if
79    /// neither manifest is present, if the decoder weights are sharded
80    /// across more than one file, or if any component tensor fails to load.
81    pub fn from_directory(dir: &Path) -> Result<Self> {
82        let config = Qwen35Config::from_model_dir(dir)
83            .map_err(|e| EmbedError::ModelInitialization(format!("config.json: {e}")))?;
84        let vision_cfg = config.vision_config.clone().ok_or_else(|| {
85            EmbedError::ModelInitialization(format!(
86                "{} has no vision_config; not a vision-language checkpoint",
87                dir.display()
88            ))
89        })?;
90
91        let vision_weights = load_qwen35_vision_weights(dir, &vision_cfg)
92            .map_err(|e| EmbedError::ModelInitialization(format!("vision weights: {e}")))?;
93
94        let shard_path = resolve_single_shard(dir)?;
95        let sf = SafetensorsFile::open(&shard_path).map_err(|e| {
96            EmbedError::ModelInitialization(format!("opening {}: {e}", shard_path.display()))
97        })?;
98        let weights = load_f16_weights(&sf, &config)
99            .map_err(|e| EmbedError::ModelInitialization(format!("decoder weights: {e}")))?;
100
101        let tokenizer_path = dir.join("tokenizer.json");
102        let tokenizer = BpeTokenizer::from_tokenizer_json(&tokenizer_path).map_err(|e| {
103            EmbedError::ModelInitialization(format!("{}: {e}", tokenizer_path.display()))
104        })?;
105
106        Ok(Self::new(weights, config, vision_weights, tokenizer))
107    }
108
109    /// Pool an image (plus an optional text prompt) into a single
110    /// L2-normalized `[dimensions()]` embedding vector.
111    ///
112    /// Same scaffold and pooling contract as
113    /// [`lattice_inference::vision::embed_image_from_bytes_f16`] (see that
114    /// function's docs for the exact prompt-assembly layout).
115    ///
116    /// # Errors
117    ///
118    /// Returns [`EmbedError::InvalidInput`] if `image_bytes` cannot be
119    /// decoded, its dimensions are not compatible with the checkpoint's
120    /// patch/merge geometry, or the assembled request otherwise fails
121    /// validation (the error message names the offending field). Returns
122    /// [`EmbedError::InferenceFailed`] for every other underlying failure —
123    /// e.g. the prompt plus image tokens exceeding the checkpoint's context
124    /// window.
125    pub fn embed_image(
126        &self,
127        image_bytes: &[u8],
128        prompt: &str,
129        pooling: PoolingStrategy,
130    ) -> Result<Vec<f32>> {
131        embed_image_from_bytes_f16(
132            &self.weights,
133            &self.config,
134            &self.vision_weights,
135            &self.tokenizer,
136            image_bytes,
137            prompt,
138            pooling,
139        )
140        .map_err(map_inference_error)
141    }
142
143    /// Pool a text-only prompt through the same decoder + pooling path as
144    /// [`Self::embed_image`], landing in the same vector space.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`EmbedError::InvalidInput`] if the prompt is empty or
149    /// tokenizes to an out-of-vocabulary id. Returns
150    /// [`EmbedError::InferenceFailed`] for every other underlying failure —
151    /// e.g. the prompt exceeding the checkpoint's context window.
152    pub fn embed_text(&self, prompt: &str, pooling: PoolingStrategy) -> Result<Vec<f32>> {
153        lattice_inference::forward::cpu_f16::embed_text_vlm_f16(
154            &self.weights,
155            &self.config,
156            &self.tokenizer,
157            prompt,
158            pooling,
159        )
160        .map_err(map_inference_error)
161    }
162
163    /// Output embedding dimension (the checkpoint's decoder hidden size).
164    pub fn dimensions(&self) -> usize {
165        self.config.hidden_size
166    }
167}
168
169/// Map an inference-layer error to the embed crate's two-variant contract:
170/// caller-supplied-input problems stay distinguishable from every other
171/// (model/runtime) failure, so callers can tell "fix your request" apart
172/// from "retry or report a bug" (see `embed_image`/`embed_text` docs).
173fn map_inference_error(e: InferenceError) -> EmbedError {
174    match e {
175        InferenceError::InvalidInput(msg) => EmbedError::InvalidInput(msg),
176        other => EmbedError::InferenceFailed(other.to_string()),
177    }
178}
179
180/// Resolve the single safetensors shard `load_f16_weights` needs. By the time
181/// this runs, [`load_qwen35_vision_weights`] has already required a
182/// `model.safetensors.index.json` or `quantize_index.json` manifest to exist
183/// in `model_dir` (see module docs) — so a convenience `model.safetensors`
184/// file (often a symlink some local checkouts add alongside the manifest) is
185/// checked first as a cheap shortcut when present, then falls back to
186/// resolving the shard named by the index. This mirrors
187/// `Qwen35Model::from_safetensors`'s plain-then-index precedence, but
188/// resolves the concrete shard path a single-`SafetensorsFile` loader needs
189/// (multi-shard checkpoints are out of scope here — see module docs).
190fn resolve_single_shard(model_dir: &Path) -> Result<std::path::PathBuf> {
191    let plain = model_dir.join("model.safetensors");
192    if plain.exists() {
193        return Ok(plain);
194    }
195    let index = lattice_inference::weights::parse_index(model_dir).map_err(|e| {
196        EmbedError::ModelInitialization(format!(
197            "no model.safetensors in {} and no valid model.safetensors.index.json: {e}",
198            model_dir.display()
199        ))
200    })?;
201    let mut shards: Vec<&str> = index.weight_map.values().map(String::as_str).collect();
202    shards.sort_unstable();
203    shards.dedup();
204    match shards.as_slice() {
205        [one] => Ok(model_dir.join(one)),
206        [] => Err(EmbedError::ModelInitialization(format!(
207            "empty weight_map in {}",
208            model_dir.join("model.safetensors.index.json").display()
209        ))),
210        _ => Err(EmbedError::ModelInitialization(format!(
211            "checkpoint at {} is sharded across {} files; VisionEmbeddingModel::from_directory \
212             only supports single-shard checkpoints -- use VisionEmbeddingModel::new with \
213             manually loaded components instead",
214            model_dir.display(),
215            shards.len()
216        ))),
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use lattice_inference::model::qwen35_config::{LayerType, RopeParams, VisionModelConfig};
224    use lattice_inference::vision::checkpoint::{VisualBlockWeights, VisualMergerWeights};
225    use lattice_inference::weights::f16_weights::{
226        F16AttentionWeights, F16CommonLayerWeights, F16FeedForwardWeights,
227        F16FullAttentionLayerWeights, f32_to_f16_slice,
228    };
229
230    /// Deterministic pseudo-random f32 fill (xorshift LCG), matching the
231    /// fixture builder in `lattice_inference::vision::pooled_embed`'s own
232    /// unit tests, so this crate's wrapper is exercised against
233    /// non-trivial weights without needing a real checkpoint.
234    fn pseudo_random_fill(seed: u32, n: usize) -> Vec<f32> {
235        let mut state = seed | 1;
236        let mut next = move || {
237            state ^= state << 13;
238            state ^= state >> 17;
239            state ^= state << 5;
240            (state as f32 / u32::MAX as f32) * 0.2 - 0.1
241        };
242        (0..n).map(|_| next()).collect()
243    }
244
245    fn tiny_vision_cfg() -> VisionModelConfig {
246        VisionModelConfig {
247            depth: 1,
248            hidden_size: 8,
249            num_heads: 2,
250            patch_size: 2,
251            spatial_merge_size: 2,
252            out_hidden_size: 8, // must equal decoder hidden_size below
253            temporal_patch_size: 1,
254            num_position_embeddings: 16,
255            in_channels: 1,
256            deepstack_visual_indexes: vec![],
257            intermediate_size: None,
258        }
259    }
260
261    fn tiny_vision_weights(vision_cfg: &VisionModelConfig, seed: u32) -> Qwen35VisionWeights {
262        let hidden = vision_cfg.hidden_size;
263        let patch_len = vision_cfg.in_channels
264            * vision_cfg.temporal_patch_size
265            * vision_cfg.patch_size
266            * vision_cfg.patch_size;
267        let mlp_dim = 2 * hidden;
268        let merge_in = vision_cfg.spatial_merge_size * vision_cfg.spatial_merge_size * hidden;
269
270        let block = VisualBlockWeights {
271            qkv_weight: pseudo_random_fill(seed, 3 * hidden * hidden),
272            qkv_bias: pseudo_random_fill(seed.wrapping_add(1), 3 * hidden),
273            proj_weight: pseudo_random_fill(seed.wrapping_add(2), hidden * hidden),
274            proj_bias: pseudo_random_fill(seed.wrapping_add(3), hidden),
275            fc1_weight: pseudo_random_fill(seed.wrapping_add(4), mlp_dim * hidden),
276            fc1_bias: pseudo_random_fill(seed.wrapping_add(5), mlp_dim),
277            fc2_weight: pseudo_random_fill(seed.wrapping_add(6), hidden * mlp_dim),
278            fc2_bias: pseudo_random_fill(seed.wrapping_add(7), hidden),
279            norm1_weight: vec![1.0; hidden],
280            norm1_bias: vec![0.0; hidden],
281            norm2_weight: vec![1.0; hidden],
282            norm2_bias: vec![0.0; hidden],
283        };
284
285        Qwen35VisionWeights {
286            patch_embed_weight: pseudo_random_fill(seed.wrapping_add(8), hidden * patch_len),
287            patch_embed_weight_shape: vec![
288                hidden,
289                vision_cfg.in_channels,
290                vision_cfg.temporal_patch_size,
291                vision_cfg.patch_size,
292                vision_cfg.patch_size,
293            ],
294            patch_embed_bias: pseudo_random_fill(seed.wrapping_add(9), hidden),
295            pos_embed: pseudo_random_fill(
296                seed.wrapping_add(10),
297                vision_cfg.num_position_embeddings * hidden,
298            ),
299            blocks: vec![block],
300            merger: VisualMergerWeights {
301                fc1_weight: pseudo_random_fill(seed.wrapping_add(11), merge_in * merge_in),
302                fc1_bias: pseudo_random_fill(seed.wrapping_add(12), merge_in),
303                fc2_weight: pseudo_random_fill(
304                    seed.wrapping_add(13),
305                    vision_cfg.out_hidden_size * merge_in,
306                ),
307                fc2_bias: pseudo_random_fill(seed.wrapping_add(14), vision_cfg.out_hidden_size),
308                norm_weight: vec![1.0; hidden],
309                norm_bias: vec![0.0; hidden],
310            },
311        }
312    }
313
314    /// A minimal one-layer full-attention decoder + vision config wired
315    /// together: small enough to hand-construct, non-trivial (pseudo-random)
316    /// projections so the pipeline is actually exercised end to end.
317    fn tiny_vlm_fixture() -> (Qwen35Config, F16ModelWeights, Qwen35VisionWeights) {
318        let hidden = 8usize;
319        let vocab = 16usize;
320        let vision_cfg = tiny_vision_cfg();
321
322        let cfg = Qwen35Config {
323            hidden_size: hidden,
324            num_hidden_layers: 1,
325            vocab_size: vocab,
326            intermediate_size: 4,
327            rms_norm_eps: 1e-6,
328            num_attention_heads: 1,
329            num_key_value_heads: 1,
330            head_dim: hidden,
331            rope_theta: 1.0e7,
332            partial_rotary_factor: 1.0,
333            rope_parameters: Some(RopeParams {
334                rope_theta: 1.0e7,
335                partial_rotary_factor: Some(1.0),
336                mrope_section: Some(vec![2, 1, 1]),
337                mrope_interleaved: Some(true),
338            }),
339            linear_num_key_heads: 2,
340            linear_num_value_heads: Some(2),
341            linear_key_head_dim: 32,
342            linear_value_head_dim: 32,
343            linear_conv_kernel_dim: 4,
344            num_experts: None,
345            num_experts_per_tok: None,
346            moe_intermediate_size: None,
347            shared_expert_intermediate_size: None,
348            output_router_logits: false,
349            router_aux_loss_coef: None,
350            tie_word_embeddings: true,
351            full_attention_interval: 1,
352            layer_types: vec![LayerType::FullAttention],
353            layer_mask: vec![true],
354            eos_token_id: 999,
355            max_position_embeddings: 512,
356            mtp_num_hidden_layers: 0,
357            mtp_use_dedicated_embeddings: false,
358            quarot_rotation_seed: None,
359            vision_config: Some(vision_cfg.clone()),
360            image_token_id: Some(9),
361            video_token_id: None,
362            vision_start_token_id: Some(10),
363            vision_end_token_id: Some(11),
364        };
365
366        let to_f16 = |src: &[f32]| -> Vec<u16> {
367            let mut dst = vec![0u16; src.len()];
368            f32_to_f16_slice(src, &mut dst);
369            dst
370        };
371
372        let embed_tokens_f32 = pseudo_random_fill(777, vocab * hidden);
373        let q_dim = cfg.full_q_dim();
374        let kv_dim = cfg.full_kv_dim();
375        let full_weights = F16FullAttentionLayerWeights {
376            q_proj: to_f16(&pseudo_random_fill(101, 2 * q_dim * hidden)),
377            k_proj: to_f16(&pseudo_random_fill(102, kv_dim * hidden)),
378            v_proj: to_f16(&pseudo_random_fill(103, kv_dim * hidden)),
379            o_proj: to_f16(&pseudo_random_fill(104, hidden * q_dim)),
380            q_norm: vec![0.0f32; hidden],
381            k_norm: vec![0.0f32; hidden],
382        };
383        let common = F16CommonLayerWeights {
384            input_layernorm: vec![0.0f32; hidden],
385            post_attention_layernorm: vec![0.0f32; hidden],
386            ffn: F16FeedForwardWeights::Dense {
387                gate_proj: to_f16(&vec![0.0f32; 4 * hidden]),
388                up_proj: to_f16(&vec![0.0f32; 4 * hidden]),
389                down_proj: to_f16(&vec![0.0f32; hidden * 4]),
390            },
391        };
392        let weights = F16ModelWeights {
393            embed_tokens: to_f16(&embed_tokens_f32),
394            final_norm: vec![0.0f32; hidden],
395            layers: vec![(F16AttentionWeights::Full(full_weights), common)],
396        };
397
398        let vision_weights = tiny_vision_weights(&vision_cfg, 555);
399        (cfg, weights, vision_weights)
400    }
401
402    fn make_test_png(w: u32, h: u32, seed: u8) -> Vec<u8> {
403        use image::RgbImage;
404        let mut img = RgbImage::new(w, h);
405        for y in 0..h {
406            for x in 0..w {
407                let v = ((x + y + seed as u32) % 256) as u8;
408                img.put_pixel(x, y, image::Rgb([v, v, v]));
409            }
410        }
411        let mut buf = Vec::new();
412        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
413            .unwrap();
414        buf
415    }
416
417    fn tiny_tokenizer() -> BpeTokenizer {
418        let mut vocab_map = std::collections::HashMap::new();
419        for (i, c) in ["describe", "this", "image"].iter().enumerate() {
420            vocab_map.insert((*c).to_string(), i as u32);
421        }
422        BpeTokenizer::from_vocab_and_merges(vocab_map, vec![]).expect("tokenizer constructs")
423    }
424
425    /// Single-character vocab: with no merges, a byte-level BPE tokenizer
426    /// falls back to per-character tokens, so (unlike `tiny_tokenizer`'s
427    /// whole-word entries) this actually produces non-empty `real_length`
428    /// output — required by `embed_text_vlm_f16`'s empty-prompt guard.
429    /// Mirrors the tokenizer `cpu_f16.rs`'s own `embed_text_vlm_f16` tests use.
430    fn single_char_tokenizer() -> BpeTokenizer {
431        let mut vocab_map = std::collections::HashMap::new();
432        for (i, c) in ["a", "b", "c"].iter().enumerate() {
433            vocab_map.insert((*c).to_string(), i as u32);
434        }
435        BpeTokenizer::from_vocab_and_merges(vocab_map, vec![]).expect("tokenizer constructs")
436    }
437
438    /// The embed-crate wrapper must return the exact same vector as calling
439    /// the raw inference-crate primitive directly: wiring adds no numerical
440    /// difference. This is the core claim of this module (a wire-through,
441    /// not a reimplementation).
442    #[test]
443    fn embed_image_matches_raw_inference_primitive() {
444        let (cfg, weights, vision_weights) = tiny_vlm_fixture();
445        let tokenizer = tiny_tokenizer();
446        let png = make_test_png(8, 8, 0);
447
448        let model = VisionEmbeddingModel::new(
449            weights.clone(),
450            cfg.clone(),
451            vision_weights.clone(),
452            tokenizer.clone(),
453        );
454        let via_wrapper = model
455            .embed_image(
456                &png,
457                "describe this image",
458                PoolingStrategy::MeanVisualTokens,
459            )
460            .expect("wrapper embed_image succeeds");
461
462        let via_raw = embed_image_from_bytes_f16(
463            &weights,
464            &cfg,
465            &vision_weights,
466            &tokenizer,
467            &png,
468            "describe this image",
469            PoolingStrategy::MeanVisualTokens,
470        )
471        .expect("raw primitive succeeds");
472
473        assert_eq!(
474            via_wrapper, via_raw,
475            "embed-crate wrapper must return the identical vector to the raw primitive"
476        );
477    }
478
479    #[test]
480    fn embed_image_is_deterministic_and_normalized() {
481        let (cfg, weights, vision_weights) = tiny_vlm_fixture();
482        let tokenizer = tiny_tokenizer();
483        let png = make_test_png(8, 8, 0);
484        let model = VisionEmbeddingModel::new(weights, cfg.clone(), vision_weights, tokenizer);
485
486        let v1 = model
487            .embed_image(
488                &png,
489                "describe this image",
490                PoolingStrategy::MeanVisualTokens,
491            )
492            .expect("embed succeeds");
493        let v2 = model
494            .embed_image(
495                &png,
496                "describe this image",
497                PoolingStrategy::MeanVisualTokens,
498            )
499            .expect("embed succeeds");
500
501        assert_eq!(
502            v1, v2,
503            "same image + prompt must produce an identical vector"
504        );
505        assert_eq!(v1.len(), model.dimensions());
506        assert!(v1.iter().all(|x| x.is_finite()));
507        let norm: f32 = v1.iter().map(|x| x * x).sum::<f32>().sqrt();
508        assert!((norm - 1.0).abs() < 1e-4, "expected unit norm, got {norm}");
509    }
510
511    #[test]
512    fn embed_image_rejects_non_vlm_checkpoint() {
513        let (mut cfg, weights, vision_weights) = tiny_vlm_fixture();
514        cfg.vision_config = None;
515        let tokenizer = tiny_tokenizer();
516        let png = make_test_png(8, 8, 0);
517        let model = VisionEmbeddingModel::new(weights, cfg, vision_weights, tokenizer);
518
519        let err = model
520            .embed_image(
521                &png,
522                "describe this image",
523                PoolingStrategy::MeanVisualTokens,
524            )
525            .expect_err("a checkpoint with no vision_config must be rejected");
526        let msg = err.to_string();
527        assert!(matches!(err, EmbedError::InvalidInput(_)));
528        assert!(
529            msg.contains("vision_config"),
530            "error must name the missing field, got: {msg}"
531        );
532    }
533
534    #[test]
535    fn embed_image_rejects_misaligned_image() {
536        let (cfg, weights, vision_weights) = tiny_vlm_fixture();
537        let tokenizer = tiny_tokenizer();
538        // factor = patch_size(2) * merge(2) = 4; 6 is not a multiple of 4.
539        let png = make_test_png(6, 4, 0);
540        let model = VisionEmbeddingModel::new(weights, cfg, vision_weights, tokenizer);
541
542        let err = model
543            .embed_image(
544                &png,
545                "describe this image",
546                PoolingStrategy::MeanVisualTokens,
547            )
548            .expect_err("a misaligned image must be rejected, not panic");
549        assert!(matches!(err, EmbedError::InvalidInput(_)));
550    }
551
552    #[test]
553    fn embed_text_matches_raw_inference_primitive() {
554        let (cfg, weights, vision_weights) = tiny_vlm_fixture();
555        let tokenizer = single_char_tokenizer();
556        let model = VisionEmbeddingModel::new(
557            weights.clone(),
558            cfg.clone(),
559            vision_weights,
560            tokenizer.clone(),
561        );
562
563        let via_wrapper = model
564            .embed_text("abc", PoolingStrategy::LastToken)
565            .expect("wrapper embed_text succeeds");
566        let via_raw = lattice_inference::forward::cpu_f16::embed_text_vlm_f16(
567            &weights,
568            &cfg,
569            &tokenizer,
570            "abc",
571            PoolingStrategy::LastToken,
572        )
573        .expect("raw primitive succeeds");
574
575        assert_eq!(via_wrapper, via_raw);
576    }
577
578    /// A runtime (non-input) failure -- the prompt exceeding the checkpoint's
579    /// context window, surfaced as `InferenceError::Inference` from the
580    /// shared prefill path (cpu_f16.rs) -- must map to
581    /// `EmbedError::InferenceFailed`, not `EmbedError::InvalidInput`: the
582    /// prompt itself is well-formed, the checkpoint just can't fit it.
583    #[test]
584    fn embed_text_maps_context_overflow_to_inference_failed() {
585        let (mut cfg, weights, vision_weights) = tiny_vlm_fixture();
586        cfg.max_position_embeddings = 1;
587        let tokenizer = single_char_tokenizer();
588        let model = VisionEmbeddingModel::new(weights, cfg, vision_weights, tokenizer);
589
590        let err = model
591            .embed_text("abc", PoolingStrategy::LastToken)
592            .expect_err("a prompt longer than max_position_embeddings must fail");
593        assert!(
594            matches!(err, EmbedError::InferenceFailed(_)),
595            "context-window overflow is a runtime failure, not caller-input validation, got: {err:?}"
596        );
597        assert!(
598            err.to_string().contains("context window"),
599            "error should retain the underlying context-window detail, got: {err}"
600        );
601    }
602
603    #[test]
604    fn resolve_single_shard_rejects_multi_shard_index() {
605        let tmp = tempfile::tempdir().expect("tempdir");
606        let index_path = tmp.path().join("model.safetensors.index.json");
607        std::fs::write(
608            &index_path,
609            r#"{"metadata":{},"weight_map":{"a":"shard1.safetensors","b":"shard2.safetensors"}}"#,
610        )
611        .expect("write index");
612
613        let err = resolve_single_shard(tmp.path()).expect_err("multi-shard must be rejected");
614        let msg = err.to_string();
615        assert!(msg.contains("sharded across 2 files"), "got: {msg}");
616    }
617
618    #[test]
619    fn resolve_single_shard_rejects_missing_manifest() {
620        let tmp = tempfile::tempdir().expect("tempdir");
621        let err = resolve_single_shard(tmp.path()).expect_err("missing manifest must be rejected");
622        assert!(matches!(err, EmbedError::ModelInitialization(_)));
623    }
624
625    /// `from_directory`'s documented contract requires an index/quantize
626    /// manifest (the vision-tensor loader runs before decoder-shard
627    /// resolution and has no plain-file fallback). A directory with a valid
628    /// config.json (including `vision_config`) but no manifest at all must
629    /// fail with an actionable, named error -- not merely `expect_err` on
630    /// some opaque error -- pinning the real (manifest-required) behavior
631    /// rather than the previously-documented (plain-file-sufficient) one.
632    #[test]
633    fn from_directory_without_manifest_reports_actionable_error() {
634        let tmp = tempfile::tempdir().expect("tempdir");
635        let config_json = include_str!(concat!(
636            env!("CARGO_MANIFEST_DIR"),
637            "/../inference/tests/fixtures/qwen35_0_8b_config.json"
638        ));
639        std::fs::write(tmp.path().join("config.json"), config_json).expect("write config.json");
640
641        let Err(err) = VisionEmbeddingModel::from_directory(tmp.path()) else {
642            panic!("a directory with no index/quantize manifest must be rejected")
643        };
644        assert!(matches!(err, EmbedError::ModelInitialization(_)));
645        let msg = err.to_string();
646        assert!(
647            msg.contains("model.safetensors.index.json") && msg.contains("quantize_index.json"),
648            "error must name the missing manifest(s), got: {msg}"
649        );
650    }
651}