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

184 lines
6.6 KiB
Rust

//! End-to-end demo: text in → token IDs → graph → logits → top-k token IDs out.
//!
//! Builds a synthetic Llama-style model with an embedded vocabulary,
//! tokenizes a string, runs the forward pass through the full pipeline
//! (RoPE, scaling, causal mask, multi-head), and decodes the argmax token
//! per position back to text.
//!
//! The vocabulary and weights are random, so the output is gibberish, but
//! the *pipeline* is fully exercised: every primitive runs, the tokenizer
//! produces token IDs from real characters, the forward pass produces
//! logits with the correct shape, and the output decodes back to a string.
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::tokenizer::Vocab;
use jouleclaw_loader_gguf::read_gguf;
use jouleclaw_runtime::{compile, execute, ExecutionOptions, Runtime};
use std::collections::HashMap;
use std::io::Cursor;
/// Build a minimal vocabulary that can tokenize ASCII letters and spaces.
/// Vocab layout:
/// 0..3: <unk>, <s>, </s>
/// 3: ▁ (space marker, score 0)
/// 4..30: a..z (score -1.0 each)
/// 30..62: ▁a..▁z (score -0.5 each, prefer "word-start" forms)
fn build_test_vocab() -> Vec<(String, f32)> {
let mut v = vec![
("<unk>".to_string(), 0.0),
("<s>".to_string(), 0.0),
("</s>".to_string(), 0.0),
("\u{2581}".to_string(), 0.0),
];
for c in 'a'..='z' {
v.push((c.to_string(), -1.0));
}
for c in 'a'..='z' {
v.push((format!("\u{2581}{}", c), -0.5));
}
// Pad with byte tokens so non-ASCII inputs can still be encoded.
for b in 0..256u32 {
let key = format!("<0x{:02X}>", b);
// Avoid duplicates if any of these tokens collide with already-added.
// None of the above tokens look like `<0xNN>`, so we're safe.
v.push((key, -10.0));
}
v
}
#[test]
fn text_in_tokens_in_logits_out_decode_back() {
let vocab_pairs = build_test_vocab();
let vocab_size = vocab_pairs.len();
let cfg = SyntheticConfig {
vocab_size,
embedding_length: 16,
block_count: 2,
feed_forward_length: 32,
head_count: 4, // d_head = 4
head_count_kv: 4,
rms_eps: 1e-6,
seed: 42,
vocab: Some(vocab_pairs),
merges: None,
bos_id: Some(1),
eos_id: Some(2),
unk_id: Some(0), chat_template: None,
};
// Synthesize and parse.
let bytes = synthesize_llama_gguf(&cfg);
let model = read_gguf(Cursor::new(bytes)).expect("parse synthetic gguf");
println!("Synthetic GGUF: {} bytes, {} tensors", model.data().len(), model.tensors.len());
// Load the vocabulary.
let vocab = Vocab::from_gguf(&model).expect("load vocab");
println!("Vocab: {} tokens, model='{}'", vocab.len(), vocab.model_name);
// Tokenize the input text.
let input_text = "hello world";
let token_ids = vocab.encode_spm(input_text, true); // with BOS
println!("Tokenized '{}' to {} tokens: {:?}",
input_text, token_ids.len(), token_ids);
// Decode back to verify the tokenizer round-trips.
let recovered = vocab.decode_spm(&token_ids);
println!("Decoded back: '{}'", recovered);
assert_eq!(recovered, input_text,
"tokenizer must round-trip ASCII text exactly");
// Build the inference graph for the actual sequence length.
let seq_len = token_ids.len();
let llama = build_llama_graph(&model, seq_len).expect("build graph");
println!("Graph: {} nodes for seq_len={}", llama.graph.nodes.len(), seq_len);
// Compile and execute.
let runtime = Runtime::boot();
let compiled = compile(llama.graph, &runtime.kernels).expect("compile");
println!("Compiled: {} plan entries", compiled.plan.len());
// Bind the token IDs as the graph input.
let id_bytes: Vec<u8> = token_ids.iter()
.flat_map(|&id| (id as i32).to_le_bytes()).collect();
let input_tensor = Tensor {
meta: TensorMeta::new(Dtype::I32, &[seq_len]),
storage: std::sync::Arc::new(TensorStorage { bytes: id_bytes, mapped: None }),
};
let mut inputs = HashMap::new();
inputs.insert("token_ids".into(), input_tensor);
let res = execute(&compiled, inputs, ExecutionOptions::default()).expect("execute");
let logits = res.outputs.get("logits").unwrap();
println!("Forward pass: shape={:?}, joules={:.6e} J, wall={:?}",
logits.meta.shape,
res.trace.joule_accounting.total_joules,
res.trace.wall_clock);
// Decode argmax per position back to tokens.
assert_eq!(logits.meta.shape, vec![seq_len, vocab_size]);
let l = logits.as_f32_vec();
let mut argmax_ids = Vec::with_capacity(seq_len);
for pos in 0..seq_len {
let row = &l[pos * vocab_size..(pos + 1) * vocab_size];
let mut best_idx = 0usize;
let mut best = row[0];
for i in 1..row.len() {
if row[i] > best {
best = row[i];
best_idx = i;
}
}
argmax_ids.push(best_idx as u32);
}
println!("Argmax token IDs per position: {:?}", argmax_ids);
let argmax_text = vocab.decode_spm(&argmax_ids);
println!("Decoded argmax tokens: '{}'", argmax_text);
// Sanity: every argmax ID is in vocabulary range.
for id in &argmax_ids {
assert!((*id as usize) < vocab_size);
}
// The output is gibberish (random weights) but the pipeline ran end-to-end.
println!("\nOK: text → tokens → forward pass → argmax → text round-trip works.");
}
/// Just the tokenization round-trip on a non-trivial string with multiple words.
#[test]
fn tokenizer_round_trips_ascii_with_spaces() {
let cfg = SyntheticConfig {
vocab_size: build_test_vocab().len(),
embedding_length: 8,
block_count: 1,
feed_forward_length: 16,
head_count: 1,
head_count_kv: 1,
rms_eps: 1e-6,
seed: 1,
vocab: Some(build_test_vocab()),
merges: None,
bos_id: Some(1),
eos_id: Some(2),
unk_id: Some(0), chat_template: None,
};
let bytes = synthesize_llama_gguf(&cfg);
let model = read_gguf(Cursor::new(bytes)).unwrap();
let vocab = Vocab::from_gguf(&model).unwrap();
for text in &[
"a",
"abc",
"hello",
"the quick brown fox",
"joule is a runtime",
] {
let ids = vocab.encode_spm(text, false);
let recovered = vocab.decode_spm(&ids);
assert_eq!(&recovered, text,
"round-trip failed for '{}': got '{}'", text, recovered);
}
}