#![cfg(feature = "gpu")]
use std::path::PathBuf;
use cera::gguf::GgufFile;
use cera::kv_cache::InferenceState;
use cera::model::{Model, load_model, load_model_gpu};
const FIXTURE: &str = "LFM2.5-230M-Q4_K_M.gguf";
fn models_dir() -> PathBuf {
if let Ok(d) = std::env::var("CERA_ORACLE_MODELS_DIR") {
return PathBuf::from(d);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../target/oracle/models")
}
fn fixture_or_skip() -> Option<PathBuf> {
let p = models_dir().join(FIXTURE);
if p.exists() {
return Some(p);
}
assert!(
std::env::var("CERA_REQUIRE_MODEL")
.unwrap_or_default()
.is_empty(),
"CERA_REQUIRE_MODEL is set but {FIXTURE} is absent at {}",
p.display()
);
eprintln!("[gpu-embd] SKIP (absent): {}", p.display());
None
}
fn load_gpu(path: &std::path::Path) -> Option<Box<dyn Model>> {
match load_model_gpu(GgufFile::open(path).expect("open gguf"), Some(path), 4096) {
Ok(m) => Some(m),
Err(e) => {
assert!(
std::env::var("CERA_REQUIRE_GPU")
.unwrap_or_default()
.is_empty(),
"CERA_REQUIRE_GPU is set but the GPU model failed to load: {e}"
);
eprintln!("[gpu-embd] SKIP (no GPU): {e}");
None
}
}
}
fn load_cpu(path: &std::path::Path) -> Box<dyn Model> {
load_model(GgufFile::open(path).expect("open gguf"), Some(path), 4096).expect("load cpu model")
}
fn synthetic_embedding(hidden_size: usize, salt: u32) -> Vec<f32> {
(0..hidden_size)
.map(|i| {
let x = (i as u32).wrapping_mul(2_654_435_761).wrapping_add(salt);
((x >> 8) as f32 / u32::MAX as f32 * 256.0) - 0.5
})
.collect()
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len(), "logit vectors differ in length");
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
dot / (na * nb)
}
#[test]
fn gpu_model_advertises_embedding_input() {
let Some(path) = fixture_or_skip() else {
return;
};
let Some(gpu) = load_gpu(&path) else { return };
assert!(
gpu.supports_embedding_input(),
"GpuLfm2Model must advertise embedding input, or Session::append_embeddings \
refuses images on the WebGPU backend before calling forward_from_embedding"
);
}
#[test]
fn forward_from_embedding_matches_the_cpu_model() {
let Some(path) = fixture_or_skip() else {
return;
};
let Some(gpu) = load_gpu(&path) else { return };
let cpu = load_cpu(&path);
let hidden_size = cpu.config().hidden_size;
let embedding = synthetic_embedding(hidden_size, 0x5EED);
let mut cpu_state = InferenceState::from_config(cpu.config()).expect("state");
let mut gpu_state = InferenceState::from_config(gpu.config()).expect("state");
let cpu_logits = cpu.forward_from_embedding(&embedding, 0, &mut cpu_state);
let gpu_logits = gpu.forward_from_embedding(&embedding, 0, &mut gpu_state);
let cos = cosine(&cpu_logits, &gpu_logits);
assert!(
cos > 0.999,
"GPU forward_from_embedding diverged from CPU: cosine {cos:.6}. \
The layer dispatch is shared with the token path, so a low cosine \
points at how hidden_buf is seeded rather than at the kernels."
);
}
#[test]
fn successive_embeddings_advance_the_kv_cache() {
let Some(path) = fixture_or_skip() else {
return;
};
let Some(gpu) = load_gpu(&path) else { return };
let hidden_size = gpu.config().hidden_size;
let embedding = synthetic_embedding(hidden_size, 0xA11CE);
let mut state = InferenceState::from_config(gpu.config()).expect("state");
let first = gpu.forward_from_embedding(&embedding, 0, &mut state);
assert_eq!(
state.seq_len, 1,
"forward_from_embedding must advance seq_len itself"
);
let second = gpu.forward_from_embedding(&embedding, 1, &mut state);
assert_eq!(
state.seq_len, 2,
"second embedding must advance seq_len too"
);
let cos = cosine(&first, &second);
assert!(
cos < 0.9999,
"the same embedding at positions 0 and 1 produced identical logits \
(cosine {cos:.6}); the second step did not attend to the first, so \
the KV cache is not advancing"
);
}
#[test]
fn cpu_and_gpu_agree_on_position_after_a_prefix() {
let Some(path) = fixture_or_skip() else {
return;
};
let Some(gpu) = load_gpu(&path) else { return };
let cpu = load_cpu(&path);
let hidden_size = cpu.config().hidden_size;
let embedding = synthetic_embedding(hidden_size, 0xB0B);
let mut cpu_state = InferenceState::from_config(cpu.config()).expect("state");
let mut gpu_state = InferenceState::from_config(gpu.config()).expect("state");
cpu.forward(&[1], 0, &mut cpu_state);
gpu.forward(&[1], 0, &mut gpu_state);
assert_eq!(cpu_state.seq_len, 1, "CPU forward must advance seq_len");
assert_eq!(gpu_state.seq_len, 1, "GPU forward must advance seq_len");
let cpu_logits = cpu.forward_from_embedding(&embedding, 1, &mut cpu_state);
let gpu_logits = gpu.forward_from_embedding(&embedding, 1, &mut gpu_state);
let cos = cosine(&cpu_logits, &gpu_logits);
assert!(
cos > 0.999,
"GPU and CPU disagree on an embedding appended after a prefix: \
cosine {cos:.6}"
);
}
#[test]
fn forward_embedding_matches_the_cpu_model() {
let Some(path) = fixture_or_skip() else {
return;
};
let Some(gpu) = load_gpu(&path) else { return };
let cpu = load_cpu(&path);
let mut cpu_state = InferenceState::from_config(cpu.config()).expect("state");
let mut gpu_state = InferenceState::from_config(gpu.config()).expect("state");
let cpu_hidden = cpu.forward_embedding(&[1], 0, &mut cpu_state);
let gpu_hidden = gpu.forward_embedding(&[1], 0, &mut gpu_state);
assert_eq!(
gpu_hidden.len(),
cpu.config().hidden_size,
"forward_embedding must return one hidden-size vector, not logits"
);
assert_eq!(
cpu_state.seq_len, gpu_state.seq_len,
"backends disagree on how far forward_embedding advanced the cache"
);
let cos = cosine(&cpu_hidden, &gpu_hidden);
let rms = |v: &[f32]| (v.iter().map(|x| x * x).sum::<f32>() / v.len() as f32).sqrt();
let (cpu_rms, gpu_rms) = (rms(&cpu_hidden), rms(&gpu_hidden));
assert!(
cos > 0.999,
"GPU forward_embedding diverged from CPU: cosine {cos:.6}, \
RMS cpu={cpu_rms:.4} gpu={gpu_rms:.4}. A gpu RMS far below the cpu \
one means the tail stopped before the output norm; the contract is \
the post-norm state."
);
assert!(
(gpu_rms / cpu_rms - 1.0).abs() < 0.05,
"forward_embedding magnitudes disagree: cpu RMS {cpu_rms:.4} vs gpu \
{gpu_rms:.4}. A large ratio here is a missing or extra normalization \
step, not kernel noise."
);
}
#[test]
fn forward_hidden_from_embedding_matches_the_cpu_model() {
let Some(path) = fixture_or_skip() else {
return;
};
let Some(gpu) = load_gpu(&path) else { return };
let cpu = load_cpu(&path);
let hidden_size = cpu.config().hidden_size;
let embedding = synthetic_embedding(hidden_size, 0xA0D10);
let mut cpu_state = InferenceState::from_config(cpu.config()).expect("state");
let mut gpu_state = InferenceState::from_config(gpu.config()).expect("state");
let cpu_hidden = cpu.forward_hidden_from_embedding(&embedding, 0, &mut cpu_state);
let gpu_hidden = gpu.forward_hidden_from_embedding(&embedding, 0, &mut gpu_state);
assert_eq!(gpu_hidden.len(), hidden_size);
assert_eq!(
cpu_state.seq_len, gpu_state.seq_len,
"backends disagree on how far forward_hidden_from_embedding advanced \
the cache"
);
let cos = cosine(&cpu_hidden, &gpu_hidden);
assert!(
cos > 0.999,
"GPU forward_hidden_from_embedding diverged from CPU: cosine {cos:.6}"
);
}
#[test]
fn multi_frame_prefill_from_embeddings_matches_the_cpu_model() {
let Some(path) = fixture_or_skip() else {
return;
};
let Some(gpu) = load_gpu(&path) else { return };
let cpu = load_cpu(&path);
let hidden_size = cpu.config().hidden_size;
const FRAMES: usize = 6;
let embeddings: Vec<f32> = (0..FRAMES)
.flat_map(|i| synthetic_embedding(hidden_size, 0xF00D + i as u32))
.collect();
let mut cpu_state = InferenceState::from_config(cpu.config()).expect("state");
let mut gpu_state = InferenceState::from_config(gpu.config()).expect("state");
let cpu_logits = cpu.forward_prefill_from_embeddings(&embeddings, FRAMES, 0, &mut cpu_state);
let gpu_logits = gpu.forward_prefill_from_embeddings(&embeddings, FRAMES, 0, &mut gpu_state);
assert_eq!(
cpu_state.seq_len, gpu_state.seq_len,
"backends disagree on how far a {FRAMES}-frame image advanced the cache"
);
let cos = cosine(&cpu_logits, &gpu_logits);
assert!(
cos > 0.999,
"GPU multi-frame prefill_from_embeddings diverged from CPU: cosine \
{cos:.6}. The logits are the last frame's, so a low cosine means the \
frames were seeded at the wrong positions or in the wrong order."
);
}