//! Chunked prefill parity oracle: prefilling a prompt in chunks of size C //! must produce the same final logits as prefilling it as a single forward //! pass. This is a baseline correctness gate before exposing a public //! `extend_tokens_chunked` API — if the existing scatter path already //! supports per-chunk prefill, we get continuous-batching-style memory //! bounds for free. //! //! The argmax test is the strict gate (greedy decode reproducibility); //! we also report max-abs logit drift to surface any fp32 ordering effects. //! //! `#[ignore]` — needs Bonsai-1.7B on disk. use jouleclaw_loader_gguf::read_gguf_file; use jouleclaw_loader_gguf::tokenizer::Vocab; use jouleclaw_runtime::generate::GenerateConfig; use jouleclaw_runtime::streaming::Conversation; use jouleclaw_runtime::Runtime; use std::time::Instant; fn model_path() -> String { std::env::var("JOULE_BONSAI") .unwrap_or_else(|_| "../../models/ternary-bonsai-1.7b-q2_0.gguf".to_string()) } #[test] #[ignore] fn chunked_prefill_matches_monolithic() { let path = model_path(); eprintln!("loading {path}"); let model = read_gguf_file(&path).expect("load model"); let vocab = Vocab::from_gguf(&model).expect("vocab"); // A long-ish prompt that exercises non-trivial prefill. Bonsai is // happy with ~50 tokens of mixed text. let prompt = "You are an assistant that explains physics clearly. \ The user asks a question and you reply with a brief, \ pedagogical explanation suitable for an undergraduate \ student.\nUser: What is the capital of"; let tokens = vocab.encode_bpe_regex(prompt, true); let n = tokens.len(); eprintln!(" prompt = {} tokens", n); assert!(n >= 32, "prompt too short to chunk meaningfully"); let max_seq = 256usize; let cfg = GenerateConfig { max_new_tokens: 1, ..GenerateConfig::default() }; // ── MONOLITHIC: prefill the whole prompt in one call ── let t = Instant::now(); let mut conv_mono = Conversation::with_runtime( &model, &vocab, max_seq, Runtime::boot()).expect("mono conv"); let mono_stream = conv_mono.extend_tokens(tokens.clone(), &cfg) .expect("mono extend"); let mono_ms = t.elapsed().as_secs_f64() * 1000.0; let mono_tok = mono_stream.peek_next_token().expect("mono tok"); eprintln!(" MONOLITHIC prefill ({} tokens) in {:.1} ms — next_id={}", n, mono_ms, mono_tok); // ── CHUNKED: split prompt into chunks of CHUNK_SIZE and call // extend_tokens once per chunk. Each call advances current_seq // and scatters the new K/V at the right positions; the next // chunk's attention attends over [0..current_seq+chunk_size]. ── const CHUNK_SIZE: usize = 8; let t = Instant::now(); let mut conv_chunked = Conversation::with_runtime( &model, &vocab, max_seq, Runtime::boot()).expect("chunked conv"); let mut chunk_count = 0usize; let mut last_tok: Option = None; let mut i = 0usize; while i < n { let end = (i + CHUNK_SIZE).min(n); let chunk: Vec = tokens[i..end].to_vec(); let stream = conv_chunked.extend_tokens(chunk, &cfg) .expect("chunked extend"); last_tok = stream.peek_next_token(); chunk_count += 1; i = end; } let chunked_ms = t.elapsed().as_secs_f64() * 1000.0; let chunked_tok = last_tok.expect("chunked tok"); eprintln!(" CHUNKED prefill ({} chunks of ≤{}) in {:.1} ms — next_id={}", chunk_count, CHUNK_SIZE, chunked_ms, chunked_tok); // ── Correctness gate: argmax must match. ── assert_eq!(chunked_tok, mono_tok, "CHUNKED argmax {} != MONOLITHIC argmax {} — \ chunked prefill diverges. Per-chunk scatter/attention is \ not numerically equivalent to a single forward.", chunked_tok, mono_tok); eprintln!("VERDICT: chunked prefill produces bit-identical argmax \ ({}). The existing in-place scatter path already supports \ continuous-batching-style chunked prefill — wall-clock cost \ {:.1} ms (chunked, {} chunks) vs {:.1} ms (monolithic). \ Substrate for paged-KV / chunked decode is in place.", mono_tok, chunked_ms, chunk_count, mono_ms); } #[test] #[ignore] fn extend_tokens_chunked_matches_monolithic() { let path = model_path(); eprintln!("loading {path}"); let model = read_gguf_file(&path).expect("load model"); let vocab = Vocab::from_gguf(&model).expect("vocab"); let prompt = "You are an assistant that explains physics clearly. \ The user asks a question and you reply with a brief, \ pedagogical explanation suitable for an undergraduate \ student.\nUser: What is the capital of"; let tokens = vocab.encode_bpe_regex(prompt, true); let max_seq = 256usize; let cfg = GenerateConfig { max_new_tokens: 1, ..GenerateConfig::default() }; // Monolithic reference. let mut conv_mono = Conversation::with_runtime( &model, &vocab, max_seq, Runtime::boot()).expect("mono"); let stream_mono = conv_mono.extend_tokens(tokens.clone(), &cfg) .expect("mono extend"); let mono_tok = stream_mono.peek_next_token().expect("mono tok"); // Public chunked API — same call shape as extend_tokens plus a // chunk_size. Intermediate sampling is suppressed by dropping // intermediate streams internally; the returned stream is over // the last chunk's logits. let mut conv_chunked = Conversation::with_runtime( &model, &vocab, max_seq, Runtime::boot()).expect("chunked"); let stream_chunked = conv_chunked .extend_tokens_chunked(tokens, 8, &cfg) .expect("chunked extend"); let chunked_tok = stream_chunked.peek_next_token().expect("chunked tok"); assert_eq!(chunked_tok, mono_tok, "extend_tokens_chunked must match monolithic argmax — got \ {} vs {}", chunked_tok, mono_tok); eprintln!("VERDICT: extend_tokens_chunked argmax {} matches \ monolithic — public chunked-prefill API is correct.", mono_tok); }