#![cfg(feature = "remote")]
mod common;
use cera::engine::{BackendPreference, CeraEngine, EngineConfig, ModelFiles};
use cera::manifest::InferenceType;
use cera::tokenizer::{ChatMessage, ChatMessageMultimodal, ContentItem};
use cera::{FinishReason, GenerateOpts, ModalitySink};
const MAIN_URL: &str =
"https://huggingface.co/LiquidAI/LFM2.5-VL-450M-GGUF/resolve/main/LFM2.5-VL-450M-Q4_0.gguf";
const MAIN_FILE: &str = "LFM2.5-VL-450M-Q4_0.gguf";
const MMPROJ_URL: &str = "https://huggingface.co/LiquidAI/LFM2.5-VL-450M-GGUF/resolve/main/mmproj-LFM2.5-VL-450m-Q8_0.gguf";
const MMPROJ_FILE: &str = "mmproj-LFM2.5-VL-450m-Q8_0.gguf";
#[test]
#[ignore = "downloads ~310 MB across two GGUFs; set CERA_TEST_DOWNLOAD=1 and pass --ignored"]
fn vl_bundle_loads_text_only() {
if std::env::var("CERA_TEST_DOWNLOAD").is_err() {
eprintln!("skipping: CERA_TEST_DOWNLOAD not set");
return;
}
let main = common::download::ensure_cached(MAIN_URL, MAIN_FILE);
let mmproj = common::download::ensure_cached(MMPROJ_URL, MMPROJ_FILE);
assert!(main.exists(), "main GGUF missing at {}", main.display());
assert!(
mmproj.exists(),
"mmproj GGUF missing at {}",
mmproj.display()
);
let mut files = ModelFiles::text(&main);
files.multimodal_projector = Some(mmproj.clone());
files.inference_type = Some(InferenceType::LlamaCppImageToText);
let engine = CeraEngine::from_files(
files,
EngineConfig {
context_size: 256,
backend: BackendPreference::Cpu,
..Default::default()
},
)
.expect("VL bundle should load with the Phase-1 gate open");
let meta = engine.metadata();
assert!(
meta.max_seq_len > 0,
"engine metadata missing max_seq_len — main GGUF parse failed silently"
);
assert_eq!(
meta.architecture, "lfm2",
"main GGUF arch should be plain `lfm2`; got `{}`",
meta.architecture
);
let mmproj_gguf = engine
.vision_encoder_gguf()
.expect("VL bundles must expose the mmproj GGUF through vision_encoder_gguf()");
let arch = mmproj_gguf
.architecture()
.expect("mmproj should expose general.architecture");
assert_eq!(arch, "clip", "mmproj arch should be `clip`; got `{arch}`");
let has_vision = mmproj_gguf
.get_bool("clip.has_vision_encoder")
.unwrap_or(false);
assert!(
has_vision,
"mmproj should set `clip.has_vision_encoder = true`"
);
let ve = engine
.vision_encoder()
.expect("VL bundles must expose typed VisionEncoderWeights via vision_encoder()");
assert_eq!(ve.config.n_layer, 12, "ViT block count");
assert_eq!(ve.config.n_embd, 768, "ViT hidden dim");
assert_eq!(ve.config.n_head, 12, "ViT head count");
assert_eq!(ve.config.n_ff, 3072, "ViT FFN dim");
assert_eq!(ve.config.image_size, 256);
assert_eq!(ve.config.patch_size, 16);
assert_eq!(
ve.config.n_trained_patches, 256,
"trained 16×16 position-grid"
);
assert_eq!(ve.config.image_min_pixels, 65_536, "LFM2-VL min-pixel band");
assert_eq!(
ve.config.image_max_pixels, 262_144,
"LFM2-VL max-pixel band"
);
assert_eq!(ve.config.projection_dim, 1024, "matches LFM2 embed dim");
assert_eq!(ve.config.scale_factor, 2, "pixel-shuffle factor");
assert_eq!(ve.blocks.len(), 12);
for (i, m) in ve.config.image_mean.iter().enumerate() {
assert!(*m > 0.0 && *m < 1.0, "image_mean[{i}] = {m} outside (0, 1)");
}
for (i, s) in ve.config.image_std.iter().enumerate() {
assert!(*s > 0.0 && *s < 1.0, "image_std[{i}] = {s} outside (0, 1)");
}
assert!(
ve.config.eps > 0.0 && ve.config.eps < 1e-3,
"layer_norm_epsilon = {} outside (0, 1e-3)",
ve.config.eps
);
for (i, blk) in ve.blocks.iter().enumerate() {
assert_eq!(blk.q_w.rows, 768, "block {i} q_w rows");
assert_eq!(blk.q_w.cols, 768, "block {i} q_w cols");
assert_eq!(blk.k_w.rows, 768, "block {i} k_w rows");
assert_eq!(blk.v_w.rows, 768, "block {i} v_w rows");
assert_eq!(blk.o_w.rows, 768, "block {i} o_w rows");
assert_eq!(blk.ffn_up_w.rows, 3072, "block {i} ffn_up_w rows");
assert_eq!(blk.ffn_up_w.cols, 768, "block {i} ffn_up_w cols");
assert_eq!(blk.ffn_down_w.rows, 768, "block {i} ffn_down_w rows");
assert_eq!(blk.ffn_down_w.cols, 3072, "block {i} ffn_down_w cols");
assert_eq!(blk.ln1_w.len(), 768, "block {i} ln1_w len");
assert_eq!(blk.ln2_w.len(), 768, "block {i} ln2_w len");
}
assert_eq!(ve.position_embed.len(), 256 * 768);
assert_eq!(ve.projector.mm1_w.rows, 2048);
assert_eq!(ve.projector.mm1_w.cols, 3072);
assert_eq!(ve.projector.mm2_w.rows, 1024);
assert_eq!(ve.projector.mm2_w.cols, 2048);
let tokenizer = engine.tokenizer();
let messages = vec![ChatMessage {
role: "user".to_string(),
content: "Hi".to_string(),
}];
let formatted = cera::tokenizer::apply_chat_template(tokenizer, &messages, true)
.expect("LFM2.5-VL chat template should render after generation-block strip");
let prompt_tokens = tokenizer.encode(&formatted);
assert!(
!prompt_tokens.is_empty(),
"rendered chat template tokenized to nothing — encoder is broken"
);
let mut session = engine.new_session(Default::default());
session
.append_tokens(&prompt_tokens)
.expect("prefill should succeed against a VL bundle's LFM2 LLM");
struct Collect(Vec<u32>);
impl ModalitySink for Collect {
fn on_text_tokens(&mut self, t: &[u32]) {
self.0.extend_from_slice(t);
}
fn on_done(&mut self, _: FinishReason) {}
}
let mut sink = Collect(Vec::new());
let opts = GenerateOpts {
max_tokens: 8,
temperature: 0.0,
..Default::default()
};
session
.generate(&opts, &mut sink)
.expect("greedy decode should succeed against a VL bundle");
assert!(
!sink.0.is_empty(),
"VL bundle produced zero tokens — chat template / forward / sink wiring broken"
);
let ve = engine
.vision_encoder()
.expect("vision_encoder still attached");
let trained_side = (ve.config.n_trained_patches as f64).sqrt().round() as usize;
let n_pix = 3 * (trained_side * ve.config.patch_size).pow(2);
let zeros = vec![0.0f32; n_pix];
let img_tokens = ve
.encode_image(&zeros, trained_side, trained_side)
.expect("encode_image should succeed on a zero-input image");
let expected_n_tokens = (trained_side / ve.config.scale_factor).pow(2);
let expected_n = expected_n_tokens * ve.config.projection_dim;
assert_eq!(img_tokens.len(), expected_n, "image-token output length");
assert!(
img_tokens.iter().all(|v| v.is_finite()),
"encode_image produced non-finite values"
);
let max_abs = img_tokens.iter().fold(0.0f32, |a, &v| a.max(v.abs()));
assert!(
max_abs > 0.0,
"encode_image returned all zeros — forward likely short-circuited"
);
assert!(
max_abs < 1e3,
"encode_image returned implausibly large values (max abs = {max_abs}) — \
numerical blow-up somewhere in the pipeline"
);
}
#[test]
#[ignore = "downloads ~310 MB across two GGUFs; set CERA_TEST_DOWNLOAD=1 and pass --ignored"]
fn vl_bundle_appends_synthetic_image() {
if std::env::var("CERA_TEST_DOWNLOAD").is_err() {
eprintln!("skipping: CERA_TEST_DOWNLOAD not set");
return;
}
let main = common::download::ensure_cached(MAIN_URL, MAIN_FILE);
let mmproj = common::download::ensure_cached(MMPROJ_URL, MMPROJ_FILE);
let mut files = ModelFiles::text(&main);
files.multimodal_projector = Some(mmproj);
files.inference_type = Some(InferenceType::LlamaCppImageToText);
let engine = CeraEngine::from_files(
files,
EngineConfig {
context_size: 512,
backend: BackendPreference::Cpu,
..Default::default()
},
)
.expect("VL bundle load");
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("pug.jpg");
let img_bytes: Vec<u8> = if fixture.exists() {
std::fs::read(&fixture).expect("read pug.jpg fixture")
} else {
eprintln!(
"note: {} not found — falling back to a synthetic red PNG. \
Save a small image fixture at that path for richer manual output.",
fixture.display()
);
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::<Rgb<u8>, _>::from_fn(256, 256, |_, _| Rgb([255u8, 0, 0]));
let mut png = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("encode synthetic png");
png
};
let caps = engine.capabilities();
assert!(caps.image_in, "VL capabilities must report image_in=true");
let tokenizer = engine.tokenizer();
let messages = vec![ChatMessageMultimodal {
role: "user".to_string(),
content: vec![
ContentItem::Image,
ContentItem::Text {
text: "Describe what you see.".to_string(),
},
],
}];
let mut session = engine.new_session(Default::default());
session
.append_chat_with_images(&messages, &[&img_bytes], true)
.expect("append_chat_with_images should succeed end-to-end");
struct Collect(Vec<u32>);
impl ModalitySink for Collect {
fn on_text_tokens(&mut self, t: &[u32]) {
self.0.extend_from_slice(t);
}
fn on_done(&mut self, _: FinishReason) {}
}
let mut sink = Collect(Vec::new());
let opts = GenerateOpts {
max_tokens: 32,
temperature: 0.0,
..Default::default()
};
session
.generate(&opts, &mut sink)
.expect("generate after image+prompt");
assert!(
!sink.0.is_empty(),
"generated zero tokens — append_image / forward / decode wiring is broken"
);
let decoded = tokenizer.decode(&sink.0);
eprintln!("vl smoke decoded output: {decoded:?}");
let total = decoded.chars().count() as f32;
let alpha_or_space = decoded
.chars()
.filter(|c| c.is_ascii_alphabetic() || *c == ' ')
.count() as f32;
assert!(total > 0.0, "decoded text is empty");
assert!(
alpha_or_space / total >= 0.6,
"post-image output mostly non-letters: {decoded:?}"
);
assert!(
decoded.contains(' '),
"post-image output has no spaces — single unbroken token: {decoded:?}"
);
let alpha_count = decoded.chars().filter(char::is_ascii_alphabetic).count();
assert!(
alpha_count >= 4,
"post-image generation looks degenerate (got {decoded:?}); image embeddings \
likely poisoned the LLM stream"
);
let uncapped_pos = {
let mut s = engine.new_session(Default::default());
s.append_chat_with_images(&messages, &[&img_bytes], true)
.expect("uncapped chat append");
s.position()
};
let mut capped = engine.new_session(Default::default());
capped.set_image_max_long_size(Some(128));
capped
.append_chat_with_images(&messages, &[&img_bytes], true)
.expect("capped chat append should run end-to-end via the session default");
let capped_pos = capped.position();
assert!(
capped_pos < uncapped_pos,
"max_long_size must reduce image tokens via the chat path: \
capped KV {capped_pos} should be < uncapped {uncapped_pos}"
);
let mut capped_sink = Collect(Vec::new());
capped
.generate(&opts, &mut capped_sink)
.expect("generate after capped image prefill");
assert!(
!capped_sink.0.is_empty(),
"capped (sub-min_pixels) image prefill produced no generation — \
small-grid pos-embed interpolation may be broken"
);
}
#[test]
#[ignore = "downloads ~310 MB across two GGUFs; set CERA_TEST_DOWNLOAD=1 and pass --ignored"]
fn vl_bundle_gpu_path_generates() {
if std::env::var("CERA_TEST_DOWNLOAD").is_err() {
eprintln!("skipping: CERA_TEST_DOWNLOAD not set");
return;
}
let main = common::download::ensure_cached(MAIN_URL, MAIN_FILE);
let mmproj = common::download::ensure_cached(MMPROJ_URL, MMPROJ_FILE);
let mut files = ModelFiles::text(&main);
files.multimodal_projector = Some(mmproj);
files.inference_type = Some(InferenceType::LlamaCppImageToText);
let engine = CeraEngine::from_files(
files,
EngineConfig {
context_size: 512,
backend: BackendPreference::Auto,
..Default::default()
},
)
.expect("VL bundle load (Auto backend)");
#[cfg(any(feature = "gpu", all(feature = "metal", target_os = "macos")))]
if !engine.has_gpu_vision_encoder() {
eprintln!("skipping GPU-path assertions: no usable GPU device; encoder fell back to CPU");
return;
}
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("pug.jpg");
let img_bytes: Vec<u8> = if fixture.exists() {
std::fs::read(&fixture).expect("read pug.jpg fixture")
} else {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::<Rgb<u8>, _>::from_fn(256, 256, |_, _| Rgb([255u8, 0, 0]));
let mut png = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("encode synthetic png");
png
};
let tokenizer = engine.tokenizer();
let messages = vec![ChatMessageMultimodal {
role: "user".to_string(),
content: vec![
ContentItem::Image,
ContentItem::Text {
text: "Describe what you see.".to_string(),
},
],
}];
let mut session = engine.new_session(Default::default());
session
.append_chat_with_images(&messages, &[&img_bytes], true)
.expect("append_chat_with_images (GPU path) should succeed end-to-end");
struct Collect(Vec<u32>);
impl ModalitySink for Collect {
fn on_text_tokens(&mut self, t: &[u32]) {
self.0.extend_from_slice(t);
}
fn on_done(&mut self, _: FinishReason) {}
}
let mut sink = Collect(Vec::new());
let opts = GenerateOpts {
max_tokens: 32,
temperature: 0.0,
..Default::default()
};
session
.generate(&opts, &mut sink)
.expect("generate after GPU image prefill");
let decoded = tokenizer.decode(&sink.0);
eprintln!("vl GPU-path decoded output: {decoded:?}");
let total = decoded.chars().count() as f32;
let alpha_or_space = decoded
.chars()
.filter(|c| c.is_ascii_alphabetic() || *c == ' ')
.count() as f32;
assert!(total > 0.0, "decoded text is empty");
assert!(
alpha_or_space / total >= 0.6,
"GPU-path output mostly non-letters: {decoded:?}"
);
assert!(
decoded.contains(' '),
"GPU-path output has no spaces — single unbroken token: {decoded:?}"
);
}
#[test]
#[ignore = "downloads ~310 MB across two GGUFs; set CERA_TEST_DOWNLOAD=1 and pass --ignored"]
fn vl_bundle_gpu_embeddings_match_cpu() {
if std::env::var("CERA_TEST_DOWNLOAD").is_err() {
eprintln!("skipping: CERA_TEST_DOWNLOAD not set");
return;
}
let main = common::download::ensure_cached(MAIN_URL, MAIN_FILE);
let mmproj = common::download::ensure_cached(MMPROJ_URL, MMPROJ_FILE);
let mut files = ModelFiles::text(&main);
files.multimodal_projector = Some(mmproj);
files.inference_type = Some(InferenceType::LlamaCppImageToText);
let engine = CeraEngine::from_files(
files,
EngineConfig {
context_size: 512,
backend: BackendPreference::Cpu,
..Default::default()
},
)
.expect("load");
let cpu_enc = engine.vision_encoder().expect("vision encoder").clone();
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::<Rgb<u8>, _>::from_fn(256, 256, |x, y| {
Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8])
});
let mut png = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let pre =
cera::model::vision_preprocessor::preprocess_image_with_opts(&png, &cpu_enc.config, None)
.expect("preprocess");
let cpu_emb = cpu_enc
.encode_image(&pre.pixels, pre.grid_w, pre.grid_h)
.expect("cpu encode");
let gpu = cera::model::vision_encoder_gpu::build_gpu_vision_encoder(
cpu_enc.as_ref(),
BackendPreference::Auto,
);
let Some(gpu) = gpu else {
eprintln!("no GPU backend available; skipping");
return;
};
let gpu_emb = gpu
.encode_image(&pre.pixels, pre.grid_w, pre.grid_h)
.expect("gpu encode");
assert_eq!(cpu_emb.len(), gpu_emb.len(), "embedding length mismatch");
let mut sum_abs = 0.0f64;
for (i, (c, g)) in cpu_emb.iter().zip(gpu_emb.iter()).enumerate() {
assert!(
g.is_finite(),
"GPU embedding non-finite at {i}: {g} (cpu={c})"
);
sum_abs += (c - g).abs() as f64;
}
let mean_abs = sum_abs / cpu_emb.len() as f64;
assert!(
mean_abs < 0.5,
"GPU vs CPU embedding mean_abs={mean_abs:.4} too large — likely a kernel bug"
);
eprintln!(
"GPU vs CPU embedding parity: mean_abs={mean_abs:.4}, n={}",
cpu_emb.len()
);
}