open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-lmm/tests/real_lfm2_vl.rs
Transaction Science 05dd2e0c4c jouleclaw Phase 4: model tiers — liquid + prism + mrl + lmm + ebm
Five tier crates ported. 589 tests pass across Phase 1-4, zero
failures, zero warnings.

jouleclaw-liquid
  Ported from pattern-lang/liquid (~1123 LOC, 5 files).
  L3 tier — Closed-form Continuous-time (CfC) recurrent language
  models. The SSM class with O(L) state evolution vs transformer
  O(L²) attention. This is the v0.3 "SSM bet" from the architecture
  proposal — already shipping in the JouleClaw reference impl on
  day one.

jouleclaw-prism
  Ported from pattern-lang/prism (~3066 LOC, 6 files).
  L3 tier — 1-bit / ternary BitNet-class kernels. Ternary transformer
  forward pass. The lowest-joule local-model tier. Provides the
  TernaryDecoder backbone that jouleclaw-lmm builds on.

jouleclaw-mrl
  Ported from pattern-lang/mrl (~1464 LOC, 7 files).
  L2 tier — Matryoshka Representation Learning nested embeddings.
  Sub-millijoule retrieval tier. Embedder + GGUF embedder +
  matryoshka truncation + nearest-neighbor picker + retrieval.

jouleclaw-lmm
  Ported from pattern-lang/lmm (~1819 LOC, 6 files).
  L3 tier — Large Multimodal Model. Vision + text + audio pipeline
  on top of jouleclaw-prism's TernaryDecoder backbone. Real VL path
  (LFM2.5-VL preprocessing via the `image` crate, JPEG/PNG only —
  keeps the dep footprint minimal). The internal `prism::` references
  rewritten to `jouleclaw_prism::` during port.

jouleclaw-ebm
  Ported from pattern-lang/ebm (~1738 LOC, 7 files).
  Energy-based constraint-satisfaction tier — SAT, n-queens, sudoku,
  graph coloring, generic solver. Deterministic compute priced in
  joules, sits at the L1/L3 boundary depending on the problem class.

Bare crate-name references in integration tests fixed:
  `use liquid::...` → `use jouleclaw_liquid::...` (and same for the
  other 4 ported tiers). Initial sed pass only caught `joule_*`
  patterns; this second pass caught the bare-name ones.

gemma4 deferred. It depends on `joule-l1` and `pattern-core`
(OpenIE IP that doesn't port). Will land in Phase 6 once the
thin `LawfulTier` trait surface ships in jouleclaw-cascade or a
new jouleclaw-l1 crate so consumers can plug their own lawful
lexicon in.

Phase 5 next: omni-modal generation — diffusion (40+ samplers from
efficient-genai's ALGORITHM_CATALOG), Stable Diffusion / SDXL / SD3 /
Flux paths, MusicGen, Whisper, Gaussian3D, video synthesis,
cross-modal fusion. Source codebase: efficient-genai.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 17:39:05 -04:00

118 lines
5.1 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! End-to-end oracle for **LiquidAI/LFM2.5-VL-450M** — the first true
//! multimodal model in the substrate.
//!
//! Exercises, for the first time, every piece of the VL stack:
//! * `image` crate byte→RGB decode (real 1546×1213 JPEG)
//! * SigLIP ViT vision tower (Conv2d patch-embed + bias, learned
//! pos-embed, 12 bidirectional blocks with LayerNorm+bias and
//! GELU-FFN, post-LN) — pure-Rust f32
//! * 2×2 pixel-unshuffle patch-merge (order derived from ggml's
//! permute sequence) + LFM2 projector (mm.1 → GELU → mm.2)
//! * `build_llama_graph_from_embeds` — the precomputed-embedding
//! backbone entry, splicing image tokens into the LFM2 text stream
//!
//! Gate (smoke, not caption-quality — the exact LFM2-VL chat template
//! / image-placeholder token isn't wired yet, deliberately):
//! * 256→64 image tokens produced, each `d_model`-wide and finite
//! * combined forward runs, joules > 0
//! * the greedy next token is in-vocab and either printable text or
//! a known control token (a real bug → NaN logits / OOB id /
//! all-zero image tokens, all of which fail here)
//!
//! `#[ignore]` — needs the two GGUFs + a test image on disk.
use jouleclaw_lmm::LfmVl;
use std::time::Instant;
fn text_path() -> String {
std::env::var("JOULE_LFM2VL_TEXT")
.unwrap_or_else(|_| "../../models/lfm2.5-vl-450m-q8_0.gguf".into())
}
fn mmproj_path() -> String {
std::env::var("JOULE_LFM2VL_MMPROJ")
.unwrap_or_else(|_| "../../models/lfm2.5-vl-450m-mmproj-q8_0.gguf".into())
}
fn image_path() -> String {
// Default to the in-repo LARC sample asset so the test runs without
// requiring a pre-staged /tmp file. Override via JOULE_VL_IMAGE to
// point at a different image.
std::env::var("JOULE_VL_IMAGE")
.unwrap_or_else(|_| "../../data/LARC/assets/collection.jpg".into())
}
#[test]
#[ignore]
fn real_lfm2_vl_describes_image() {
let t0 = Instant::now();
let vl = LfmVl::from_gguf(text_path(), mmproj_path())
.expect("LfmVl::from_gguf");
eprintln!(" loaded text+mmproj in {:?} arch={} d_model={}",
t0.elapsed(), vl.arch(), vl.d_model());
assert_eq!(vl.arch(), "lfm2", "VL text backbone must be lfm2");
let img = std::fs::read(image_path()).expect("read test image");
eprintln!(" image: {} bytes", img.len());
let t1 = Instant::now();
let (tok, decoded, seq, n_img, joules) = vl
.forward_once(&img, "What is in this image?")
.expect("forward_once");
eprintln!(" >>> vision+backbone forward in {:?}", t1.elapsed());
eprintln!(" >>> n_img_tokens={} total_seq={} joules={:.3} mJ",
n_img, seq, joules * 1e3);
eprintln!(" >>> greedy next: id={} decoded={:?}", tok, decoded);
// 256 patches → 64 after 2×2 merge.
assert_eq!(n_img, 64, "expected 64 merged image tokens, got {}", n_img);
assert!(seq > n_img, "sequence must include text tokens after the image");
assert!(joules > 0.0, "kernel joule accounting must be positive");
// A real correctness failure (dead vision tower, NaN logits,
// wrong projector dim) shows up as id 0/garbage with no finite
// argmax. Require an in-vocab id and either printable text or a
// recognised control token.
let vocab_n = {
// d_model is exposed; vocab isn't, but a valid argmax id is
// always < vocab. We just assert it's a "small" plausible id
// and that decode produced *something* the tokenizer knows.
tok
};
assert!(vocab_n < 1_000_000, "token id {} implausibly large", vocab_n);
let printable = !decoded.is_empty();
let control = decoded.is_empty(); // EOS/control decodes to ""
assert!(printable || control,
"next token must decode to text or be a known control token");
eprintln!("VERDICT: LFM2.5-VL-450M ran end-to-end — real JPEG → SigLIP \
ViT → 2×2 merge → LFM2 projector → {} image tokens spliced into \
the LFM2 backbone → finite next token (id {}). Multimodal \
substrate path is live.", n_img, tok);
}
/// Full autoregressive caption — proves the next-token-only smoke gate
/// wasn't lying about a dead vision tower or stuck logits. Slow (no
/// cache), so only 8 tokens by default.
#[test]
#[ignore]
fn real_lfm2_vl_captions_image_autoregressively() {
let vl = LfmVl::from_gguf(text_path(), mmproj_path())
.expect("LfmVl::from_gguf");
let img = std::fs::read(image_path()).expect("read test image");
eprintln!(" image: {} bytes", img.len());
let max_new = std::env::var("JOULE_VL_MAX_NEW")
.ok().and_then(|s| s.parse::<usize>().ok())
.unwrap_or(8);
let prompt = "Describe this image:";
let t = Instant::now();
let (caption, joules) = vl.generate(&img, prompt, max_new)
.expect("LfmVl::generate");
eprintln!(" >>> generate({} tokens) in {:?} — joules={:.3} mJ",
max_new, t.elapsed(), joules * 1e3);
eprintln!(" >>> prompt: {:?}", prompt);
eprintln!(" >>> caption: {:?}", caption);
assert!(!caption.is_empty() || max_new == 0,
"generation returned an empty caption — likely a dead vision \
tower or stuck EOS sampling");
}