open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime/tests/real_liquid_lfm2.rs
Transaction Science 29dff259e4 jouleclaw Phase 3: backends (reference + apple) + runtime
Three crates ported. All 440 tests pass across Phase 1+2+3.

jouleclaw-backend-reference
  Ported from pattern-lang/joule-backend-reference (~3556 LOC, 17 files).
  Pure-Rust scalar kernels — the determinism oracle every other backend
  validates against. Activation, conv1d, elementwise, lookup (incl. bit
  and ternary), matmul (incl. bit, q8_0, stq1_0, ternary), norm, rope,
  sample, shape, softmax.

jouleclaw-backend-apple
  Ported from pattern-lang/joule-backend-apple (~1116 LOC, 4 files).
  Accelerate (cblas_sgemm via direct FFI; build.rs links the
  framework), Metal compute kernels, MPSMatrixMultiplication. On M5,
  MPS matmul auto-routes through the GPU's Neural Accelerators
  (Tensor APIs). Two Rust-2024 extern-block fixes (unsafe extern "C")
  in accelerate.rs and mps_matmul.rs.

jouleclaw-runtime
  Ported from pattern-lang/joule-runtime (~10218 LOC, 10 files).
  The runtime kernel: chat, embed, generate, streaming, compile,
  execute, validate, plus the speculative-decoding primitives
  (drafter, PLD = prompt lookup decoding). Wires the cascade
  (jouleclaw-cascade), the topology (jouleclaw-topology), the format
  loader (jouleclaw-loader-gguf), and both backends together.

  One Rust-2024 binding-mode fix in compile.rs (|(_, &d)| d == 0 →
  |(_, d)| **d == 0). Three types in jouleclaw-loader-gguf
  (KvQuant, KvSnapshot, ShortConvStateSnapshot) gained
  serde::{Serialize, Deserialize} derives so the runtime's
  ConversationCheckpoint can be serialized; serde added to the
  loader-gguf Cargo.toml.

  Day-one model coverage inherits everything mistral.rs's parent
  loaders support — Llama (incl. Llama 4), Mistral (incl. Small 4),
  Qwen (3, 3-VL, 3 MoE), Gemma (incl. Gemma 4 with MTP drafter), Phi
  (incl. Phi-4 MM), DeepSeek V3, Granite 4 — through this runtime's
  PLD + drafter paths.

  31 integration test files cover: alias-energy, blocks, boot
  validation, causal, chat, chunked prefill, concat-and-offset, embed,
  end-to-end model, generation (incl. inplace), GQA, inplace-alias
  proof, integration, KV cache (incl. inplace), multihead, prefix
  cache, real Bonsai (incl. streaming), real Liquid LFM2, real Tencent
  HYMT, real TinyLlama, repeat, rope-and-scaling, sampling features,
  scatter (incl. inplace), shape, slice (incl. slice-attention),
  streaming, text-to-logits, validation.

Phase 4 next: the model tiers — gemma4, liquid (CfC SSM), prism (1-bit
ternary BitNet), mrl (Matryoshka embeddings), lmm (vision/text/audio),
ebm (energy-based constraint satisfaction). All depend on the runtime
+ backends shipped in this commit.

Deferred to later phases: HAL from efficient-genai + the discrete-GPU
backends (CUDA, ROCm, Vulkan) — these are needed for Phase 5
omni-modal (diffusion, stable-diffusion, gaussian3d, video), not for
Phase 4 text/code/SSM model tiers which run on Apple + reference.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 13:09:59 -04:00

138 lines
6.5 KiB
Rust

//! End-to-end oracle for **LiquidAI/LFM2.5-350M-Q4_K_M**.
//!
//! What this validates that no prior oracle did:
//!
//! 1. **`lfm2`** — fourth supported architecture alongside `llama`,
//! `qwen3`, `hunyuan-dense`. Hybrid: each block is *either* a
//! standard attention block *or* a `shortconv` recurrent block,
//! decided per-layer by the `attention.head_count_kv` array (a `0`
//! entry marks the recurrent slot).
//! 2. **`Conv1DDepthwiseCausal`** — the new graph op + kernel for
//! LFM2's depthwise 3-tap causal 1-D convolution.
//! 3. **Per-layer dispatch in `build_block`** routing conv vs attn.
//! 4. **`token_embd_norm.weight`** — LFM2's name for the final RMS
//! norm (no separate `output_norm.weight`).
//!
//! Gate: parse succeeds, the per-layer kv array contains *both* zeros
//! (conv layers) and non-zeros (attn layers) so the hybrid path is
//! actually exercised, prefill runs, logits are finite, and the
//! greedy next token decodes to a non-empty string.
use jouleclaw_loader_gguf::llama::{build_llama_graph, LlamaConfig};
use jouleclaw_loader_gguf::read_gguf_file;
use jouleclaw_loader_gguf::sample::{sample_logits, SamplingConfig};
use jouleclaw_loader_gguf::tokenizer::Vocab;
use jouleclaw_runtime::{compile, execute, ExecutionOptions, Runtime};
use std::collections::HashMap;
use std::time::Instant;
fn model_path() -> String {
std::env::var("JOULE_LFM2")
.unwrap_or_else(|_| "../../models/lfm2.5-350m-q4_k_m.gguf".to_string())
}
#[test]
#[ignore]
fn real_lfm2_loads_and_speaks() {
let path = model_path();
eprintln!("loading {}", path);
let t0 = Instant::now();
let model = read_gguf_file(&path).expect("read_gguf_file");
eprintln!(" parsed GGUF in {:?}", t0.elapsed());
eprintln!(" arch = {:?}", model.metadata_string("general.architecture"));
let cfg = LlamaConfig::from_metadata(&model).expect("LlamaConfig::from_metadata");
eprintln!(
" config: arch={} vocab={} dim={} layers={} ff={} heads={} kv={} head_dim={} \
qk_norm={} ctx={} rope_base={}",
cfg.arch, cfg.vocab_size, cfg.embedding_length, cfg.block_count,
cfg.feed_forward_length, cfg.head_count, cfg.head_count_kv, cfg.head_dim,
cfg.qk_norm, cfg.context_length, cfg.rope_base,
);
assert_eq!(cfg.arch, "lfm2");
// The hybrid path: per-layer kv array must contain BOTH zeros (conv
// layers) and non-zeros (attn layers). If it's all the same value,
// the new code path isn't actually exercised.
let plk = &cfg.per_layer_head_count_kv;
eprintln!(" per_layer_head_count_kv = {:?}", plk);
assert_eq!(plk.len(), cfg.block_count,
"LFM2 must report one kv entry per layer");
let n_conv = plk.iter().filter(|&&v| v == 0).count();
let n_attn = plk.iter().filter(|&&v| v > 0).count();
eprintln!(" hybrid layout: {} conv layers, {} attn layers", n_conv, n_attn);
assert!(n_conv > 0 && n_attn > 0,
"LFM2 hybrid must have both conv and attn layers; got {} / {}",
n_conv, n_attn);
assert!(cfg.shortconv_l_cache >= 2,
"LFM2 shortconv.l_cache must be set (got {})", cfg.shortconv_l_cache);
let vocab = Vocab::from_gguf(&model).expect("Vocab::from_gguf");
// LFM2.5-350M is instruct-tuned with a qwen2-style chat template
// (`<|im_start|>role\n…<|im_end|>`). Without the template wrap an
// instruct model often emits `<|im_end|>` immediately. We use an
// open-ended continuation-shaped prompt instead so a greedy step
// is more likely to produce a real word.
let prompt = "Once upon a time, there was a small";
let token_ids_u32 = vocab.encode_bpe_regex(prompt, false);
eprintln!(" prompt {:?} → {} tokens", prompt, token_ids_u32.len());
assert!(!token_ids_u32.is_empty());
let seq_len = token_ids_u32.len();
let t1 = Instant::now();
let llama = build_llama_graph(&model, seq_len).expect("build_llama_graph");
eprintln!(" built graph: {} nodes in {:?}", llama.graph.nodes.len(), t1.elapsed());
let t2 = Instant::now();
let runtime = Runtime::reference_only();
let compiled = compile(llama.graph, &runtime.kernels).expect("compile");
eprintln!(" compiled: {} plan entries in {:?}", compiled.plan.len(), t2.elapsed());
let bytes: Vec<u8> = token_ids_u32.iter()
.flat_map(|&id| (id as i32).to_le_bytes()).collect();
let token_tensor = jouleclaw_core::tensor::Tensor {
meta: jouleclaw_core::tensor::TensorMeta::new(
jouleclaw_core::tensor::Dtype::I32, &[seq_len]),
storage: std::sync::Arc::new(
jouleclaw_core::tensor::TensorStorage { bytes, mapped: None }),
};
let mut inputs = HashMap::new();
inputs.insert("token_ids".to_string(), token_tensor);
let t3 = Instant::now();
let res = execute(&compiled, inputs, ExecutionOptions::default()).expect("execute");
let fwd = t3.elapsed();
eprintln!(" >>> ONE forward pass ({} tokens): {:?}", seq_len, fwd);
let logits = res.outputs.get("logits").expect("logits");
assert_eq!(logits.meta.shape, vec![seq_len, cfg.vocab_size]);
let l = logits.as_f32_vec();
let last = &l[(seq_len - 1) * cfg.vocab_size..seq_len * cfg.vocab_size];
let max = last.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let sum: f32 = last.iter().map(|x| (x - max).exp()).sum();
assert!(sum.is_finite() && sum > 0.0, "logits must be finite");
let next = sample_logits(last, &SamplingConfig::greedy());
let tok = vocab.id_to_token(next).unwrap_or("<UNK>");
let cont = vocab.decode_bpe(&[next]);
eprintln!(" >>> greedy next: id={} token={:?} decoded={:?}", next, tok, cont);
eprintln!(" >>> {:?}{}", prompt, cont);
// Substrate correctness gate: the next token is valid (in-vocab,
// not UNK), and we either got printable text *or* a known control
// token (e.g. `<|im_end|>`, which an instruct model emits without
// its chat template). A real bug shows up as <UNK>/<unk>, NaN
// logits, or an id past vocab.
assert!((next as usize) < cfg.vocab_size,
"next token id {} out of vocab range {}", next, cfg.vocab_size);
assert_ne!(tok, "<UNK>", "model emitted unknown token");
let printable = !cont.is_empty();
let control = tok.starts_with("<|") && tok.ends_with("|>");
assert!(printable || control,
"expected printable text or known control token, got id={} tok={:?} cont={:?}",
next, tok, cont);
eprintln!("VERDICT: LFM2.5-350M (hybrid conv+attn, Conv1DDepthwiseCausal, lfm2 arch) \
loaded + ran + produced a finite, decodable next token. Forward {:?} for {} tokens.",
fwd, seq_len);
}