use crate::models::lazy_gemma::GemmaModel;
use crate::models::lazy_paligemma::{PaligemmaConfig, PaligemmaModel, PaligemmaWeights};
use fuel_ir::Shape;
use fuel_ir::error::Result;
use fuel_tensor::{lazy::Tensor, lazy::WeightStorage};
use std::sync::Arc;
pub const COLPALI_PROJ_DIM: usize = 128;
#[derive(Debug, Clone)]
pub struct ColPaliWeights {
pub paligemma: PaligemmaWeights,
pub custom_text_projection: WeightStorage,
pub custom_text_projection_bias: Arc<[f32]>,
}
#[derive(Debug, Clone)]
pub struct ColPaliModel {
pub config: PaligemmaConfig,
pub weights: ColPaliWeights,
}
impl ColPaliModel {
pub fn forward_images(&self, pixel_values: &Tensor, text_tokens: &[u32]) -> Result<Tensor> {
let cfg = &self.config;
let pg = PaligemmaModel {
config: cfg.clone(),
weights: self.weights.paligemma.clone(),
};
let hidden = pg.forward_hidden(pixel_values, text_tokens)?;
self.project_and_normalize(&hidden, pixel_values)
}
pub fn forward_text(&self, text_tokens: &[u32]) -> Result<Tensor> {
let cfg = &self.config;
let gemma = GemmaModel {
config: cfg.text_config.clone(),
weights: self.weights.paligemma.text.clone(),
};
let hidden = gemma.forward_hidden(text_tokens, 0)?;
self.project_and_normalize(&hidden, &hidden)
}
fn project_and_normalize(&self, hidden: &Tensor, anchor: &Tensor) -> Result<Tensor> {
let cfg = &self.config;
let h_dim = cfg.text_config.hidden_size;
let projected =
self.weights
.custom_text_projection
.apply_linear(hidden, h_dim, COLPALI_PROJ_DIM)?;
let bias = anchor.const_f32_like(
Arc::clone(&self.weights.custom_text_projection_bias),
Shape::from_dims(&[COLPALI_PROJ_DIM]),
)?;
let biased = projected.broadcast_add(&bias)?;
l2_normalize_last(&biased, 1e-12)
}
}
fn l2_normalize_last(x: &Tensor, eps: f64) -> Result<Tensor> {
let last = x.shape().dims().len() - 1;
x.l2_normalize(last, eps)
}
impl ColPaliWeights {
pub fn load_from_mmapped(
st: &fuel_loaders::safetensors::MmapedSafetensors,
cfg: &PaligemmaConfig,
) -> Result<Self> {
use fuel_tensor::{lazy::load_tensor_as_f32, lazy::load_transposed_matrix_preserve_dtype};
let paligemma = PaligemmaWeights::load_from_mmapped(st, cfg)?;
let text_hidden = cfg.text_config.hidden_size;
let custom_text_projection = load_transposed_matrix_preserve_dtype(
st,
"custom_text_proj.weight",
COLPALI_PROJ_DIM,
text_hidden,
)?;
let custom_text_projection_bias =
Arc::from(load_tensor_as_f32(st, "custom_text_proj.bias")?);
Ok(Self {
paligemma,
custom_text_projection,
custom_text_projection_bias,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::lazy_gemma::{GemmaConfig, GemmaWeights};
use crate::models::lazy_paligemma::PaligemmaWeights;
use crate::models::lazy_siglip::{SiglipActivation, SiglipVisionConfig, SiglipVisionWeights};
use fuel_tensor::Device;
fn vec_of(n: usize, next: &mut dyn FnMut() -> f32) -> Arc<[f32]> {
Arc::from((0..n).map(|_| next()).collect::<Vec<_>>())
}
fn rng_seed(seed: u32) -> impl FnMut() -> f32 {
let mut s = seed;
move || {
s = s.wrapping_mul(1103515245).wrapping_add(12345);
((s >> 16) as u16 as f32 / 65535.0 - 0.5) * 0.05
}
}
fn ws(n: usize, nb: &mut dyn FnMut() -> f32) -> WeightStorage {
WeightStorage::F32(vec_of(n, nb))
}
fn tiny_vision_cfg() -> SiglipVisionConfig {
SiglipVisionConfig {
hidden_size: 8,
intermediate_size: 16,
num_hidden_layers: 1,
num_attention_heads: 2,
num_channels: 3,
image_size: 8,
patch_size: 4,
hidden_activation: SiglipActivation::GeluPytorchTanh,
layer_norm_eps: 1e-6,
}
}
fn tiny_text_cfg() -> GemmaConfig {
GemmaConfig {
vocab_size: 16,
hidden_size: 8,
intermediate_size: 16,
num_hidden_layers: 2,
num_attention_heads: 2,
num_key_value_heads: 1,
head_dim: 4,
rms_norm_eps: 1e-6,
rope_theta: 10_000.0,
max_position_embeddings: 64,
attention_bias: false,
hidden_activation: crate::models::lazy_gemma::GemmaActivation::GeluPytorchTanh,
}
}
fn tiny_colpali_weights(v_cfg: &SiglipVisionConfig, t_cfg: &GemmaConfig) -> ColPaliWeights {
use fuel_tensor::lazy::LayerWeights;
let mut nb = rng_seed(2026);
let v_h = v_cfg.hidden_size;
let i_size = v_cfg.intermediate_size;
let np = v_cfg.num_patches();
let layers: Vec<crate::models::lazy_siglip::SiglipEncoderLayerWeights> = (0..v_cfg
.num_hidden_layers)
.map(|_| crate::models::lazy_siglip::SiglipEncoderLayerWeights {
ln1_gain: Arc::from(vec![1.0_f32; v_h]),
ln1_bias: Arc::from(vec![0.0_f32; v_h]),
q_proj: ws(v_h * v_h, &mut nb),
q_proj_bias: vec_of(v_h, &mut nb),
k_proj: ws(v_h * v_h, &mut nb),
k_proj_bias: vec_of(v_h, &mut nb),
v_proj: ws(v_h * v_h, &mut nb),
v_proj_bias: vec_of(v_h, &mut nb),
out_proj: ws(v_h * v_h, &mut nb),
out_proj_bias: vec_of(v_h, &mut nb),
ln2_gain: Arc::from(vec![1.0_f32; v_h]),
ln2_bias: Arc::from(vec![0.0_f32; v_h]),
fc1: ws(v_h * i_size, &mut nb),
fc1_bias: vec_of(i_size, &mut nb),
fc2: ws(i_size * v_h, &mut nb),
fc2_bias: vec_of(v_h, &mut nb),
})
.collect();
let vision = SiglipVisionWeights {
patch_proj: vec_of(
v_h * v_cfg.num_channels * v_cfg.patch_size * v_cfg.patch_size,
&mut nb,
),
patch_proj_bias: vec_of(v_h, &mut nb),
position_embedding: vec_of(np * v_h, &mut nb),
layers,
post_ln_gain: Arc::from(vec![1.0_f32; v_h]),
post_ln_bias: Arc::from(vec![0.0_f32; v_h]),
head: None,
};
let t_h = t_cfg.hidden_size;
let i_t = t_cfg.intermediate_size;
let n_kv = t_cfg.num_key_value_heads;
let kv_dim = n_kv * t_cfg.head_dim;
let gemma_layers: Vec<LayerWeights> = (0..t_cfg.num_hidden_layers)
.map(|_| LayerWeights {
attn_q: ws(t_h * t_h, &mut nb),
attn_q_bias: None,
attn_k: ws(t_h * kv_dim, &mut nb),
attn_k_bias: None,
attn_v: ws(t_h * kv_dim, &mut nb),
attn_v_bias: None,
attn_o: ws(t_h * t_h, &mut nb),
ffn_gate: ws(t_h * i_t, &mut nb),
ffn_up: ws(t_h * i_t, &mut nb),
ffn_down: ws(i_t * t_h, &mut nb),
attn_norm_gain: Arc::from(vec![1.0_f32; t_h]),
ffn_norm_gain: Arc::from(vec![1.0_f32; t_h]),
})
.collect();
let text = GemmaWeights {
token_embedding: vec_of(t_cfg.vocab_size * t_h, &mut nb),
layers: gemma_layers,
final_norm_gain: Arc::from(vec![1.0_f32; t_h]),
output: ws(t_h * t_cfg.vocab_size, &mut nb),
};
let paligemma = PaligemmaWeights {
vision,
mm_proj: ws(v_h * t_h, &mut nb),
mm_proj_bias: vec_of(t_h, &mut nb),
text,
};
ColPaliWeights {
paligemma,
custom_text_projection: ws(t_h * COLPALI_PROJ_DIM, &mut nb),
custom_text_projection_bias: vec_of(COLPALI_PROJ_DIM, &mut nb),
}
}
fn tiny_cfg() -> PaligemmaConfig {
PaligemmaConfig {
vision_config: tiny_vision_cfg(),
text_config: tiny_text_cfg(),
projection_dim: 8, }
}
#[test]
fn forward_text_shape_and_l2_normalized() {
let cfg = tiny_cfg();
let weights = tiny_colpali_weights(&cfg.vision_config, &cfg.text_config);
let model = ColPaliModel {
config: cfg.clone(),
weights,
};
let tokens = vec![1_u32, 2, 3, 4];
let out = model.forward_text(&tokens).unwrap();
assert_eq!(out.shape().dims(), &[1, tokens.len(), COLPALI_PROJ_DIM]);
let data = out.realize_f32();
for t in 0..tokens.len() {
let mut sum_sq = 0.0_f32;
for d in 0..COLPALI_PROJ_DIM {
let v = data[t * COLPALI_PROJ_DIM + d];
sum_sq += v * v;
assert!(v.is_finite(), "non-finite at t={t} d={d}: {v}");
}
assert!(
(sum_sq - 1.0).abs() < 1e-4,
"L2 norm not unit at t={t}: sum_sq = {sum_sq}"
);
}
}
#[test]
fn forward_images_shape_and_l2_normalized() {
let cfg = tiny_cfg();
let weights = tiny_colpali_weights(&cfg.vision_config, &cfg.text_config);
let model = ColPaliModel {
config: cfg.clone(),
weights,
};
let img_size = cfg.vision_config.image_size;
let pixel_values = Tensor::from_f32(
(0..(3 * img_size * img_size))
.map(|i| (i as f32) * 0.01)
.collect::<Vec<_>>(),
Shape::from_dims(&[1, 3, img_size, img_size]),
&Device::cpu(),
)
.unwrap();
let tokens = vec![5_u32, 6, 7];
let out = model.forward_images(&pixel_values, &tokens).unwrap();
let np = cfg.vision_config.num_patches();
assert_eq!(
out.shape().dims(),
&[1, np + tokens.len(), COLPALI_PROJ_DIM]
);
let data = out.realize_f32();
for t in 0..(np + tokens.len()) {
let mut sum_sq = 0.0_f32;
for d in 0..COLPALI_PROJ_DIM {
let v = data[t * COLPALI_PROJ_DIM + d];
sum_sq += v * v;
assert!(v.is_finite(), "non-finite at t={t} d={d}: {v}");
}
assert!(
(sum_sq - 1.0).abs() < 1e-4,
"image embeddings L2 norm not unit at t={t}: sum_sq = {sum_sq}"
);
}
}
#[test]
fn forward_text_responds_to_tokens() {
let cfg = tiny_cfg();
let weights = tiny_colpali_weights(&cfg.vision_config, &cfg.text_config);
let model = ColPaliModel {
config: cfg,
weights,
};
let a = model.forward_text(&[1_u32, 2, 3]).unwrap().realize_f32();
let b = model.forward_text(&[5_u32, 6, 7]).unwrap().realize_f32();
let mut max_diff = 0.0_f32;
for (x, y) in a.iter().zip(b.iter()) {
max_diff = max_diff.max((x - y).abs());
}
assert!(
max_diff > 1e-7,
"text embeddings must respond to token changes, max_diff = {max_diff}"
);
}
}