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]>
165 lines
6.7 KiB
Rust
165 lines
6.7 KiB
Rust
//! Tests for Phase 1.3 block constructors.
|
|
|
|
use jouleclaw_core::blocks;
|
|
use jouleclaw_core::graph::GraphBuilder;
|
|
use jouleclaw_core::op::{ActivationKind, NormKind, OpAttrs, OpKind};
|
|
use jouleclaw_core::tensor::{Dtype, Tensor, TensorMeta};
|
|
use jouleclaw_runtime::{compile, execute, ExecutionOptions, Runtime};
|
|
use std::collections::HashMap;
|
|
|
|
fn det_random(n: usize, seed: u64) -> Vec<f32> {
|
|
let mut s = seed;
|
|
(0..n).map(|_| {
|
|
s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
|
let bits = (s >> 40) as u32;
|
|
(bits as f32) * (1.0 / (1u32 << 24) as f32) - 0.5
|
|
}).collect()
|
|
}
|
|
|
|
fn bind(name: &str, shape: &[usize], seed: u64) -> (String, Tensor) {
|
|
let n = shape.iter().product::<usize>();
|
|
let data = det_random(n, seed);
|
|
(name.into(), Tensor::from_f32(TensorMeta::new(Dtype::F32, shape), &data))
|
|
}
|
|
|
|
/// Single-head attention with proper QK^T computes softmax-normalized
|
|
/// probabilities. Each row of the attention matrix must sum to 1.
|
|
///
|
|
/// Strategy: build the attention block, but instead of completing through
|
|
/// the residual, branch out at the softmax to expose the probabilities,
|
|
/// and verify each row sums to 1 (within float precision).
|
|
#[test]
|
|
fn attention_softmax_rows_sum_to_one() {
|
|
let seq = 8;
|
|
let d = 16;
|
|
|
|
let mut g = GraphBuilder::new();
|
|
let x = g.input("x", TensorMeta::new(Dtype::F32, &[seq, d]));
|
|
let w_norm = g.input("w_norm", TensorMeta::new(Dtype::F32, &[d]));
|
|
let w_q = g.input("w_q", TensorMeta::new(Dtype::F32, &[d, d]));
|
|
let w_k = g.input("w_k", TensorMeta::new(Dtype::F32, &[d, d]));
|
|
|
|
let xn = g.norm(x, w_norm, NormKind::Rms, 1e-6);
|
|
let q = g.matmul(xn, w_q);
|
|
let k = g.matmul(xn, w_k);
|
|
let scores = g.matmul_bt(q, k);
|
|
let probs = g.softmax(scores, -1);
|
|
g.output("probs", probs);
|
|
let graph = g.build();
|
|
|
|
let runtime = Runtime::boot();
|
|
let compiled = compile(graph, &runtime.kernels).unwrap();
|
|
|
|
let mut inputs = HashMap::new();
|
|
let (name, t) = bind("x", &[seq, d], 1); inputs.insert(name, t);
|
|
let (name, t) = bind("w_norm", &[d], 2); inputs.insert(name, t);
|
|
let (name, t) = bind("w_q", &[d, d], 3); inputs.insert(name, t);
|
|
let (name, t) = bind("w_k", &[d, d], 4); inputs.insert(name, t);
|
|
|
|
let res = execute(&compiled, inputs, ExecutionOptions::default()).unwrap();
|
|
let probs = res.outputs.get("probs").unwrap();
|
|
assert_eq!(probs.meta.shape, vec![seq, seq],
|
|
"attention probs must be [seq, seq] for single-head");
|
|
|
|
let p = probs.as_f32_vec();
|
|
for row in 0..seq {
|
|
let row_sum: f32 = (0..seq).map(|j| p[row * seq + j]).sum();
|
|
assert!((row_sum - 1.0).abs() < 1e-5,
|
|
"row {} of attention probabilities should sum to 1.0, got {}", row, row_sum);
|
|
for j in 0..seq {
|
|
let v = p[row * seq + j];
|
|
assert!(v >= 0.0 && v <= 1.0,
|
|
"attention prob [{},{}] out of [0,1]: {}", row, j, v);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The transformer_block helper produces the expected number of nodes:
|
|
/// attention has 1 Norm + 6 MatMul (Q, K, V, QK^T, attn*V, Wo) + 1 Softmax + 1 Add = 9
|
|
/// FFN has 1 Norm + 3 MatMul (gate, up, down) + 1 Activation + 1 Mul + 1 Add = 7
|
|
/// Total ops = 16.
|
|
#[test]
|
|
fn transformer_block_node_count() {
|
|
let mut g = GraphBuilder::new();
|
|
let d = 8;
|
|
let dff = 16;
|
|
let x = g.input("x", TensorMeta::new(Dtype::F32, &[4, d]));
|
|
let w_an = g.input("w_an", TensorMeta::new(Dtype::F32, &[d]));
|
|
let w_q = g.input("w_q", TensorMeta::new(Dtype::F32, &[d, d]));
|
|
let w_k = g.input("w_k", TensorMeta::new(Dtype::F32, &[d, d]));
|
|
let w_v = g.input("w_v", TensorMeta::new(Dtype::F32, &[d, d]));
|
|
let w_o = g.input("w_o", TensorMeta::new(Dtype::F32, &[d, d]));
|
|
let w_fn = g.input("w_fn", TensorMeta::new(Dtype::F32, &[d]));
|
|
let w_g = g.input("w_g", TensorMeta::new(Dtype::F32, &[d, dff]));
|
|
let w_u = g.input("w_u", TensorMeta::new(Dtype::F32, &[d, dff]));
|
|
let w_d = g.input("w_d", TensorMeta::new(Dtype::F32, &[dff, d]));
|
|
|
|
let out = blocks::transformer_block(&mut g, x, w_an, w_q, w_k, w_v, w_o, w_fn, w_g, w_u, w_d);
|
|
g.output("y", out);
|
|
let graph = g.build();
|
|
|
|
let mut op_count = 0;
|
|
let mut by_kind: std::collections::HashMap<OpKind, usize> = Default::default();
|
|
for node in &graph.nodes {
|
|
if let jouleclaw_core::graph::NodeKind::Op { op, .. } = &node.kind {
|
|
op_count += 1;
|
|
*by_kind.entry(*op).or_insert(0) += 1;
|
|
}
|
|
}
|
|
assert_eq!(op_count, 16, "transformer block should have 16 op nodes");
|
|
assert_eq!(by_kind.get(&OpKind::MatMul), Some(&9),
|
|
"9 MatMul: 6 in attention (Q/K/V/QK^T/attn*V/Wo), 3 in FFN (gate/up/down)");
|
|
assert_eq!(by_kind.get(&OpKind::Norm), Some(&2));
|
|
assert_eq!(by_kind.get(&OpKind::Add), Some(&2));
|
|
assert_eq!(by_kind.get(&OpKind::Softmax), Some(&1));
|
|
assert_eq!(by_kind.get(&OpKind::Activation), Some(&1));
|
|
assert_eq!(by_kind.get(&OpKind::Mul), Some(&1));
|
|
}
|
|
|
|
/// matmul_bt produces correct shapes regardless of input shapes:
|
|
/// A[m,k] x B[n,k]^T -> [m,n].
|
|
#[test]
|
|
fn matmul_bt_shape_inference() {
|
|
let mut g = GraphBuilder::new();
|
|
let a = g.input("a", TensorMeta::new(Dtype::F32, &[7, 13]));
|
|
let b = g.input("b", TensorMeta::new(Dtype::F32, &[5, 13]));
|
|
let c = g.matmul_bt(a, b);
|
|
g.output("c", c);
|
|
let graph = g.build();
|
|
|
|
let c_node = &graph.nodes[c.0 as usize];
|
|
assert_eq!(c_node.output_meta[0].shape, vec![7, 5],
|
|
"A[7,13] x B[5,13]^T should yield [7,5]");
|
|
if let jouleclaw_core::graph::NodeKind::Op { attrs: OpAttrs::MatMul { transpose_a, transpose_b, .. }, .. } = &c_node.kind {
|
|
assert_eq!(*transpose_a, false);
|
|
assert_eq!(*transpose_b, true);
|
|
} else {
|
|
panic!("expected MatMul node");
|
|
}
|
|
}
|
|
|
|
/// Verifying the activation kinds export properly and the FFN block doesn't
|
|
/// silently lose ActivationKind variants.
|
|
#[test]
|
|
fn ffn_block_uses_silu() {
|
|
let mut g = GraphBuilder::new();
|
|
let d = 4; let dff = 8;
|
|
let x = g.input("x", TensorMeta::new(Dtype::F32, &[2, d]));
|
|
let w_n = g.input("w_n", TensorMeta::new(Dtype::F32, &[d]));
|
|
let w_g = g.input("w_g", TensorMeta::new(Dtype::F32, &[d, dff]));
|
|
let w_u = g.input("w_u", TensorMeta::new(Dtype::F32, &[d, dff]));
|
|
let w_d = g.input("w_d", TensorMeta::new(Dtype::F32, &[dff, d]));
|
|
let out = blocks::ffn_gated_silu(&mut g, x, w_n, w_g, w_u, w_d);
|
|
g.output("y", out);
|
|
let graph = g.build();
|
|
|
|
let mut found_silu = false;
|
|
for node in &graph.nodes {
|
|
if let jouleclaw_core::graph::NodeKind::Op {
|
|
attrs: OpAttrs::Activation { kind: ActivationKind::SiLU }, .. } = &node.kind {
|
|
found_silu = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(found_silu, "FFN block must use SiLU activation");
|
|
}
|