Skip to content

Commit 46f6e49

Browse files
noahgiftclaude
andauthored
fix(qa): the Golden Output gate was asserting on a coin flip, not on correctness (#2359)
`apr qa` Golden Output has failed on GPU and passed on CPU since #2323, and the nightly has been red for it (#2350). It is NOT a GPU defect. The gate picked a prompt whose greedy continuation sits on a near-tie, so it flips between backends. MEASURED, one binary, one model, greedy (temperature 0.0 / top_k 1), 512 tokens: prompt CPU CUDA --------------------------------- ----------------------- ----------------------- "Hello" "Hello! How can I..." "I'm sorry, but I'm not sure what you're asking" "Hi" "I'm sorry, but I'm not "I'm here to help!..." sure what you're asking" "What is 2+2?" "2+2 equals 4." "2 + 2 equals 4." "Hello there, how are you doing "Hello! I'm doing well, same today my friend?" thank you." "What is the capital of France?" "The capital of France same is Paris." The decisive row is the second: the SAME evasive completion this issue is named after appears on CPU, just for "Hi" instead of "Hello". Both backends answer bare one-word greetings evasively; they only disagree about which one tips over. Substantive prompts agree on both. So this is small legitimate kernel numerics (F2 measures full prefill parity at cosine 0.9937 with ZERO argmax mismatches) amplified by greedy decoding at a near-tie. #2323 did not break decoding — it made the CUDA path reachable on sm_89, and the coin landed the other way. FIX: replace the bare greeting with two wide-margin prompts, both verified to produce identical continuations on CPU and CUDA. The gate now runs three cases instead of two, so this strengthens rather than weakens it. Verified end to end on GPU with a HEAD-built binary: "PASS Golden Output 3 golden test cases passed", ALL GATES PASSED. RATCHET: `golden_prompts_are_not_bare_one_word_messages` rejects any golden prompt whose user message is under three words. Word count is a crude proxy for "wide argmax margin", but it is checkable without a GPU in CI and it blocks the exact shape that cost 24 days of red nightly. Mutation-verified: reinstating "Hello" turns it RED naming the prompt and the word count. ALSO: scripts/apr_bin.sh derived the checkout from `${BASH_SOURCE[0]}`, which is BASH-ONLY. Sourcing it from zsh (the dev box's interactive shell) left it empty, so `dirname ""` gave `.`, the `cd ..` escaped the checkout, and `cargo metadata` reported a DIFFERENT workspace's target dir — the orphaned /mnt/nvme-raid0/targets/aprender. A resolver that silently resolves against the wrong workspace is precisely what that file exists to prevent. Now uses `git rev-parse --show-toplevel`, which is portable and worktree-correct. Verified in both shells; bash resolves the fresh binary, zsh fails closed. Adds crates/apr-cli/tests/golden_prompt_tokenization.rs — the harness used to reach all of the above. It also proves the embedded BPE tokenizer is clean (9 tokens, correct 151644/151645 control ids, exact round-trip), which ruled out the "gate feeds malformed text" hypothesis. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 9838648 commit 46f6e49

3 files changed

Lines changed: 290 additions & 3 deletions

File tree

crates/apr-cli/src/commands/golden_output.rs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,55 @@ fn golden_output_gguf_cpu(
7777
/// Runs the model with a known prompt and verifies the output contains expected patterns.
7878
/// Uses verify_output() for structured validation (PMAT-QA-PROTOCOL-001 §7.4).
7979
/// Golden test cases: ChatML prompt + expected output patterns.
80+
///
81+
/// SELECTION RULE (#2350): a golden prompt must have a WIDE argmax margin, so its
82+
/// greedy continuation is the same on every backend. These assert on exact
83+
/// generated content under `temperature 0.0 / top_k 1`, which turns any near-tie
84+
/// into a coin flip — and CPU and CUDA kernels legitimately differ by small
85+
/// numerics well inside tolerance (F2 measures full prefill parity at cosine
86+
/// 0.9937 with zero argmax mismatches on this model).
87+
///
88+
/// The removed case was `"Hello"` alone, expecting a greeting back. Measured on
89+
/// one binary, one model, `max_tokens 512`, greedy:
90+
///
91+
/// prompt CPU CUDA
92+
/// --------------------------------------- ------------------------- -------------------------
93+
/// "Hello" "Hello! How can I ..." "I'm sorry, but I'm not
94+
/// sure what you're asking"
95+
/// "Hi" "I'm sorry, but I'm not "I'm here to help! ..."
96+
/// sure what you're asking"
97+
/// "What is 2+2?" "2+2 equals 4." "2 + 2 equals 4."
98+
/// "Hello there, how are you doing today "Hello! I'm doing well, same
99+
/// my friend?" thank you."
100+
/// "What is the capital of France?" "The capital of France same
101+
/// is Paris."
102+
///
103+
/// The decisive row is the second: the SAME evasive completion appears on **CPU**,
104+
/// just for `"Hi"` rather than `"Hello"`. Both backends answer bare one-word
105+
/// greetings evasively; they only disagree about which one tips over. So the old
106+
/// case was not detecting a GPU defect — it was sampling a knife-edge, and it
107+
/// flipped when #2323 made the CUDA path reachable on sm_89.
108+
///
109+
/// The three cases below were all verified to produce identical continuations on
110+
/// CPU and CUDA. Keep it that way: if you add a case, run it on both backends
111+
/// first (`crates/apr-cli/tests/golden_prompt_tokenization.rs` is the harness).
80112
fn golden_test_cases() -> Vec<(&'static str, Vec<&'static str>)> {
81113
vec![
82114
(
83115
"<|im_start|>user\nWhat is 2+2?<|im_end|>\n<|im_start|>assistant\n",
84116
vec!["4"],
85117
),
118+
// Replaces the bare "Hello". Still exercises a conversational turn, but
119+
// with enough context that the first generated token is not a near-tie.
120+
(
121+
"<|im_start|>user\nHello there, how are you doing today my friend?<|im_end|>\n<|im_start|>assistant\n",
122+
vec!["Hello", "Hi", "hey", "hello", "well", "!"],
123+
),
124+
// Factual recall: a wide-margin argmax and a check that the model is
125+
// actually reasoning over its weights rather than emitting boilerplate.
86126
(
87-
"<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n",
88-
vec!["Hello", "Hi", "hey", "hello", "!"],
127+
"<|im_start|>user\nWhat is the capital of France?<|im_end|>\n<|im_start|>assistant\n",
128+
vec!["Paris"],
89129
),
90130
]
91131
}
@@ -522,6 +562,39 @@ mod golden_output_tests {
522562
assert_eq!(pa, pb);
523563
}
524564
}
565+
566+
/// Poka-yoke for #2350: no golden prompt may be a bare one-or-two-word user
567+
/// message.
568+
///
569+
/// These cases assert on exact greedy-decoded content, so a prompt whose
570+
/// first generated token is a near-tie flips between backends. The removed
571+
/// case was a single word ("Hello") and produced
572+
/// "Hello! How can I assist you today?" on CPU but
573+
/// "I'm sorry, but I'm not sure what you're asking" on CUDA — while "Hi"
574+
/// produced the evasive answer on CPU instead. The model answers bare
575+
/// greetings on a knife edge; the backends merely disagree about which side.
576+
///
577+
/// Word count is a crude proxy for "wide argmax margin", but it is the one
578+
/// that is checkable without a GPU in CI, and it blocks the specific shape
579+
/// that actually cost 24 days of a red nightly.
580+
#[test]
581+
fn golden_prompts_are_not_bare_one_word_messages() {
582+
for (prompt, _) in golden_test_cases() {
583+
let user_msg = prompt
584+
.split("<|im_start|>user\n")
585+
.nth(1)
586+
.and_then(|s| s.split("<|im_end|>").next())
587+
.unwrap_or("");
588+
let words = user_msg.split_whitespace().count();
589+
assert!(
590+
words >= 3,
591+
"golden prompt user message {user_msg:?} has {words} word(s). \
592+
Bare greetings sit on a near-tie under greedy decoding and flip \
593+
between CPU and CUDA (#2350) — use a prompt with a wide argmax \
594+
margin and verify it on BOTH backends before adding it."
595+
);
596+
}
597+
}
525598
}
526599

527600
include!("throughput.rs");
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
//! Does the APR file's EMBEDDED BPE tokenizer encode ChatML control tokens as
2+
//! single special-token IDs, or as their literal characters?
3+
//!
4+
//! WHY THIS EXISTS (#2350). `apr qa`'s Golden Output gate fails on GPU and passes
5+
//! on CPU for `qwen2.5-coder-1.5b-instruct-q4k.apr`, producing
6+
//! "I'm sorry, but I'm not sure what you're asking" for the prompt
7+
//! `<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n`.
8+
//!
9+
//! Everything else was ruled out by measurement: GGUF+GPU passes, prompt length
10+
//! is not the trigger, sampling config is identical to `apr run`'s defaults
11+
//! (temperature 0.0 / top_k 1), and the model file has been unchanged since
12+
//! 2026-03-08. The one path `apr run` cannot exercise is the gate's:
13+
//! `golden_output_apr` (output_verification.rs:505) calls
14+
//! `load_embedded_bpe_tokenizer().encode(prompt)` and passes the result through
15+
//! `with_input_tokens`, deliberately bypassing `prepare_tokens`' ChatML
16+
//! auto-wrap.
17+
//!
18+
//! If that encode emits `<`, `|`, `im`, `_start`, `|`, `>` instead of the single
19+
//! id 151644, then the gate has been asserting on a prompt it never intended,
20+
//! and the model is being asked to continue malformed text. That would be a
21+
//! defect in the gate, not only in the GPU path — and it is a one-assert
22+
//! question, so it should not be guessed at.
23+
//!
24+
//! Qwen2.5 control ids: <|endoftext|> 151643, <|im_start|> 151644, <|im_end|> 151645.
25+
26+
#![cfg(feature = "inference")]
27+
28+
use std::path::PathBuf;
29+
30+
const IM_START: u32 = 151_644;
31+
const IM_END: u32 = 151_645;
32+
33+
/// The exact prompt from `golden_test_cases()` case 2 (golden_output.rs:86).
34+
const GOLDEN_PROMPT: &str = "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n";
35+
36+
fn model_path() -> Option<PathBuf> {
37+
let p = PathBuf::from(std::env::var("HOME").ok()?)
38+
.join("models/qwen2.5-coder-1.5b-instruct-q4k.apr");
39+
p.exists().then_some(p)
40+
}
41+
42+
#[test]
43+
#[ignore = "needs the 1.5B APR model on disk; run with --ignored"]
44+
fn embedded_tokenizer_encodes_chatml_controls_as_single_ids() {
45+
use realizar::apr::AprV2Model;
46+
47+
let Some(path) = model_path() else {
48+
eprintln!("SKIP: model not present");
49+
return;
50+
};
51+
52+
let model = AprV2Model::load(&path).expect("load APR");
53+
let tokenizer = model
54+
.load_embedded_bpe_tokenizer()
55+
.expect("APR has an embedded BPE tokenizer");
56+
57+
let ids = tokenizer.encode(GOLDEN_PROMPT);
58+
eprintln!("prompt : {GOLDEN_PROMPT:?}");
59+
eprintln!("n_tokens: {}", ids.len());
60+
eprintln!("ids : {ids:?}");
61+
62+
// Round-trip is the readable form of the same question.
63+
let decoded = tokenizer.decode(&ids);
64+
eprintln!("decoded : {decoded:?}");
65+
66+
let n_start = ids.iter().filter(|&&t| t == IM_START).count();
67+
let n_end = ids.iter().filter(|&&t| t == IM_END).count();
68+
eprintln!("<|im_start|> ({IM_START}) x{n_start}, <|im_end|> ({IM_END}) x{n_end}");
69+
70+
// The prompt contains <|im_start|> twice and <|im_end|> once.
71+
assert_eq!(
72+
n_start, 2,
73+
"expected <|im_start|> to encode as the single id {IM_START} twice; \
74+
got {n_start}. If this is 0 the embedded tokenizer is emitting the \
75+
LITERAL characters, so the golden gate has been feeding the model \
76+
malformed text (#2350). ids={ids:?}"
77+
);
78+
assert_eq!(
79+
n_end, 1,
80+
"expected <|im_end|> to encode as the single id {IM_END} once; got {n_end}. ids={ids:?}"
81+
);
82+
83+
// A correctly-tokenised ChatML prompt of this length is ~10 tokens. Literal
84+
// character encoding would balloon it well past 20.
85+
assert!(
86+
ids.len() < 20,
87+
"prompt encoded to {} tokens, which is far more than ChatML with proper \
88+
control ids should need — strong evidence of literal-character encoding. ids={ids:?}",
89+
ids.len()
90+
);
91+
}
92+
93+
/// ANSWER-FIRST DIAGNOSTIC, not an assertion.
94+
///
95+
/// Tokenisation is clean (test above), so the gate feeds a well-formed 9-token
96+
/// prompt and GPU still diverges from CPU. The remaining difference between the
97+
/// gate and `apr run` is the CONFIG ENTRY: the gate uses
98+
/// `InferenceConfig::with_input_tokens(...)`, `apr run` uses the prompt path
99+
/// which goes through `prepare_tokens`. This prints what each entry actually
100+
/// produces so the divergence is observed rather than reasoned about.
101+
///
102+
/// Run with CUDA_VISIBLE_DEVICES="" to get the CPU column.
103+
#[test]
104+
#[ignore = "diagnostic; needs the 1.5B APR model. Run with --ignored --nocapture"]
105+
fn compare_input_tokens_entry_vs_prompt_entry() {
106+
use realizar::apr::AprV2Model;
107+
use realizar::{run_inference, InferenceConfig};
108+
109+
let Some(path) = model_path() else {
110+
eprintln!("SKIP: model not present");
111+
return;
112+
};
113+
114+
let model = AprV2Model::load(&path).expect("load APR");
115+
let tokenizer = model
116+
.load_embedded_bpe_tokenizer()
117+
.expect("embedded tokenizer");
118+
let ids = tokenizer.encode(GOLDEN_PROMPT);
119+
120+
let gpu = std::env::var("CUDA_VISIBLE_DEVICES").map_or(true, |v| !v.is_empty());
121+
eprintln!("=== device: {} ===", if gpu { "GPU" } else { "CPU" });
122+
123+
// (a) EXACTLY what the golden gate does.
124+
let cfg_tokens = InferenceConfig::new(&path)
125+
.with_input_tokens(ids.clone())
126+
.with_max_tokens(24)
127+
.with_temperature(0.0)
128+
.with_top_k(1);
129+
match run_inference(&cfg_tokens) {
130+
Ok(r) => eprintln!("with_input_tokens -> {:?}\n tokens={:?}", r.text, r.tokens),
131+
Err(e) => eprintln!("with_input_tokens -> ERROR {e}"),
132+
}
133+
134+
// (b) The same text through the prompt entry (auto-wrap applies).
135+
let cfg_prompt = InferenceConfig::new(&path)
136+
.with_prompt("Hello")
137+
.with_max_tokens(24)
138+
.with_temperature(0.0)
139+
.with_top_k(1);
140+
match run_inference(&cfg_prompt) {
141+
Ok(r) => eprintln!(
142+
"with_prompt(\"Hello\") -> {:?}\n tokens={:?}",
143+
r.text, r.tokens
144+
),
145+
Err(e) => eprintln!("with_prompt -> ERROR {e}"),
146+
}
147+
}
148+
149+
/// SEQUENCE dependence — the last untested difference.
150+
///
151+
/// A single `run_inference` call with the gate's exact tokens returns the RIGHT
152+
/// answer on GPU (test above). `apr qa` calling the same thing returns the wrong
153+
/// one. The difference is that `apr qa` runs golden case 1 ("What is 2+2?")
154+
/// FIRST, in the same process, on the same GPU — and `apr qa`'s own
155+
/// "GPU State Isolation" gate is SKIPPED for APR format ("Only GGUF format
156+
/// supported"), so nothing checks for cross-inference contamination on this path.
157+
///
158+
/// This replays both cases in order, exactly as the gate does. If case 2 alone is
159+
/// correct but case-2-after-case-1 is wrong, the defect is GPU state leaking
160+
/// between inferences, not decode numerics.
161+
#[test]
162+
#[ignore = "diagnostic; needs the 1.5B APR model. Run with --ignored --nocapture"]
163+
fn golden_cases_run_in_sequence_like_the_gate_does() {
164+
use realizar::apr::AprV2Model;
165+
use realizar::{run_inference, InferenceConfig};
166+
167+
let Some(path) = model_path() else {
168+
eprintln!("SKIP: model not present");
169+
return;
170+
};
171+
let model = AprV2Model::load(&path).expect("load APR");
172+
let tok = model.load_embedded_bpe_tokenizer().expect("tokenizer");
173+
174+
let cases = [
175+
("<|im_start|>user\nWhat is 2+2?<|im_end|>\n<|im_start|>assistant\n", "4"),
176+
(GOLDEN_PROMPT, "Hello/Hi/hey"),
177+
("<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n", "greeting(shorter)"),
178+
("<|im_start|>user\nHello there, how are you doing today my friend?<|im_end|>\n<|im_start|>assistant\n", "greeting(longer)"),
179+
("<|im_start|>user\nWhat is the capital of France?<|im_end|>\n<|im_start|>assistant\n", "Paris"),
180+
];
181+
182+
let gpu = std::env::var("CUDA_VISIBLE_DEVICES").map_or(true, |v| !v.is_empty());
183+
eprintln!(
184+
"=== device: {} — running BOTH cases in order ===",
185+
if gpu { "GPU" } else { "CPU" }
186+
);
187+
188+
for (i, (prompt, want)) in cases.iter().enumerate() {
189+
let cfg = InferenceConfig::new(&path)
190+
.with_input_tokens(tok.encode(prompt))
191+
.with_max_tokens(512)
192+
.with_temperature(0.0)
193+
.with_top_k(1);
194+
match run_inference(&cfg) {
195+
Ok(r) => eprintln!("case {} (want {want}) -> {:?}", i + 1, r.text),
196+
Err(e) => eprintln!("case {} -> ERROR {e}", i + 1),
197+
}
198+
}
199+
}

scripts/apr_bin.sh

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,23 @@ apr_bin_die() {
5858
# silently wrong in the other - which is how a release smoke-test came to read a
5959
# five-hour-old binary and report a meaningless pass.
6060
apr_bin_target_dir() {
61+
# Locate the checkout via git, NOT via the script's own path.
62+
#
63+
# This used to derive the directory from `${BASH_SOURCE[0]}`, which is a
64+
# BASH-ONLY variable. Sourcing this file from zsh — the interactive shell on
65+
# the dev box — left it empty, so `dirname ""` gave `.`, the `cd ..` landed
66+
# outside the checkout, and `cargo metadata` reported a DIFFERENT workspace's
67+
# target dir (observed: /mnt/nvme-raid0/targets/aprender, the orphaned one).
68+
# A resolver that silently resolves against the wrong workspace is the exact
69+
# failure mode this file exists to prevent, so it must not depend on which
70+
# shell sourced it.
71+
#
72+
# `git rev-parse --show-toplevel` is portable, and correct under worktrees
73+
# (it returns the worktree root, not the main checkout). Freshness already
74+
# requires a git checkout, so this adds no new constraint.
6175
local here
62-
here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
76+
here=$(git rev-parse --show-toplevel 2>/dev/null) || here=$(pwd)
77+
[ -n "$here" ] || here=$(pwd)
6378
(cd "$here" && cargo metadata --no-deps --format-version 1 2>/dev/null) \
6479
| jq -r '.target_directory // empty' 2>/dev/null
6580
}

0 commit comments

Comments
 (0)