open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime/tests/end_to_end_model.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

201 lines
8.2 KiB
Rust

//! End-to-end Llama loader test.
//!
//! Full pipeline:
//! 1. Synthesize a tiny Llama-style GGUF buffer (2 blocks, d=16, dff=32, vocab=32)
//! 2. Parse it via the GGUF parser
//! 3. Build the inference graph via the Llama loader
//! 4. Compile + execute with deterministic random token IDs
//! 5. Verify: output shape is [seq, vocab], probabilities sum to 1, runs
//! twice produce bit-identical output
use jouleclaw_core::tensor::{Dtype, Tensor, TensorMeta, TensorStorage};
use jouleclaw_loader_gguf::llama::build_llama_graph;
use jouleclaw_loader_gguf::synthetic::{synthesize_llama_gguf, SyntheticConfig};
use jouleclaw_loader_gguf::read_gguf;
use jouleclaw_runtime::{compile, execute, ExecutionOptions, Runtime};
use std::collections::HashMap;
use std::io::Cursor;
fn make_token_ids(seq_len: usize, vocab_size: usize, seed: u64) -> Tensor {
let mut s = seed;
let bytes: Vec<u8> = (0..seq_len).flat_map(|_| {
s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let id = ((s >> 32) as u32 % vocab_size as u32) as i32;
id.to_le_bytes().to_vec()
}).collect();
Tensor {
meta: TensorMeta::new(Dtype::I32, &[seq_len]),
storage: std::sync::Arc::new(TensorStorage { bytes, mapped: None }),
}
}
#[test]
fn synthesize_parse_load_execute_end_to_end() {
let cfg = SyntheticConfig::default();
let bytes = synthesize_llama_gguf(&cfg);
println!("Synthetic GGUF size: {} bytes", bytes.len());
// Parse the GGUF.
let model = read_gguf(Cursor::new(bytes)).expect("GGUF parse");
assert_eq!(model.metadata_string("general.architecture"), Some("llama"));
assert_eq!(model.metadata_u64("llama.block_count"), Some(cfg.block_count as u64));
println!("Parsed model: {} tensors, {} metadata entries",
model.tensors.len(), model.metadata.len());
// Build the inference graph.
let seq_len = 4;
let llama = build_llama_graph(&model, seq_len).expect("build llama graph");
println!("Built graph: {} nodes (config: {:?})",
llama.graph.nodes.len(), llama.config);
// Compile.
let runtime = Runtime::boot();
let compiled = compile(llama.graph, &runtime.kernels).expect("compile");
println!("Compiled: {} plan entries", compiled.plan.len());
// Bind tokens and execute.
let token_ids = make_token_ids(seq_len, cfg.vocab_size, 7);
let mut inputs = HashMap::new();
inputs.insert("token_ids".into(), token_ids.clone());
let res1 = execute(&compiled, inputs.clone(), ExecutionOptions::default())
.expect("execute 1");
let res2 = execute(&compiled, inputs, ExecutionOptions::default())
.expect("execute 2");
// Output shape check.
let logits = res1.outputs.get("logits").expect("logits output");
assert_eq!(logits.meta.shape, vec![seq_len, cfg.vocab_size],
"logits should be [seq_len, vocab_size]");
// Bit-identical determinism check.
assert_eq!(
res1.outputs.get("logits").unwrap().storage.bytes,
res2.outputs.get("logits").unwrap().storage.bytes,
"logits must be byte-identical across runs"
);
assert_eq!(res1.trace.output_hashes, res2.trace.output_hashes);
// Probability check: softmax over vocab dim should sum to 1.
let l = logits.as_f32_vec();
let v = cfg.vocab_size;
for row in 0..seq_len {
let row_logits = &l[row * v..(row + 1) * v];
let max = row_logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let exps: Vec<f32> = row_logits.iter().map(|x| (x - max).exp()).collect();
let sum: f32 = exps.iter().sum();
let probs: Vec<f32> = exps.iter().map(|e| e / sum).collect();
let p_sum: f32 = probs.iter().sum();
assert!((p_sum - 1.0).abs() < 1e-5,
"softmax over logits row {} should sum to 1.0, got {}", row, p_sum);
}
// Joule accounting: should be non-trivial — many MatMuls, plus the
// other primitives.
let acc = &res1.trace.joule_accounting;
assert!(acc.total_joules > 0.0);
println!("\n--- Forward pass joule accounting ---");
println!("total: {:.6e} J", acc.total_joules);
let mut by_op: Vec<_> = acc.by_op_kind.iter().collect();
by_op.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap());
for (op, j) in by_op {
println!(" {:>10?}: {:.6e} J", op, j);
}
// First few logits, for sanity.
println!("\nLogits[0, 0..8] = {:?}", &l[0..8.min(l.len())]);
println!("Wall clock: {:?}", res1.trace.wall_clock);
println!("\nOK: end-to-end Llama load + forward pass + determinism");
}
/// Smaller, faster test variant — single block, d=8, vocab=16, seq=2.
/// Purpose: quick smoke test with minimal compute.
#[test]
fn smoke_test_minimum_viable_model() {
let cfg = SyntheticConfig {
vocab_size: 16, embedding_length: 8, block_count: 1,
feed_forward_length: 16, head_count: 1, head_count_kv: 1, rms_eps: 1e-6, seed: 1, vocab: None, merges: None, bos_id: None, eos_id: None, unk_id: None, chat_template: None,
};
let bytes = synthesize_llama_gguf(&cfg);
let model = read_gguf(Cursor::new(bytes)).unwrap();
let llama = build_llama_graph(&model, 2).unwrap();
let runtime = Runtime::boot();
let compiled = compile(llama.graph, &runtime.kernels).unwrap();
let token_ids = make_token_ids(2, cfg.vocab_size, 1);
let mut inputs = HashMap::new();
inputs.insert("token_ids".into(), token_ids);
let res = execute(&compiled, inputs, ExecutionOptions::default()).unwrap();
let logits = res.outputs.get("logits").unwrap();
assert_eq!(logits.meta.shape, vec![2, cfg.vocab_size]);
}
/// Multi-head variant: 4 heads, d_model=16 (so d_head=4), 2 blocks, vocab=32.
/// This exercises the full multi_head_attention path through the Llama loader.
#[test]
fn multi_head_llama_end_to_end() {
let cfg = SyntheticConfig {
vocab_size: 32,
embedding_length: 16,
block_count: 2,
feed_forward_length: 32,
head_count: 4,
head_count_kv: 4,// 16 / 4 = 4 d_head
rms_eps: 1e-6,
seed: 99,
vocab: None,
merges: None,
bos_id: None,
eos_id: None,
unk_id: None, chat_template: None,
};
let bytes = synthesize_llama_gguf(&cfg);
let model = read_gguf(Cursor::new(bytes)).unwrap();
let seq_len = 4;
let llama = build_llama_graph(&model, seq_len).expect("multi-head graph build");
println!("Multi-head graph: {} nodes, head_count={}, d_head={}",
llama.graph.nodes.len(),
llama.config.head_count,
llama.config.embedding_length / llama.config.head_count);
let runtime = Runtime::boot();
let compiled = compile(llama.graph, &runtime.kernels).expect("compile");
let token_ids = make_token_ids(seq_len, cfg.vocab_size, 7);
let mut inputs = HashMap::new();
inputs.insert("token_ids".into(), token_ids);
let res1 = execute(&compiled, inputs.clone(), ExecutionOptions::default())
.expect("execute 1");
let res2 = execute(&compiled, inputs, ExecutionOptions::default())
.expect("execute 2");
let logits = res1.outputs.get("logits").unwrap();
assert_eq!(logits.meta.shape, vec![seq_len, cfg.vocab_size]);
// Bit-identical determinism check (with reshape + transpose + batched
// matmul in the path, this is a stronger statement than for single-head).
assert_eq!(
res1.outputs.get("logits").unwrap().storage.bytes,
res2.outputs.get("logits").unwrap().storage.bytes,
"multi-head Llama must be deterministic"
);
// Logits softmax sums to 1 across vocab.
let l = logits.as_f32_vec();
for row in 0..seq_len {
let row_logits = &l[row * cfg.vocab_size..(row + 1) * cfg.vocab_size];
let max = row_logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let sum: f32 = row_logits.iter().map(|x| (x - max).exp()).sum();
let probs: Vec<f32> = row_logits.iter().map(|x| (x - max).exp() / sum).collect();
let p_sum: f32 = probs.iter().sum();
assert!((p_sum - 1.0).abs() < 1e-5,
"multi-head logits row {} softmax should sum to 1.0, got {}", row, p_sum);
}
println!("Multi-head joules: {:.6e} J, wall: {:?}",
res1.trace.joule_accounting.total_joules,
res1.trace.wall_clock);
}