baedeker_core/runtime/verify.rs
1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Numerical correctness verification for GPU offload kernels (Layer 2).
5//!
6//! This is the **pure, no-std core** of the DeepReinforce exact-match
7//! correctness protocol — the protocol described in [_Towards a Reliable
8//! Kernel Correctness Check in Matrix
9//! Multiplication_](https://deep-reinforce.com/correctness_check.html).
10//! The GPU-dependent driver lives in the `baedeker-borsalino` adapter (it
11//! needs `rand` + a concrete [`GpuBackend`](crate::runtime::gpu::GpuBackend)).
12//!
13//! # Why exact match, not tolerance
14//!
15//! Tolerance-based checks (`abs(gpu - ref) < eps`) are unreliable for GPU
16//! kernels because floating-point associativity does not hold:
17//! `(a + b) + c ≠ a + (b + c)` in reduced precision, and different GPU thread
18//! orderings produce different accumulation sequences. Two correct kernels can
19//! therefore emit different outputs, and no universal tolerance works across
20//! matrix sizes or precisions.
21//!
22//! The exact-match protocol sidesteps this by restricting kernel inputs to
23//! **binary `{0, 1}`** values with a zero-biased distribution. This guarantees
24//! every partial sum is a small non-negative integer. Within the exact-integer
25//! range of the target precision — `[0, 2048]` for FP16, and far wider for
26//! FP32 — floating-point associativity holds **exactly**. The kernel output is
27//! then compared against an FP32 CPU reference with **bit-exact equality** at
28//! every position whose reference value is at or below the threshold;
29//! positions above the threshold are ignored (they have lost exactness).
30//!
31//! # Applicability
32//!
33//! The protocol applies to **linear** kernels (elementwise add/scale, saxpy,
34//! matmul) and bilinear kernels with binary operands (geometric product). It
35//! does **not** apply to non-linear operations (`log`, `exp`, `tanh`): those
36//! produce irrational outputs that cannot be checked with exact match.
37//!
38//! Baedeker's first verified kernel is the [`F32_ADD_WGSL`] elementwise add —
39//! see [`f32_add_reference`] for its CPU reference. Adding another offload
40//! kernel means adding its reference function here and a driver entry in the
41//! adapter.
42//!
43//! # Layering
44//!
45//! This module is Layer 2 of the "both, layered" GPU verification strategy.
46//! Layer 1 ([`GpuBackend::dispatch_verified`](crate::runtime::gpu::GpuBackend::dispatch_verified))
47//! is a uniform structural check (workgroup-divisibility proof) that runs on
48//! every dispatch. Layer 2 (this module) is opt-in numerical correctness for
49//! known-linear kernels, run on demand to prove a kernel's math is right.
50
51use alloc::vec::Vec;
52
53/// FP16 exact-integer ceiling: the largest integer exactly representable in
54/// half precision. Positions whose FP32 CPU reference exceeds this are
55/// ignored by [`compare_outputs`] because they may have lost exactness under
56/// reduced-precision accumulation.
57///
58/// FP32's own exact-integer ceiling is ~16 million, so this threshold is the
59/// binding constraint whenever a kernel might run (or be compared) at half
60/// precision.
61pub const FP_BINARY_THRESHOLD: f32 = 2048.0;
62
63/// Production WGSL for the `f32_add` SIMD offload kernel: `out[i] = a[i] + b[i]`.
64///
65/// Three storage bindings (`a`, `b` read; `out` read-write), `@workgroup_size(256)`,
66/// bounds-checked against `arrayLength(&out)`. The runtime caches a compiled
67/// copy per store; the verification driver compiles this same source so it
68/// exercises the production kernel, not a parallel verification kernel.
69pub const F32_ADD_WGSL: &str = r#"
70@group(0) @binding(0) var<storage, read> a: array<f32>;
71@group(0) @binding(1) var<storage, read> b: array<f32>;
72@group(0) @binding(2) var<storage, read_write> out: array<f32>;
73
74@compute @workgroup_size(256)
75fn vadd(@builtin(global_invocation_id) gid: vec3<u32>) {
76 let i = gid.x;
77 if (i < arrayLength(&out)) {
78 out[i] = a[i] + b[i];
79 }
80}
81"#;
82
83/// Configuration for the exact-match numerical correctness protocol.
84///
85/// # Defaults
86///
87/// | Field | Default | Rationale |
88/// |---|---|---|
89/// | `threshold` | 2048.0 | [`FP_BINARY_THRESHOLD`] — FP16 exact-integer ceiling |
90/// | `trials` | 16 | Enough random binary trials to catch systematic bugs |
91/// | `p_zero` | 0.7 | 70% zeros keeps accumulated sums below the threshold |
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct VerifyConfig {
94 /// Bit-exact ceiling. Positions where the FP32 CPU reference exceeds this
95 /// value are ignored (they may have lost floating-point exactness).
96 pub threshold: f32,
97
98 /// Number of random binary-input trials to run.
99 pub trials: u32,
100
101 /// Probability of sampling `0.0` vs `1.0` for each input element. Higher
102 /// zero bias keeps accumulated sums below the threshold for larger
103 /// problem sizes.
104 pub p_zero: f32,
105}
106
107impl Default for VerifyConfig {
108 fn default() -> Self {
109 Self {
110 threshold: FP_BINARY_THRESHOLD,
111 trials: 16,
112 p_zero: 0.7,
113 }
114 }
115}
116
117/// Result of an exact-match numerical correctness check.
118///
119/// Aggregated across trials by the driver: `positions_checked` and
120/// `positions_exact` sum over every trial, while `max_diff` is the maximum
121/// observed at any checked position. `passed` is true only if **every**
122/// position at or below the threshold matched exactly across **every** trial
123/// (and at least one position was checked).
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct VerifyResult {
126 /// Whether the kernel passed (every checked position exact, ≥1 checked).
127 pub passed: bool,
128
129 /// Number of trials run.
130 pub trials: u32,
131
132 /// Total output positions compared across all trials (reference ≤ threshold).
133 pub positions_checked: usize,
134
135 /// Positions that matched the reference exactly.
136 pub positions_exact: usize,
137
138 /// Maximum absolute difference at checked positions. `0.0` for a correct
139 /// kernel.
140 pub max_diff: f32,
141}
142
143impl VerifyResult {
144 /// An empty aggregator to fold per-trial [`compare_outputs`] results into.
145 pub fn empty_aggregator() -> Self {
146 Self {
147 passed: true,
148 trials: 0,
149 positions_checked: 0,
150 positions_exact: 0,
151 max_diff: 0.0,
152 }
153 }
154
155 /// Fold a single-trial result into an aggregator.
156 pub fn fold_trial(&mut self, trial: VerifyResult) {
157 self.trials += trial.trials;
158 self.positions_checked += trial.positions_checked;
159 self.positions_exact += trial.positions_exact;
160 if trial.max_diff > self.max_diff {
161 self.max_diff = trial.max_diff;
162 }
163 if !trial.passed {
164 self.passed = false;
165 }
166 }
167}
168
169/// Compare GPU output against an FP32 reference with threshold gating.
170///
171/// For each position where `reference[i] <= threshold`, the GPU value must
172/// match exactly. Positions above the threshold are ignored. This is the pure,
173/// GPU-free heart of the protocol — fully unit-testable.
174///
175/// Returns a single-trial result (`trials == 1`). Pass is `true` only if at
176/// least one position was checked and every checked position matched exactly.
177///
178/// ```
179/// use baedeker_core::runtime::verify::compare_outputs;
180///
181/// let gpu = [1.0_f32, 2.0, 3.0];
182/// let r#ref = [1.0_f32, 2.0, 3.0];
183/// assert!(compare_outputs(&gpu, &r#ref, 2048.0).passed);
184/// ```
185pub fn compare_outputs(gpu_output: &[f32], reference: &[f32], threshold: f32) -> VerifyResult {
186 let mut positions_checked = 0usize;
187 let mut positions_exact = 0usize;
188 let mut max_diff = 0.0f32;
189
190 for (gpu_val, ref_val) in gpu_output.iter().zip(reference.iter()) {
191 if *ref_val <= threshold {
192 positions_checked += 1;
193 let diff = (*gpu_val - ref_val).abs();
194 if diff == 0.0 {
195 positions_exact += 1;
196 }
197 if diff > max_diff {
198 max_diff = diff;
199 }
200 }
201 }
202
203 let passed = positions_checked > 0 && positions_exact == positions_checked;
204 VerifyResult {
205 passed,
206 trials: 1,
207 positions_checked,
208 positions_exact,
209 max_diff,
210 }
211}
212
213/// FP32 CPU reference for the [`F32_ADD_WGSL`] kernel: `out[i] = a[i] + b[i]`.
214///
215/// With binary `{0, 1}` inputs every output lies in `{0, 1, 2}`, all far below
216/// [`FP_BINARY_THRESHOLD`], so every position is checked.
217///
218/// ```
219/// use baedeker_core::runtime::verify::f32_add_reference;
220///
221/// let out = f32_add_reference(&[0.0, 1.0, 1.0], &[1.0, 0.0, 1.0]);
222/// assert_eq!(out, [1.0, 1.0, 2.0]);
223/// ```
224pub fn f32_add_reference(a: &[f32], b: &[f32]) -> Vec<f32> {
225 a.iter().zip(b.iter()).map(|(&x, &y)| x + y).collect()
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 // ── VerifyConfig ──────────────────────────────────────────────
233
234 #[test]
235 fn default_config_uses_fp16_threshold() {
236 let cfg = VerifyConfig::default();
237 assert_eq!(cfg.threshold, FP_BINARY_THRESHOLD);
238 assert_eq!(cfg.threshold, 2048.0);
239 assert!(cfg.trials >= 1);
240 assert!(cfg.p_zero > 0.0 && cfg.p_zero < 1.0);
241 }
242
243 // ── compare_outputs: correct kernel ───────────────────────────
244
245 #[test]
246 fn compare_outputs_exact_match_passes() {
247 let gpu = [1.0_f32, 2.0, 3.0, 4.0];
248 let r#ref = [1.0_f32, 2.0, 3.0, 4.0];
249 let result = compare_outputs(&gpu, &r#ref, 2048.0);
250 assert!(result.passed);
251 assert_eq!(result.positions_checked, 4);
252 assert_eq!(result.positions_exact, 4);
253 assert_eq!(result.max_diff, 0.0);
254 }
255
256 // ── compare_outputs: incorrect kernel detected ────────────────
257
258 #[test]
259 fn compare_outputs_mismatch_detected() {
260 let gpu = [1.0_f32, 2.0, 3.0, 5.0]; // last element wrong
261 let r#ref = [1.0_f32, 2.0, 3.0, 4.0];
262 let result = compare_outputs(&gpu, &r#ref, 2048.0);
263 assert!(!result.passed);
264 assert_eq!(result.positions_checked, 4);
265 assert_eq!(result.positions_exact, 3);
266 assert_eq!(result.max_diff, 1.0);
267 }
268
269 // ── compare_outputs: threshold gating ─────────────────────────
270
271 #[test]
272 fn compare_outputs_ignores_positions_above_threshold() {
273 // 3000 > 2048 threshold → that position is ignored, not a failure.
274 let gpu = [1.0_f32, 2.0, 3000.0];
275 let r#ref = [1.0_f32, 2.0, 2049.0];
276 let result = compare_outputs(&gpu, &r#ref, 2048.0);
277 assert!(result.passed);
278 assert_eq!(result.positions_checked, 2);
279 assert_eq!(result.positions_exact, 2);
280 }
281
282 #[test]
283 fn compare_outputs_threshold_boundary_inclusive() {
284 // Reference exactly at threshold IS checked.
285 let gpu = [2048.0_f32];
286 let r#ref = [2048.0_f32];
287 let result = compare_outputs(&gpu, &r#ref, 2048.0);
288 assert!(result.passed);
289 assert_eq!(result.positions_checked, 1);
290 }
291
292 // ── compare_outputs: vacuous check is not a pass ──────────────
293
294 #[test]
295 fn compare_outputs_all_above_threshold_is_not_a_pass() {
296 // No checked positions means no evidence — must not pass vacuously,
297 // otherwise a totally-out-of-range kernel would look correct.
298 let gpu = [5000.0_f32, 6000.0];
299 let r#ref = [5000.0_f32, 6000.0];
300 let result = compare_outputs(&gpu, &r#ref, 2048.0);
301 assert!(!result.passed, "vacuous pass hides lack of evidence");
302 assert_eq!(result.positions_checked, 0);
303 }
304
305 #[test]
306 fn compare_outputs_unequal_lengths_compare_only_overlap() {
307 // zip stops at the shorter slice; extra GPU elements are never read.
308 let gpu = [1.0_f32, 2.0, 3.0];
309 let r#ref = [1.0_f32, 2.0];
310 let result = compare_outputs(&gpu, &r#ref, 2048.0);
311 assert!(result.passed);
312 assert_eq!(result.positions_checked, 2);
313 }
314
315 // ── f32_add_reference ─────────────────────────────────────────
316
317 #[test]
318 fn f32_add_reference_sums_elementwise() {
319 let out = f32_add_reference(&[0.0, 1.0, 1.0, 0.0], &[1.0, 0.0, 1.0, 0.0]);
320 assert_eq!(out, [1.0, 1.0, 2.0, 0.0]);
321 }
322
323 #[test]
324 fn f32_add_reference_binary_outputs_stay_below_threshold() {
325 // Every binary-input sum is in {0,1,2}, all ≤ 2048 → all checked.
326 let a = [0.0_f32, 1.0, 1.0];
327 let b = [1.0_f32, 1.0, 0.0];
328 let out = f32_add_reference(&a, &b);
329 assert!(out.iter().all(|&v| v <= FP_BINARY_THRESHOLD));
330 }
331
332 // ── VerifyResult aggregation ──────────────────────────────────
333
334 #[test]
335 fn aggregator_folds_passing_trials_into_pass() {
336 let mut agg = VerifyResult::empty_aggregator();
337 agg.fold_trial(compare_outputs(&[1.0], &[1.0], 2048.0));
338 agg.fold_trial(compare_outputs(&[2.0, 3.0], &[2.0, 3.0], 2048.0));
339 assert!(agg.passed);
340 assert_eq!(agg.trials, 2);
341 assert_eq!(agg.positions_checked, 3);
342 assert_eq!(agg.positions_exact, 3);
343 }
344
345 #[test]
346 fn aggregator_folds_any_failing_trial_into_fail() {
347 let mut agg = VerifyResult::empty_aggregator();
348 agg.fold_trial(compare_outputs(&[1.0], &[1.0], 2048.0));
349 agg.fold_trial(compare_outputs(&[9.0], &[1.0], 2048.0)); // mismatch
350 assert!(!agg.passed);
351 assert_eq!(agg.trials, 2);
352 assert_eq!(agg.positions_checked, 2);
353 assert_eq!(agg.positions_exact, 1, "only the matching trial counted");
354 assert_eq!(agg.max_diff, 8.0);
355 }
356}