1use 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
33pub struct VisionEmbeddingModel {
39 weights: F16ModelWeights,
40 config: Qwen35Config,
41 vision_weights: Qwen35VisionWeights,
42 tokenizer: BpeTokenizer,
43}
44
45impl VisionEmbeddingModel {
46 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 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 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 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 pub fn dimensions(&self) -> usize {
165 self.config.hidden_size
166 }
167}
168
169fn 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
180fn 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 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, 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 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 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 #[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 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 #[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 #[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}