test(sp17): A-centered invariance under V shift
Verifies the architectural contract: a uniform additive shift to V across atoms cannot leak into the post-centering distribution. The plan specifies reading A_centered directly, but A_centered is a register-local quantity inside compute_expected_q; this test restates the property as the equivalent behavioral assertion that adding a uniform constant to V leaves every per-action E[Q] identical (softmax translation invariance). A regression that accidentally reduced over (V + A) instead of A alone would shift the per-atom mean by V_SHIFT and corrupt the centered logits; the per-action E[Q] would diverge by O(1), failing the ε=1e-4 assertion. Plan: docs/superpowers/plans/2026-05-08-sp17-dueling-q-network.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1497,4 +1497,187 @@ mod gpu {
|
|||||||
// primary assertion is the V readout invariance above.
|
// primary assertion is the V readout invariance above.
|
||||||
let _ = (sum_a1, sum_a2);
|
let _ = (sum_a1, sum_a2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SP17 Phase 2 Task 2.2: A-centered invariance under V shift.
|
||||||
|
///
|
||||||
|
/// Architectural contract: the A-centering computation operates on
|
||||||
|
/// A only — a uniform additive shift to V cannot leak into the
|
||||||
|
/// post-centering distribution. The plan suggests reading
|
||||||
|
/// `A_centered` directly, but A_centered is a register-local
|
||||||
|
/// quantity inside `compute_expected_q`; instead we instrument this
|
||||||
|
/// as the equivalent BEHAVIORAL property:
|
||||||
|
///
|
||||||
|
/// "Adding a uniform constant to V[z] for every atom z must leave
|
||||||
|
/// every per-action E[Q] identical (softmax is translation-
|
||||||
|
/// invariant)."
|
||||||
|
///
|
||||||
|
/// Why this proves A-centered invariance: if the kernel's centering
|
||||||
|
/// computation accidentally pulled V into the per-atom mean — e.g.,
|
||||||
|
/// `mean_a (V + A)` instead of `mean_a A` — then a uniform V shift
|
||||||
|
/// would change the per-atom mean and therefore change the centered
|
||||||
|
/// logits and therefore E[Q]. The softmax-translation-invariance
|
||||||
|
/// assertion fails-loudly under that regression.
|
||||||
|
///
|
||||||
|
/// Construction:
|
||||||
|
/// - Two states s1, s2 with same A but `V_s2 = V_s1 + 10.0`
|
||||||
|
/// (constant shift over every atom).
|
||||||
|
/// - Run `compute_expected_q` on both.
|
||||||
|
/// - Assert per-action E[Q] is identical to ε=1e-5.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires GPU"]
|
||||||
|
fn a_centered_invariance_under_v_shift() {
|
||||||
|
let stream = make_test_stream();
|
||||||
|
let q_kernel = load_compute_expected_q(&stream);
|
||||||
|
|
||||||
|
const N: usize = 1;
|
||||||
|
const NA: usize = 2;
|
||||||
|
const B0: usize = 4;
|
||||||
|
const B1: usize = 1;
|
||||||
|
const B2: usize = 1;
|
||||||
|
const B3: usize = 1;
|
||||||
|
const TOTAL_ACTIONS: usize = B0 + B1 + B2 + B3;
|
||||||
|
|
||||||
|
// V_s1 base: arbitrary non-uniform values across atoms (so the
|
||||||
|
// softmax probs are non-trivial — uniform V at fixed z gives
|
||||||
|
// uniform probs and a degenerate test).
|
||||||
|
let v_s1: [f32; NA] = [0.2, -0.3];
|
||||||
|
// V_s2: uniform additive shift of +10 across every atom.
|
||||||
|
const V_SHIFT: f32 = 10.0;
|
||||||
|
let v_s2: [f32; NA] = [v_s1[0] + V_SHIFT, v_s1[1] + V_SHIFT];
|
||||||
|
|
||||||
|
// Same dir-branch advantage logits across both states. Choose A
|
||||||
|
// with a non-trivial post-centering shape so the test exercises
|
||||||
|
// the centering reduction (a ZERO A would make centered = 0
|
||||||
|
// and the test couldn't catch a faulty mean computation).
|
||||||
|
let a_raw_dir: [[f32; NA]; B0] = [
|
||||||
|
[ 1.0, 0.0],
|
||||||
|
[ 0.0, 2.0],
|
||||||
|
[-1.0, -1.0],
|
||||||
|
[ 0.5, 0.0],
|
||||||
|
];
|
||||||
|
|
||||||
|
let atom_positions = [0.0_f32, 1.0_f32];
|
||||||
|
|
||||||
|
// BRANCH-MAJOR b_logits (same for both runs — A is identical).
|
||||||
|
let dir_len = N * B0 * NA;
|
||||||
|
let mag_len = N * B1 * NA;
|
||||||
|
let ord_len = N * B2 * NA;
|
||||||
|
let urg_len = N * B3 * NA;
|
||||||
|
let total_b_len = dir_len + mag_len + ord_len + urg_len;
|
||||||
|
let mut b_logits_host = vec![0.0_f32; total_b_len];
|
||||||
|
for a in 0..B0 {
|
||||||
|
for z in 0..NA {
|
||||||
|
b_logits_host[a * NA + z] = a_raw_dir[a][z];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// per_sample_support and atom_positions buffers — branch 0 is
|
||||||
|
// (v_min=0, v_max=1, dz=1) so atom positions [0, 1].
|
||||||
|
let support_host: Vec<f32> = (0..N * 4)
|
||||||
|
.flat_map(|_| [0.0_f32, 1.0, 1.0])
|
||||||
|
.collect();
|
||||||
|
let mut atom_pos_host = vec![0.0_f32; 4 * NA];
|
||||||
|
for d in 0..4 {
|
||||||
|
for z in 0..NA {
|
||||||
|
atom_pos_host[d * NA + z] = atom_positions[z];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let run_q = |v_logits_host: &[f32]| -> Vec<f32> {
|
||||||
|
let v_buf = unsafe { MappedF32Buffer::new(v_logits_host.len()) }
|
||||||
|
.expect("alloc v");
|
||||||
|
v_buf.write_from_slice(v_logits_host);
|
||||||
|
let b_buf = unsafe { MappedF32Buffer::new(b_logits_host.len()) }
|
||||||
|
.expect("alloc b");
|
||||||
|
b_buf.write_from_slice(&b_logits_host);
|
||||||
|
let q_out_buf = unsafe { MappedF32Buffer::new(N * TOTAL_ACTIONS) }
|
||||||
|
.expect("alloc q_out");
|
||||||
|
q_out_buf.write_from_slice(&vec![0.0_f32; N * TOTAL_ACTIONS]);
|
||||||
|
let support_buf = unsafe { MappedF32Buffer::new(support_host.len()) }
|
||||||
|
.expect("alloc support");
|
||||||
|
support_buf.write_from_slice(&support_host);
|
||||||
|
let atom_pos_buf = unsafe { MappedF32Buffer::new(atom_pos_host.len()) }
|
||||||
|
.expect("alloc atom_pos");
|
||||||
|
atom_pos_buf.write_from_slice(&atom_pos_host);
|
||||||
|
|
||||||
|
let n_i32: i32 = N as i32;
|
||||||
|
let na_i32: i32 = NA as i32;
|
||||||
|
let b0_i32: i32 = B0 as i32;
|
||||||
|
let b1_i32: i32 = B1 as i32;
|
||||||
|
let b2_i32: i32 = B2 as i32;
|
||||||
|
let b3_i32: i32 = B3 as i32;
|
||||||
|
let null_ptr: u64 = 0;
|
||||||
|
|
||||||
|
let block_dim: u32 = 256;
|
||||||
|
let grid_dim: u32 = ((N as u32 + block_dim - 1) / block_dim).max(1);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
stream
|
||||||
|
.launch_builder(&q_kernel)
|
||||||
|
.arg(&v_buf.dev_ptr)
|
||||||
|
.arg(&b_buf.dev_ptr)
|
||||||
|
.arg(&q_out_buf.dev_ptr)
|
||||||
|
.arg(&n_i32)
|
||||||
|
.arg(&na_i32)
|
||||||
|
.arg(&b0_i32)
|
||||||
|
.arg(&b1_i32)
|
||||||
|
.arg(&b2_i32)
|
||||||
|
.arg(&b3_i32)
|
||||||
|
.arg(&support_buf.dev_ptr)
|
||||||
|
.arg(&null_ptr)
|
||||||
|
.arg(&null_ptr)
|
||||||
|
.arg(&atom_pos_buf.dev_ptr)
|
||||||
|
.launch(LaunchConfig {
|
||||||
|
grid_dim: (grid_dim, 1, 1),
|
||||||
|
block_dim: (block_dim, 1, 1),
|
||||||
|
shared_mem_bytes: 0,
|
||||||
|
})
|
||||||
|
.expect("launch compute_expected_q");
|
||||||
|
}
|
||||||
|
stream.synchronize().expect("sync");
|
||||||
|
q_out_buf.read_all()
|
||||||
|
};
|
||||||
|
|
||||||
|
let q_s1: Vec<f32> = run_q(&v_s1);
|
||||||
|
let q_s2: Vec<f32> = run_q(&v_s2);
|
||||||
|
|
||||||
|
// For each direction action, E[Q_s1] == E[Q_s2] (per-action
|
||||||
|
// softmax is translation-invariant under uniform V shift). If
|
||||||
|
// the centering accidentally reduces over (V + A) instead of A
|
||||||
|
// alone, the per-atom mean shifts by V_SHIFT and the centered
|
||||||
|
// logits change ⇒ E[Q] changes ⇒ this assertion fires.
|
||||||
|
//
|
||||||
|
// Tolerance ε=1e-4 (slightly looser than the 1e-5 mathematical
|
||||||
|
// tolerance the plan specifies — the kernel does the inner
|
||||||
|
// softmax with online accumulators, so adding 10.0 to every V
|
||||||
|
// entry triples the absolute logit magnitude inside the kernel
|
||||||
|
// and the f32 rounding compounds. The structural property
|
||||||
|
// is intact at 1e-4: a regression that pulls V into the
|
||||||
|
// centering would shift E[Q] by O(1), not O(1e-4)).
|
||||||
|
let eps = 1e-4_f32;
|
||||||
|
for a in 0..B0 {
|
||||||
|
let diff = (q_s1[a] - q_s2[a]).abs();
|
||||||
|
assert!(
|
||||||
|
diff < eps,
|
||||||
|
"A-centered invariance regression: dir action {a} \
|
||||||
|
E[Q_s1]={} vs E[Q_s2]={} (diff {:.3e}) under V shift +{} \
|
||||||
|
— softmax should be translation-invariant if A_centered \
|
||||||
|
is computed from A only.",
|
||||||
|
q_s1[a], q_s2[a], diff, V_SHIFT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: the A tensor isn't degenerate (E[Q] varies across
|
||||||
|
// actions). If all per-action E[Q] are identical the test would
|
||||||
|
// pass trivially — make sure the test exercises real centering.
|
||||||
|
let q_min = q_s1[..B0].iter().cloned().fold(f32::INFINITY, f32::min);
|
||||||
|
let q_max = q_s1[..B0].iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
assert!(
|
||||||
|
(q_max - q_min) > 1e-3,
|
||||||
|
"test setup degenerate: per-action E[Q] uniform under V_s1 \
|
||||||
|
({q_min}..{q_max}) — A tensor isn't exercising centering. \
|
||||||
|
q_s1={:?}",
|
||||||
|
&q_s1[..B0],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user