Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/blackboard.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 2026-10-16 — `eq_u32_via_lane_to_mask`: the lane-valued twin of `eq_u32_via_to_mask`

- **Added:** `simd_masking_ops::eq_u32_via_lane_to_mask(index, table, local, out_words)`, re-exported from `ndarray::simd`; row i is set when `index[i] < table.len()` and `table[index[i]] == local[i]`. Parity group 0xD41.
- **Why:** lance-graph D-ASM-11A (`.claude/plans/wcoj-closing-atoms-v1.md` §2). A cyclic join's closing atom compares a row's own lane with a foreign lane read through an address lane; with a constant compare it needs one disjunct per value (measured there: 66 ops at |D| = 16, 1,026 at 256). Consumer: lance-graph-mask-risc `run_pred`.
- **Scalar, stated:** the address comes out of another array. The sibling's note said "no vector gather on any backend"; corrected: an `unsafe`, unbounded `f32` gather exists (`F32x16::gather`), but no bounds-masked `u32` gather-and-compare wrapper, which the zero-fallback contract needs. Whether one pays is unmeasured (lance-graph TD-NO-BOUNDED-U32-GATHER-1).
- **Verified:** 5 unit tests + doctest; disable run (compare every row against `local[0]`) turned 2 of them red; `simd-masking-parity` OK on the v4 arm (`avx512f=true`), which checks portability of the one scalar body, not a backend divergence; clippy `-D warnings` clean.

## 2026-10-14 — Haiku cards for supervised harvest and mapping

- Five Haiku cards added (`harvest-scout`, `census-counter`, `quote-harvester`, `substrate-mapper`, `spec-editor`); rules in `.claude/rules/agent-model-tiers.md`, CLAUDE.md Agent Protocol item 7. No `guarded-executor`: only the orchestrator compiles here.
Expand Down
17 changes: 16 additions & 1 deletion crates/simd-masking-parity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@
//! the index-addressed permutation/scatter family); `0xD3x` the
//! index-addressed `masked_group_sum_i32_via` (two-hop zero-fallback); `0xD4x`
//! `eq_u32_via_to_mask` (the same index lane, packed as a predicate rather than
//! folded into a sum); `0xExx` the `F64x8` lane compares (all six relations
//! folded into a sum), `0xD41` its lane-valued twin `eq_u32_via_lane_to_mask`; `0xExx` the `F64x8` lane compares (all six relations
//! over every pair of 16 IEEE edge values: NaN, ±0, ±inf, subnormal); `0x73x` / `0x74x` / `0x750` the strided 16-byte register match (all sixteen
//! bytes; strides 16 / 20 / 32 / 512 from first offsets 0..15, care bits in
//! bytes 12..16); `0xFxx` the 16-lane byte vectors `I8x16` / `U8x16` (wrapping
//! `add`/`sub`, signed/unsigned `min`/`max`, the load/store round trips). `main.rs` (native / qemu) and
//! `selfcheck()` (the wasm cdylib export, driven by `run.mjs`) both call
//! [`run`].

use ndarray::simd::eq_u32_via_lane_to_mask;
use ndarray::simd::{
blend_i32, eq_i32_to_mask, eq_i32_to_mask_under, eq_u32_strided_to_mask, eq_u32_to_mask, eq_u32_to_mask_under,
eq_u32_via_to_mask, eq_u64_to_mask, eq_u8_to_mask, ge_i32_to_mask, ge_i32_to_mask_under, ge_u64_to_mask,
Expand Down Expand Up @@ -1788,6 +1789,20 @@ fn check_gather_scatter_group() -> Result<(), u32> {
return Err(0xD40);
}

// ── eq_u32_via_lane_to_mask ───────────────────────────────────────
// The lane-valued twin: each row's addressed entry must equal that
// row's own `local[i]`, not one constant.
let local: Vec<u32> = (0..n).map(|_| (rng.next() % 5) as u32).collect();
let mut via_lane_mask = vec![u64::MAX; out_len];
eq_u32_via_lane_to_mask(&index2, &table, &local, &mut via_lane_mask);
let want_via_lane_mask = reference_mask(n, out_len, |i| {
let k = index2[i] as usize;
k < table.len() && table[k] == local[i]
});
if via_lane_mask != want_via_lane_mask {
return Err(0xD41);
}

// ── masked_key_run_count_u32 ─────────────────────────────────────
// A key-ORDERED lane (sorted with repeats), folded in uneven tiles
// with the carry threaded through; the reference is a plain
Expand Down
1 change: 1 addition & 0 deletions src/simd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,7 @@ pub use crate::simd_masking_ops::{
eq_u32_strided_to_mask,
eq_u32_to_mask,
eq_u32_to_mask_under,
eq_u32_via_lane_to_mask,
eq_u32_via_to_mask,
eq_u64_to_mask,
eq_u8_to_mask,
Expand Down
165 changes: 160 additions & 5 deletions src/simd_masking_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3113,11 +3113,14 @@ pub fn fold_bounded_cross_power_sums_tiles(
///
/// # Vectorisation, honestly
///
/// **Scalar, and by necessity, not oversight** — same shape as
/// [`mask_gather_u32`]: the address read from `table` is `index[i]`, a
/// value out of another array, so there is no vector gather over
/// individual predicate results on any of this crate's backends. Cost is
/// `O(index.len())`; there is no input mask to skip zero words against.
/// **Scalar** — same shape as [`mask_gather_u32`]: the address read from
/// `table` is `index[i]`, a value out of another array. Several backends
/// carry an `unsafe`, unbounded `f32` gather (`F32x16::gather`), but there is
/// no bounds-masked `u32` gather-and-compare wrapper, and the zero-fallback
/// contract above needs one: an out-of-range index must read as no match,
/// not as memory. Whether such a wrapper pays on random access is
/// unmeasured. Cost is `O(index.len())`; there is no input mask to skip zero
/// words against.
///
/// # Panics
///
Expand Down Expand Up @@ -3165,6 +3168,75 @@ pub fn eq_u32_via_to_mask(index: &[u32], table: &[u32], v: u32, out_words: &mut
}
}

/// Packs `index[i] < table.len() && table[index[i]] == local[i]` into
/// `out_words`, one bit per row `i < index.len()`, LSB-first: the lane-valued
/// twin of [`eq_u32_via_to_mask`]. The value a row's addressed entry must
/// equal is that row's own `local[i]`, not one constant for every row.
///
/// This closes a cyclic join in one pass: with `index = rb`, `table = s_dst`
/// and `local = tc`, row `a` is set exactly when `s_dst[rb[a]] == tc[a]`,
/// which no single-constant predicate can express without one disjunct per
/// value.
///
/// Out-of-range rule, output contract and tail handling are those of
/// [`eq_u32_via_to_mask`]: `index[i] >= table.len()` never matches; every
/// output word is overwritten; bits past `index.len()` and surplus words are
/// written `0`.
///
/// # Vectorisation, honestly
///
/// Scalar, for the reason [`eq_u32_via_to_mask`] gives: the address comes out
/// of another array, and there is no bounds-masked `u32` gather-and-compare
/// wrapper on any backend. The scalar body is the realization on every target.
///
/// # Panics
///
/// Panics if `local.len() != index.len()`, or if
/// `out_words.len() < mask_words_for(index.len())`.
///
/// # Examples
///
/// ```
/// use ndarray::simd::eq_u32_via_lane_to_mask;
///
/// let index = [0u32, 1, 5, 1];
/// let table = [7u32, 3];
/// let local = [7u32, 4, 7, 3];
/// let mut out = [u64::MAX];
/// eq_u32_via_lane_to_mask(&index, &table, &local, &mut out);
/// // row 0: table[0] = 7 = local; row 1: 3 != 4; row 2: out of range;
/// // row 3: table[1] = 3 = local.
/// assert_eq!(out[0], 0b1001);
/// ```
#[inline]
pub fn eq_u32_via_lane_to_mask(index: &[u32], table: &[u32], local: &[u32], out_words: &mut [u64]) {
let n = index.len();
assert_eq!(local.len(), n, "eq_u32_via_lane_to_mask: local.len()={} != index.len()={}", local.len(), n);
let words = mask_words_for(n);
assert!(
out_words.len() >= words,
"eq_u32_via_lane_to_mask: out_words.len()={} < required {}",
out_words.len(),
words
);

for (w, out_word) in out_words.iter_mut().enumerate().take(words) {
let base = w * 64;
let live = (n - base).min(64);
let mut acc = 0u64;
for lane in 0..live {
let addr = index[base + lane] as usize;
if addr < table.len() && table[addr] == local[base + lane] {
acc |= 1u64 << lane;
}
}
*out_word = acc;
}
for w in out_words.iter_mut().skip(words) {
*w = 0;
}
}

/// Carry of [`masked_key_run_count_u32`] across calls: the key of the run
/// that is open at the end of the last call, and whether that run has
/// already seen a selected element. Two scalar fields, O(1) and
Expand Down Expand Up @@ -7042,6 +7114,89 @@ mod tests {
eq_u32_via_to_mask(&idx, &table, 0, &mut out);
}

// ── eq_u32_via_lane_to_mask ──

#[test]
fn eq_u32_via_lane_to_mask_matches_naive_reference_across_the_tail() {
for &n in &[0usize, 1, 63, 64, 65, 130, 1000] {
let mut seed = 0x5EED_0D41_u64 ^ n as u64;
let table_len = 13usize;
let table: Vec<u32> = (0..table_len)
.map(|_| (splitmix(&mut seed) % 4) as u32)
.collect();
let idx: Vec<u32> = (0..n)
.map(|i| {
if i % 5 == 0 {
(table_len + 2 + i) as u32
} else {
(splitmix(&mut seed) % table_len as u64) as u32
}
})
.collect();
let local: Vec<u32> = (0..n).map(|_| (splitmix(&mut seed) % 4) as u32).collect();
let want_bits: Vec<bool> = (0..n)
.map(|i| {
let k = idx[i] as usize;
k < table.len() && table[k] == local[i]
})
.collect();
let want = bits_to_words(&want_bits);
let out_words = n.div_ceil(64).max(1);
let mut out = vec![u64::MAX; out_words + 1];
eq_u32_via_lane_to_mask(&idx, &table, &local, &mut out);
assert_eq!(&out[..want.len()], &want[..], "mismatch at n={n}");
assert_eq!(out[out_words], 0, "surplus word must be cleared at n={n}");
if n >= 10 {
assert!(want_bits.iter().any(|&b| b), "fixture at n={n} has no match");
assert!(want_bits.iter().any(|&b| !b), "fixture at n={n} has no non-match");
}
}
}

/// The compare reads each row's OWN value. A kernel that compared every
/// row against one constant would agree on rows whose local value
/// happens to be that constant, so the fixture gives every row a
/// different local value and every addressed entry the matching one.
#[test]
fn eq_u32_via_lane_to_mask_compares_each_row_against_its_own_lane() {
let table: Vec<u32> = (0..64).map(|k| 100 + k).collect();
let idx: Vec<u32> = (0..64).collect();
let local: Vec<u32> = (0..64).map(|i| 100 + i).collect();
let mut out = [0u64; 1];
eq_u32_via_lane_to_mask(&idx, &table, &local, &mut out);
assert_eq!(out[0], u64::MAX, "every row equals its own addressed entry");
// Shift the local lane by one: no row matches any more.
let shifted: Vec<u32> = local.iter().map(|v| v + 1).collect();
eq_u32_via_lane_to_mask(&idx, &table, &shifted, &mut out);
assert_eq!(out[0], 0, "no row equals its addressed entry after the shift");
}

#[test]
fn eq_u32_via_lane_to_mask_out_of_range_never_matches() {
let idx = [1u32, 9, u32::MAX];
let table = [0u32];
let local = [0u32, 0, 0];
let mut out = [u64::MAX];
eq_u32_via_lane_to_mask(&idx, &table, &local, &mut out);
assert_eq!(out[0], 0, "no index names a row of `table`");
}

#[test]
#[should_panic(expected = "local.len()")]
fn eq_u32_via_lane_to_mask_rejects_a_short_local_lane() {
let mut out = [0u64; 1];
eq_u32_via_lane_to_mask(&[0, 0], &[0], &[0], &mut out);
}

#[test]
#[should_panic(expected = "out_words.len()")]
fn eq_u32_via_lane_to_mask_rejects_short_out_buffer() {
let idx = vec![0u32; 65];
let local = vec![0u32; 65];
let mut out = [0u64; 1];
eq_u32_via_lane_to_mask(&idx, &[0], &local, &mut out);
}

// ── 2026-09-13 additions: the closed comparison family, complement/xor/
// any/all, care-masked register match, masked min/max, blend ──

Expand Down
Loading