From 03852696c07ebce078af06b1b2df1093cb570da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 19:53:29 +0000 Subject: [PATCH 1/5] =?UTF-8?q?lgj-abi:=20R1=20=E2=80=94=20the=20hop's=20s?= =?UTF-8?q?election=20is=20mask=20algebra=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling: there is no gathering. Walking src's set bits is a serialization of a population that is already there, whether or not it allocates — avoiding the Vec while doing it with a loop counter is the same act in the time dimension. lgj_hop now selects with src AND class_f AND struct_f, word-parallel. No row is examined to decide whether it participates; the walk only EMITS from the result, and only because the destination index is decoded from the selected row's payload (the operand of a permutation, not a decision about membership). F2, which no PR in this arc had caught: the structured-edge gate (payload_hi32 != 0) was an `if` inside the row walk in EVERY version, including PR #22's clean one. It is a per-row equality against zero — the same strided primitive as the classid match, twelve bytes further into the facet. simd_rowstore_u32_eq_mask takes an arbitrary first_offset, so both predicates are one call each and the gate cost one call site, not a kernel. facet_bits / facet_cache / FACET_CACHE_SLOTS deleted. Under the format-string reading of the 4+12 facet the memo cached the interpolated string; the projection is applied at read, never stored. Byte-identical: 134/134 Rust including the pinned 10/19/29 regression, and 447/447 Java unchanged (304 core + 143 consumer). AND IT IS A 19x REGRESSION AS IT STANDS, which is the finding rather than a side note. 65 536 rows: sweep 2 126 us -> 40 632 us, flat in density. 32 facets x 2 predicates is 64 whole-population passes at stride 512, ~2 GB of traffic to read 512 KB. R11 priced this layout at 9.2x before the arc began; PR #40 diagnosed it as an algebra defect and banked the opposite as a law. R2 measured as a lab arm (R11 precedent, no ABI change): the canvas is the (row x facet) plane, not the row. Same bytes field-major -> one contiguous pass per predicate, a PERIODIC participation operand (64 slots per word = 2 rows x 32 facets), src expanded by splat. 902-2 271 us: ~40x the AoS mask shape, 2.3-5.7x the sweep, beating the gather outright at full density, with cost tracking the canvas rather than the frontier. Equivalence asserted at every configuration; raw output banked on the board. The columnar STORE is not landed. Until it is, the hop is lawful and slow, and that trade is deliberate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCfrD5y19cvFc4AoyydXYv --- .claude/board/ISSUES.md | 33 ++++ .claude/board/LATEST_STATE.md | 55 ++++++ .../board/hop-mask-algebra-vs-columnar.txt | 22 +++ .../{hop_gather_vs_sweep.rs => hop_shapes.rs} | 182 ++++++++++++++---- native/lgj-abi/src/exports.rs | 120 +++++++----- native/lgj-abi/src/kernels.rs | 40 +++- native/lgj-abi/src/rowstore.rs | 71 +------ 7 files changed, 365 insertions(+), 158 deletions(-) create mode 100644 .claude/board/hop-mask-algebra-vs-columnar.txt rename native/lgj-abi/examples/{hop_gather_vs_sweep.rs => hop_shapes.rs} (58%) diff --git a/.claude/board/ISSUES.md b/.claude/board/ISSUES.md index 3cb91c1..79ac7c8 100644 --- a/.claude/board/ISSUES.md +++ b/.claude/board/ISSUES.md @@ -1,5 +1,38 @@ # Issues Log — Open + Resolved (double-entry, append-only) +## ISS-LGJ-HOP-LAYOUT-BLOCKS-THE-ALGEBRA (2026-08-27) — OPEN + +**Found.** By landing R1 (selection as mask algebra) and measuring it. + +**Measured.** 65 536 rows, all densities: the lawful shape costs +**~40 600 µs** where the shipped one-pass sweep costs ~2 100 µs — a **19× +regression** — because 32 facets × 2 predicates is 64 whole-population passes +at **stride 512**, ~2 GB of memory traffic to read 512 KB of classids. Scaling +is worse than linear (1 024 rows → 144 µs; 65 536 rows → 40 632 µs, 282× for +64× the rows), the signature of cache and TLB failing together. + +**Root cause is the layout, not the algebra** — and it was priced before this +arc started. R11 (#31) measured AoS 512-stride at 12–13 ns/row against an SoA +facet lane at ~1.3 ns/row (**9.2×**) and found the layout already *data* at +every boundary except the store's constructor, with the kernels already +stride-parameterized. PR #40 then banked the opposite as a law — *"a scalar +gather beats a vectorised sweep; the win is in not doing the work"* — a +measurement taken inside the defect and generalised as a property of the +operation. That claim is superseded; see the storno on #40's arc entry. + +**The fix is measured, not proposed.** A columnar `(row × facet)` plane — +same bytes, field-major — runs the identical algebra at **902–2 271 µs**, +~40× the AoS mask shape and 2.3–5.7× the sweep it replaces, with cost tracking +the canvas rather than the frontier (2.5× across a 10 000× density range). +Banked: `.claude/board/hop-mask-algebra-vs-columnar.txt`. + +**Open because the STORE is still AoS.** The probe builds the plane from the +AoS buffer; a columnar store builds it at generation, which is the ABI-side +change (an additive constructor plus lane descriptors, per R11) and is not +landed here. Until it is, `lgj_hop` on `main` is lawful and slow, and that +trade is deliberate: the currency is correct and the physical layer is the +named blocker, rather than the currency being spent to hide a layout defect. + ## ISS-LGJ-ARC-INVENTORY-STOPPED-AT-32 (2026-08-27) — RESOLVED **Found.** While landing PR #42's own arc entry, per the board README's diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 91ab52b..2df9b7c 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,58 @@ +## 2026-08-27 — R1: the hop's selection is mask algebra again, and the layout is now the measured blocker + +Operator ruling: *"there's no gathering — gathering is a serialization of what +is already there to begin with."* Correct, and the audit that followed found +the walk was not the only place the algebra had leaked. + +- **R1 shipped, byte-identical.** `lgj_hop` selects with + `src ∧ class_f ∧ struct_f`, word-parallel. **134/134** including the pinned + 10/19/29 regression — which is the proof the answer did not move — and + 447/447 Java unchanged. +- **F2, which no PR in the arc had caught:** `payload_hi32 != 0` was an `if` + inside the row walk in EVERY version, PR #22's clean one included. It is a + per-row equality against zero, i.e. the same strided primitive as the classid + match, twelve bytes further into the facet. Closed for **one call site and + zero new kernels** — `simd_rowstore_u32_eq_mask` takes an arbitrary offset, + so `first_offset = f*16 + 0` is the class and `f*16 + 12` is the gate. +- **`facet_bits` / `facet_cache` / `FACET_CACHE_SLOTS` deleted.** Under the + operator's format-string reading of the 4+12 facet (`classid -F payload`, + PowerShell `"{0} {1}" -F $1,$2`) the memo was caching the interpolated + string. The projection is applied at read, never stored. +- **R1 alone is a 19× REGRESSION, and that is the finding.** 65 536 rows: + one-pass sweep 2 126 µs → mask algebra **40 632 µs**, flat in density. 32 + facets × 2 predicates = **64 full passes at stride 512** ≈ 2 GB of traffic to + read 512 KB. At 1 024 rows it is 144 µs; 64× the rows costs 282× the time — + cache and TLB collapsing together. The algebra is right; the LAYOUT is the + defect, exactly as R11 (#31) priced it at 9.2× before this arc began. +- **R2 measured as a lab arm (R11 precedent, zero ABI change).** The canvas is + the **(row × facet) plane**, not the row: same 512 bytes reordered + field-major, so `class` and `struct` are ONE contiguous pass each with no + stride, participation is a PERIODIC operand (64 slots per word = exactly 2 + rows × 32 facets, so it is one repeated `u64`, not a buffer), and `src` + expands 1 row-bit → 32 slot-bits by splat. + +| 65 536 rows | 0.01 % | 1 % | 25 % | 100 % | +|---|---|---|---|---| +| sweep (shipped pre-R1) | 5 147 | 3 034 | 2 969 | 5 252 | +| gather (#40, the serialization) | 1.0 | 27.7 | 1 659 | 3 880 | +| mask algebra, AoS (R1) | 41 600 | 49 667 | 48 604 | 51 329 | +| **columnar plane (R2 probe)** | **902** | **1 066** | **1 367** | **2 271** | + + Columnar is **~40×** the AoS mask shape, **2.3–5.7×** the one-pass sweep, and + beats the gather outright at 100 %. Its cost tracks the CANVAS, not the + frontier — 2.5× across a 10 000× density range — which is the signature the + mask-native invariant asks for. Equivalence asserted at all 12 configurations + per population: all four shapes byte-identical. Raw output banked at + `.claude/board/hop-mask-algebra-vs-columnar.txt`. +- **Honest boundary:** at a sparse frontier the gather is still faster in + absolute terms, because any whole-plane operation is O(population) and a walk + is O(frontier). That is not a defect to fix — it is the trade the doctrine + makes deliberately, and it is why the columnar number (flat in density) + matters more than the sparse-density comparison. +- **Not done:** the columnar store itself. The probe builds the plane from the + AoS store; a columnar STORE builds it at generation. That is the ABI-side + change and it is measured-but-unlanded. + ## 2026-08-27 — the REAL ClassView provider is bound, and it measures the fixture's reach The `ClassView` provider seam (§4-NG3, "a real ontology/cache provider is a diff --git a/.claude/board/hop-mask-algebra-vs-columnar.txt b/.claude/board/hop-mask-algebra-vs-columnar.txt new file mode 100644 index 0000000..fc89142 --- /dev/null +++ b/.claude/board/hop-mask-algebra-vs-columnar.txt @@ -0,0 +1,22 @@ +hop shapes — sweep vs gather vs mask-algebra (AoS) vs columnar (row x facet plane) +release, x86-64-v4, shared 4-vCPU container, median of 7 reps +edge_classid=0 gate=0x0 radius=25, effective=all 32 facets +equivalence asserted at EVERY configuration: all four shapes byte-identical + +== n_rows = 65536 == + frontier density sweep_us gather_us mask_us colmn_us + 6 0.01% 5146.9 1.0 41599.5 901.5 + 32 0.05% 2504.7 1.8 43757.2 1017.0 + 65 0.10% 2655.8 2.3 41782.9 1143.9 + 163 0.25% 2739.8 5.3 51184.1 1200.6 + 327 0.50% 2245.1 14.3 49686.0 1127.7 + 655 1.00% 3034.4 27.7 49667.3 1066.3 + 1310 2.00% 2766.2 62.2 49274.8 1088.2 + 3276 5.00% 2710.6 380.9 50909.4 1178.9 + 6553 10.00% 3659.7 759.9 49367.0 1229.6 + 16384 25.00% 2969.2 1659.3 48603.8 1366.7 + 32768 50.00% 3349.5 2365.8 49887.2 1626.8 + 65536 100.00% 5251.8 3879.5 51328.5 2270.9 + + +[exited with code 0] diff --git a/native/lgj-abi/examples/hop_gather_vs_sweep.rs b/native/lgj-abi/examples/hop_shapes.rs similarity index 58% rename from native/lgj-abi/examples/hop_gather_vs_sweep.rs rename to native/lgj-abi/examples/hop_shapes.rs index 1b4ec39..0c51cd3 100644 --- a/native/lgj-abi/examples/hop_gather_vs_sweep.rs +++ b/native/lgj-abi/examples/hop_shapes.rs @@ -116,32 +116,150 @@ fn hop_gather(store: &RowStore, src: &[u64], effective: u32, n_words: usize) -> out } -/// MEMOISED — the shipped shape. `facet_bits` is one 32-bit MASK per row, so -/// the participation test is an AND. Passing the mask in lets the caller time -/// the WARM case; `RowStore::facet_bits` builds and caches it on first ask. -fn hop_memoised( +/// MASK ALGEBRA — the shipped shape (R1). Selection is +/// `src ∧ class_f ∧ struct_f`, word-parallel; both predicates are the SAME +/// strided-equality primitive at two offsets into the facet. Nothing decides +/// per row whether a row takes part — the walk only EMITS from the result. +fn hop_mask_algebra(store: &RowStore, src: &[u64], effective: u32, n_words: usize) -> Vec { + let bytes = store.as_bytes(); + let n = store.n_rows as usize; + let mut out = vec![0u64; n_words]; + let mut selected = vec![0u64; n_words]; + let mut structured = vec![0u64; n_words]; + + for facet in 0..lgj_abi::rowstore::ROW_FACETS { + if (effective >> facet) & 1 == 0 { + continue; + } + let off = facet as usize * lgj_abi::rowstore::FACET_BYTES as usize; + kernels::simd_rowstore_u32_eq_mask(bytes, off, n, EDGE_CLASSID, &mut selected); + kernels::simd_mask_and_assign(&mut selected, src); + kernels::simd_rowstore_u32_eq_mask( + bytes, + off + lgj_abi::rowstore::FACET_PAYLOAD_HI32_OFFSET as usize, + n, + 0, + &mut structured, + ); + kernels::simd_mask_and_assign(&mut selected, &structured); + + for (w, &sw) in selected.iter().enumerate() { + let mut bits = sw; + while bits != 0 { + let bit = bits.trailing_zeros(); + bits &= bits - 1; + let row = (w as u64) * 64 + u64::from(bit); + if row >= store.n_rows { + continue; + } + decode_into(bytes, store.n_rows, row, facet, &mut out); + } + } + } + out +} + +/// COLUMNAR — R2 as a lab measurement (R11's precedent: measure the layout +/// before changing the store). +/// +/// Same bytes, different ORDER. A row's 512 bytes are three fields × 32 +/// facets; laying them out field-major makes the whole `(row × facet)` plane +/// one contiguous canvas per field. Then every predicate is ONE pass over a +/// contiguous `u32` column — no stride at all — instead of 32 strided passes +/// per predicate. +/// +/// Built once here because a columnar STORE would build it at generation. +/// The build is timed separately; it is not per-hop work. +struct Columnar { + classid: Vec, // [row * 32 + facet] + hi32: Vec, + lo64: Vec, +} + +impl Columnar { + fn of(store: &RowStore) -> Self { + let n = store.n_rows as usize; + let f = ROW_FACETS as usize; + let bytes = store.as_bytes(); + let mut classid = vec![0u32; n * f]; + let mut hi32 = vec![0u32; n * f]; + let mut lo64 = vec![0u64; n * f]; + for row in 0..n { + for facet in 0..f { + let b = row * ROW_BYTES as usize + facet * FACET_BYTES as usize; + let i = row * f + facet; + classid[i] = u32::from_le_bytes(bytes[b..b + 4].try_into().unwrap()); + lo64[i] = u64::from_le_bytes(bytes[b + 4..b + 12].try_into().unwrap()); + hi32[i] = u32::from_le_bytes(bytes[b + 12..b + 16].try_into().unwrap()); + } + } + Self { + classid, + hi32, + lo64, + } + } +} + +/// The hop over the `(row × facet)` bit-plane. Four mask operands, three ANDs, +/// one walk of the RESULT — and not one of the operands is built by looking at +/// which rows were selected. +fn hop_columnar( + col: &Columnar, store: &RowStore, src: &[u64], effective: u32, n_words: usize, - facet_bits: &[u32], ) -> Vec { - let bytes = store.as_bytes(); + let n_rows = store.n_rows; + let slots = col.classid.len(); // n_rows * 32 + let slot_words = slots.div_ceil(64); + + let mut selected = vec![0u64; slot_words]; + let mut structured = vec![0u64; slot_words]; + + // class — ONE contiguous pass over the whole plane. + kernels::simd_eq_u32_to_mask(&col.classid, EDGE_CLASSID, &mut selected); + // struct — ONE more. + kernels::simd_eq_u32_to_mask(&col.hi32, 0, &mut structured); + kernels::simd_mask_and_assign(&mut selected, &structured); + + // participation — PERIODIC, so the operand is one repeated word rather + // than a buffer: 64 slots per word = exactly 2 rows at 32 facets. + let part_word = (effective as u64) | ((effective as u64) << 32); + // src, expanded 1 row-bit -> 32 slot-bits. Word w covers rows 2w, 2w+1. + for (w, sel) in selected.iter_mut().enumerate() { + let r0 = 2 * w as u64; + let r1 = r0 + 1; + let lo = if r0 < n_rows && (src[(r0 / 64) as usize] >> (r0 % 64)) & 1 == 1 { + 0xFFFF_FFFFu64 + } else { + 0 + }; + let hi = if r1 < n_rows && (src[(r1 / 64) as usize] >> (r1 % 64)) & 1 == 1 { + 0xFFFF_FFFF_0000_0000u64 + } else { + 0 + }; + *sel &= part_word & (lo | hi); + } + + // Emit. Every set bit is a (row, facet) edge slot that already satisfied + // every predicate; the walk decides nothing. let mut out = vec![0u64; n_words]; - for (w, &sw) in src.iter().enumerate() { + for (w, &sw) in selected.iter().enumerate() { let mut bits = sw; while bits != 0 { let bit = bits.trailing_zeros(); bits &= bits - 1; - let row = (w as u64) * 64 + u64::from(bit); - if row >= store.n_rows { + let slot = w * 64 + bit as usize; + if slot >= slots { continue; } - let mut fb = facet_bits[row as usize] & effective; - while fb != 0 { - let facet = fb.trailing_zeros(); - fb &= fb - 1; - decode_into(bytes, store.n_rows, row, facet, &mut out); + let target = col.lo64[slot]; + if target < n_rows { + let t = target as usize; + out[t / 64] |= 1u64 << (t % 64); } } } @@ -183,7 +301,7 @@ fn time_us(mut f: impl FnMut()) -> f64 { fn main() { let effective: u32 = 0xFFFF_FFFF; // all 32 facets participate println!( - "gather-vs-sweep crossover — spread frontier, edge_classid={EDGE_CLASSID}, \ + "hop shapes — spread frontier, edge_classid={EDGE_CLASSID}, \ gate=0x{GATE_MASK:x}, radius={RADIUS}, reps={REPS} (median)\n" ); @@ -191,11 +309,12 @@ fn main() { let store = RowStore::generate_with_edges(n_rows, SEED, EDGE_CLASSID, GATE_MASK, RADIUS) .expect("fixture generation"); let n_words = (n_rows as usize).div_ceil(64); + let col = Columnar::of(&store); println!("== n_rows = {n_rows} =="); println!( "{:>10} {:>9} {:>12} {:>12} {:>12} {:>12}", - "frontier", "density", "sweep_us", "gather_us", "memo_cold", "memo_warm" + "frontier", "density", "sweep_us", "gather_us", "mask_us", "colmn_us" ); for &pct in &[ @@ -222,32 +341,27 @@ fn main() { std::hint::black_box(hop_gather(&store, &src, effective, n_words)); }); - let fb = store.facet_bits(EDGE_CLASSID); - let memo_warm = time_us(|| { - std::hint::black_box(hop_memoised(&store, &src, effective, n_words, &fb)); + let mask = time_us(|| { + std::hint::black_box(hop_mask_algebra(&store, &src, effective, n_words)); }); - // COLD: the O(n) mask build plus one hop -- what the FIRST hop on - // a fresh (store, classid) actually pays. Timed by building the - // mask from scratch each rep rather than reading the cache. - let memo_cold = time_us(|| { - let mut built = vec![0u32; store.n_rows as usize]; - lgj_abi::kernels::simd_rowstore_facet_match( - &store.bytes_arc(), - store.n_rows as usize, - EDGE_CLASSID, - &mut built, - ); - std::hint::black_box(hop_memoised(&store, &src, effective, n_words, &built)); + assert_eq!( + hop_mask_algebra(&store, &src, effective, n_words), + a, + "mask algebra disagrees at n_rows={n_rows} pct={pct}" + ); + + let colmn = time_us(|| { + std::hint::black_box(hop_columnar(&col, &store, &src, effective, n_words)); }); assert_eq!( - hop_memoised(&store, &src, effective, n_words, &fb), + hop_columnar(&col, &store, &src, effective, n_words), a, - "memoised disagrees at n_rows={n_rows} pct={pct}" + "columnar disagrees at n_rows={n_rows} pct={pct}" ); println!( "{:>10} {:>8.2}% {:>12.1} {:>12.1} {:>12.1} {:>12.1}", - count, pct, sweep, gather, memo_cold, memo_warm + count, pct, sweep, gather, mask, colmn ); } println!(); diff --git a/native/lgj-abi/src/exports.rs b/native/lgj-abi/src/exports.rs index 588fdd2..8eb1d9e 100644 --- a/native/lgj-abi/src/exports.rs +++ b/native/lgj-abi/src/exports.rs @@ -1580,14 +1580,10 @@ pub extern "C" fn lgj_hop( None => return LGJ_ERR_WRONG_RESOURCE_KIND, }; let n_rows = store_entry.n_rows; - // The gather never materialises an `n`-element buffer, so `n` itself - // is no longer needed — but the OVERFLOW GUARD still is: row indices - // are cast to `usize` below, and a store whose row count does not fit - // must be refused here rather than wrapping at the cast. Kept as an - // explicit check rather than an `_n` binding so the intent survives. - if usize::try_from(n_rows).is_err() { - return LGJ_ERR_LENGTH_OVERFLOW; - } + let n = match usize::try_from(n_rows) { + Ok(n) => n, + Err(_) => return LGJ_ERR_LENGTH_OVERFLOW, + }; let n_words = mask_words_for(n_rows) as usize; // Effective participation (spec §3.1/§3.4): the caller's facet_mask @@ -1613,57 +1609,77 @@ pub extern "C" fn lgj_hop( let mut out = vec![0u64; n_words]; let bytes = rowstore.as_bytes(); - // AND OVER MASKS, on a memoised per-facet mask. + // SELECTION IS MASK ALGEBRA. // - // `facet_bits(classid)[row]` is a 32-bit MASK of which facets of that - // row carry the edge class, so the participation test is one AND -- - // `facet_bits[row] & effective` -- and not 32 byte compares. Built - // once per (store, classid) through the sanctioned `ndarray::simd` - // kernel and shared by refcount thereafter; the store is immutable, so - // it never needs invalidation. + // selected_f = src ∧ class_f ∧ struct_f + // dst = ⋁_{f ∈ participation} scatter(selected_f) // - // This is the shape the substrate's own currency asks for, and it took - // three tries to get here honestly: + // Both predicates are the SAME strided-equality primitive + // (`simd_rowstore_u32_eq_mask`) at two offsets into the facet — the + // classid at +0, the structured-edge gate at +12 — and the two ANDs + // are word-parallel over 64 rows at a time. No row is examined to + // decide whether it participates; participation is computed for the + // whole population and intersected. // - // 1. 32 full-width classid sweeps per hop -- mask-shaped, but the - // AND was computed over the whole population EVERY hop. 24.8 ms. - // 2. gather: skip the mask entirely, read each src row's facets - // inline. 34 us -- 720x faster, and measured to beat a sweep at - // every density (no crossover exists), but it traded the algebra - // away: the classid predicate stopped being a mask at all. - // 3. this: keep the mask, pay for it ONCE. The AND is what the - // earlier sweeps were doing; memoising is what makes it cheap. + // Three shapes preceded this one and each traded the algebra for + // arithmetic: // - // Only the scatter stays outside mask algebra, and irreducibly so: the - // destination index is DECODED from the row's payload, so it is a - // data-dependent scatter, not a set operation. - let facet_bits = rowstore.facet_bits(edge_classid); - let effective_facets = effective as u32; - for (w, &sw) in src_snapshot.iter().enumerate() { - let mut bits = sw; - while bits != 0 { - let bit = bits.trailing_zeros(); - bits &= bits - 1; - let row = (w as u64) * ROWS_PER_WORD + bit as u64; - // Defensive: a conformant mask's tail is always zero, so this - // is unreachable for a well-formed src_mask -- but guards - // against a deliberately corrupted snapshot rather than - // letting the byte-offset math below run past the buffer. - if row >= n_rows { - continue; - } - let mut fb = facet_bits[row as usize] & effective_facets; - while fb != 0 { - let facet = fb.trailing_zeros(); - fb &= fb - 1; + // 1. PR #22 — mask-shaped selection (`src_word & classid_word`), + // but the structured-edge gate was an `if` inside the walk and + // the classid sweep ran once PER FACET over a 512-strided store. + // 2. PR #40 — deleted the classid mask outright and compared per + // row inside a `trailing_zeros` walk of `src`. Faster, and the + // predicate stopped being a mask at all. + // 3. PR #41 — restored an AND, but SCALAR and per-row, over a + // memoised `facet_bits` buffer: the op survived as a symbol + // while the algebra did not, and the memo stored a projection + // of bytes already resident. + // + // What remains a walk is the SCATTER alone, and only because the + // destination row index is DECODED from the selected row's payload — + // it is the operand of a permutation, not a decision about which rows + // take part. Making that a semiring product (`dst = src ⊗ A`) is the + // next rung and needs an adjacency operand this ABI does not carry. + let mut selected = vec![0u64; n_words]; + let mut structured = vec![0u64; n_words]; + + for facet in 0..crate::rowstore::ROW_FACETS { + if (effective >> facet) & 1 == 0 { + continue; + } + let facet_off = facet as usize * crate::rowstore::FACET_BYTES as usize; + + // class_f — which rows carry this class in THIS facet. + kernels::simd_rowstore_u32_eq_mask(bytes, facet_off, n, edge_classid, &mut selected); + // ∧ src — narrow to the frontier. + kernels::simd_mask_and_assign(&mut selected, &src_snapshot); + // struct_f — payload_hi32 == 0 marks a structured edge. + kernels::simd_rowstore_u32_eq_mask( + bytes, + facet_off + crate::rowstore::FACET_PAYLOAD_HI32_OFFSET as usize, + n, + 0, + &mut structured, + ); + // ∧ — the gate that used to be an `if`. + kernels::simd_mask_and_assign(&mut selected, &structured); + + // Emit from the SELECTED set. Every row reached here has already + // satisfied all three predicates; the walk decides nothing. + for (w, &sw) in selected.iter().enumerate() { + let mut bits = sw; + while bits != 0 { + let bit = bits.trailing_zeros(); + bits &= bits - 1; + let row = (w as u64) * ROWS_PER_WORD + bit as u64; + // Defensive: a conformant mask's tail is always zero, so + // this is unreachable for a well-formed src_mask. + if row >= n_rows { + continue; + } let base = (row * crate::rowstore::ROW_BYTES + u64::from(facet) * crate::rowstore::FACET_BYTES) as usize; - let payload_hi32 = - u32::from_le_bytes(bytes[base + 12..base + 16].try_into().unwrap()); - if payload_hi32 != 0 { - continue; // not a structured edge (gate failed at generation) - } // Bounds check on u64, BEFORE any `as usize` cast // (council S3-6, normative ordering). let target = u64::from_le_bytes(bytes[base + 4..base + 12].try_into().unwrap()); diff --git a/native/lgj-abi/src/kernels.rs b/native/lgj-abi/src/kernels.rs index 00ca360..682de37 100644 --- a/native/lgj-abi/src/kernels.rs +++ b/native/lgj-abi/src/kernels.rs @@ -118,15 +118,28 @@ pub fn simd_popcount(words: &[u64]) -> u64 { ndarray::simd::popcount_batch_u64(words) } -/// Row mask over one facet-classid lane of a 512-byte row store: -/// `out_words[row-th bit] = (classid of facet at first_offset in row == needle)`. +/// THE strided per-row equality primitive over the row store: +/// `out_words[row-th bit] = (LE u32 at first_offset + row * 512 == needle)`. +/// +/// `first_offset` is an arbitrary byte offset into the row, so this ONE +/// function answers every per-row `u32` predicate the store has. Both of the +/// hop's predicates are calls to it, twelve bytes apart: +/// +/// | predicate | `first_offset` | `needle` | +/// |---|---|---| +/// | facet `f` carries class `E` | `f * 16 + 0` | `E` | +/// | facet `f` is a structured edge | `f * 16 + 12` | `0` | +/// +/// That the structured-edge gate needs no new kernel is the point — it was an +/// `if` inside a row walk in every version of `lgj_hop` up to and including +/// the first mask-shaped one (PR #22), and it was always this call. /// /// Routes through `ndarray::simd::eq_u32_strided_to_mask` — the strided -/// AoS-facet scan (LE `u32` at `first_offset + row * 512`). The primitive -/// owns bounds checking (overflow-checked, panics rather than reading out of -/// bounds) and the trailing-bits-zero guarantee. +/// AoS-facet scan. The primitive owns bounds checking (overflow-checked, +/// panics rather than reading out of bounds) and the trailing-bits-zero +/// guarantee. #[inline] -pub fn simd_rowstore_classid_mask( +pub fn simd_rowstore_u32_eq_mask( bytes: &[u8], first_offset: usize, n_rows: usize, @@ -143,6 +156,21 @@ pub fn simd_rowstore_classid_mask( ); } +/// The classid reading of [`simd_rowstore_u32_eq_mask`] — `first_offset` is a +/// facet's base, so the `u32` compared is that facet's leading classid. +/// Kept as a named wrapper because `lgj_op_eq_classid` means *classid* +/// specifically, and a call site that says so is worth one line of delegation. +#[inline] +pub fn simd_rowstore_classid_mask( + bytes: &[u8], + first_offset: usize, + n_rows: usize, + needle: u32, + out_words: &mut [u64], +) { + simd_rowstore_u32_eq_mask(bytes, first_offset, n_rows, needle, out_words); +} + /// Per-row facet-match: `out[row]` gets bit `f` set iff facet `f`'s classid /// in that row equals `needle` — "which facets of this node carry class X", /// one `u32` answer per row, written into the caller's buffer. diff --git a/native/lgj-abi/src/rowstore.rs b/native/lgj-abi/src/rowstore.rs index 8a01df8..64514f6 100644 --- a/native/lgj-abi/src/rowstore.rs +++ b/native/lgj-abi/src/rowstore.rs @@ -46,6 +46,11 @@ pub const ROW_FACETS: u32 = 32; pub const FACET_BYTES: u64 = 16; /// The classid is the facet's leading little-endian `u32`. pub const FACET_CLASSID_BYTES: u64 = 4; +/// Byte offset, within a facet, of the payload's high `u32`. The generator +/// zeroes it on a structured edge and fills it with noise otherwise, so +/// `== 0` at this offset IS the structured-edge predicate — one strided +/// equality, exactly like the classid match at offset 0. +pub const FACET_PAYLOAD_HI32_OFFSET: u64 = 12; /// Classid cardinality the generator produces: `0..16` (same recipe as the /// flat fixture, so predicates select the same middling fraction). pub const ROWSTORE_CLASS_CARDINALITY: u64 = 16; @@ -90,29 +95,8 @@ pub struct RowStore { /// The seed the buffer was generated from. pub seed: u64, bytes: Arc<[u8]>, - /// Memoised per-row facet-match masks, keyed by classid. - /// - /// `facet_bits(c)[row]` has bit `f` set iff facet `f` of that row carries - /// classid `c` — i.e. one 32-bit MASK per row, which is what makes a hop - /// an AND rather than a byte compare. - /// - /// **No invalidation, by construction.** `RowStore` exposes no `&mut self` - /// method: the buffer is built once in `generate`/`generate_with_edges` - /// and is immutable for the store's whole life. So a cached answer can - /// never go stale, and this needs none of the machinery a mutable-store - /// cache would. - facet_cache: std::sync::RwLock)>>, } -/// How many distinct classids keep a memoised mask per store. -/// -/// Each entry costs `n_rows * 4` bytes (1 MiB at 262 144 rows), so this is -/// bounded rather than unbounded-by-classid. Four covers the shapes measured -/// here — a traversal reuses one edge class far more often than it rotates -/// between many — and eviction is oldest-first rather than LRU because at this -/// size the bookkeeping would cost more than the miss it avoids. -const FACET_CACHE_SLOTS: usize = 4; - impl std::fmt::Debug for RowStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RowStore") @@ -152,7 +136,6 @@ impl RowStore { n_rows, seed, bytes: Arc::from(bytes), - facet_cache: std::sync::RwLock::new(Vec::new()), }) } @@ -248,7 +231,6 @@ impl RowStore { n_rows, seed, bytes: Arc::from(bytes), - facet_cache: std::sync::RwLock::new(Vec::new()), }) } @@ -258,49 +240,6 @@ impl RowStore { &self.bytes } - /// The memoised per-row facet-match mask for `classid`, built on first - /// ask and shared thereafter. - /// - /// This is the hop's mask half. `out[row]` is a 32-bit mask of which - /// facets of that row carry `classid`, so a hop is - /// `facet_bits[row] & participation` — an AND over masks — rather than 32 - /// byte compares per row. Building it is one `MultiLaneColumn` pass - /// through the sanctioned `ndarray::simd` kernel; the point of memoising - /// is that a traversal pays that O(n) pass ONCE and every subsequent hop - /// on the same `(store, classid)` is the AND alone. - /// - /// Returns `Arc<[u32]>` deliberately: the caller shares the buffer, never - /// copies it. A cache hit is a refcount bump. - /// - /// Concurrency: the read lock is dropped before any build, so two threads - /// racing on a cold classid may both build. That is a wasted pass, never a - /// wrong answer — the store is immutable, so both compute the identical - /// buffer — and it is preferred to holding a write lock across an O(n) - /// SIMD sweep. - pub fn facet_bits(&self, classid: u32) -> Arc<[u32]> { - if let Ok(g) = self.facet_cache.read() { - if let Some((_, bits)) = g.iter().find(|(c, _)| *c == classid) { - return Arc::clone(bits); - } - } - - let n = self.n_rows as usize; - let mut built = vec![0u32; n]; - crate::kernels::simd_rowstore_facet_match(&self.bytes, n, classid, &mut built); - let bits: Arc<[u32]> = Arc::from(built); - - if let Ok(mut g) = self.facet_cache.write() { - // Another thread may have won the race; keep one entry per classid. - if !g.iter().any(|(c, _)| *c == classid) { - if g.len() >= FACET_CACHE_SLOTS { - g.remove(0); - } - g.push((classid, Arc::clone(&bits))); - } - } - bits - } - /// A cheap shared handle to the same bytes — what the kernels wrap in a /// `MultiLaneColumn` without copying. pub fn bytes_arc(&self) -> Arc<[u8]> { From ba377b37a0f5f23aceb14441f7b6f39ff51bad38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 19:55:27 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs/abi.md=20=C2=A713:=20describe=20the=20?= =?UTF-8?q?composition=20R1=20actually=20ships?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Kernel composition" paragraph still described the single-predicate shape: one classid match per facet, with the structured-edge gate absent because it was an `if` in the walk rather than an operand. R1 makes selection `src AND class_f AND struct_f`, so §13 now names both predicates, the one primitive that produces both (same call, offsets +0 and +12 into the facet), and the reason the scatter alone stays scalar — the destination is decoded from the selected row's payload, which makes it the operand of a permutation rather than a decision about membership. Caught by re-reading the diff adversarially rather than by a gate. Leaving prose behind a changed function is exactly what #39 did and #40 had to fix one PR later; no reason to make the same reviewer spend that cycle twice. No ABI change: no symbol, no signature, no minor bump. Gates re-run unchanged — 134/134, clippy -D warnings, fmt. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCfrD5y19cvFc4AoyydXYv --- docs/abi.md | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/abi.md b/docs/abi.md index a412c38..9eec1c6 100644 --- a/docs/abi.md +++ b/docs/abi.md @@ -756,15 +756,36 @@ against `n_rows` as a `u64` BEFORE any `t as usize` cast — the ordering is part of the contract, not an implementation detail, so an out-of-range `u64` target can never reach an indexing operation. -**Kernel composition.** The classid-match sub-step for each participating -facet routes through the EXISTING sanctioned primitive -(`kernels::simd_rowstore_classid_mask`, `ndarray::simd::eq_u32_strided_to_mask` -— the same kernel `lgj_op_eq_classid` uses, §11) into a scratch word -buffer that is REUSED across every participating facet, never reallocated -per facet. Only the resulting set-bit walk + payload decode + scatter is -scalar: there is no `ndarray::simd` primitive for gather-decode-scatter, -and duplicating the classid compare in scalar Rust would be exactly the -polyfill bypass §8 forbids. +**Kernel composition.** Selection is mask algebra — +`src ∧ class_f ∧ struct_f` per participating facet, accumulated into `dst`: + +| operand | how it is produced | +|---|---| +| `class_f` | facet `f` carries the edge class | +| `struct_f` | facet `f`'s `payload_hi32 == 0`, i.e. a structured edge | +| `src` | the caller's frontier, snapshotted under a read lock | + +Both predicates are the SAME sanctioned primitive at two offsets into the +16-byte facet — `kernels::simd_rowstore_u32_eq_mask`, +`ndarray::simd::eq_u32_strided_to_mask` (the kernel `lgj_op_eq_classid` also +uses, §11) — with `first_offset = f*16 + 0, needle = classid` for the class +and `first_offset = f*16 + 12, needle = 0` for the gate. The ANDs are +`ndarray::simd::mask_and_assign`, word-parallel over 64 rows at a time. Two +scratch word buffers are REUSED across every participating facet, never +reallocated per facet. + +**No row is examined to decide whether it participates.** Only the +resulting set-bit walk + payload decode + scatter is scalar, and only +because the destination row index is DECODED from the selected row's +payload: that is the operand of a permutation, not a decision about +membership. There is no `ndarray::simd` primitive for decode-scatter, and +duplicating either predicate in scalar Rust would be exactly the polyfill +bypass §8 forbids. + +> The structured-edge gate was an `if` inside the row walk until 2026-08-27 +> — in every earlier shape of this function, including the first mask-shaped +> one. It was always this call; the kernel already took an arbitrary +> `first_offset`. ### Bulk-rule conformance (§6, applied) From c3ecf37d26a704791d2439d5928b818ccc667b8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:17:29 +0000 Subject: [PATCH 3/5] abi minor 9: the facet-match reduction moves to where the data is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling: Java hands decorative where() through Panama; Rust does mask ops, only — and Java doesn't even know mask count. FacetMatchView.cardinality() violated this in three successive shapes, each one layer up from the last: a Java popcount loop over a fetched segment (doc-commented "deliberately Java-side" to save a crossing); then 32 composed per-facet mask counts summed in Java — every operation native, but the decomposition still executing in Java, which is still Java holding a moving part; and the first proposed fix, "add a popcount symbol over the buffer", which asked how to reduce a buffer Java should never hold. Minor 9, one symbol: lgj_rowstore_facet_match_count. Sigma over facets of popcount(class_f), computed natively with the same strided-equality mask the classid ops use plus the sanctioned ndarray::simd popcount. One crossing in, one u64 back; Java neither iterates facets nor sums partials and does not learn that the answer has parts. cardinality() is a single delegation through a package-private RowStore bridge. Falsifier: the count against TWO independent oracles (the lgj_row_facet_match buffer popcount — the very reduction Java used to do — and a scalar recompute sharing no kernel), absent-needle zero, null-out rejection. Both gate directions proven against a REAL minor-8 library built from main in a worktree: AbiMismatchException naming minor 9, never a bare missing symbol, never a fallback Java loop. OldAbiCompatTest 8/8. The stale-.so iron rule fired on me during this change: the root-invoked release build was silently refused (root resolves the default toolchain, below the 1.97 floor; the error hidden by tail-piping), so earlier R1 Java runs loaded a pre-R1 .so — harmless only because R1 changes no observable behaviour, and surfaced precisely by the minor-9 requireMinor gate. Correct build: inside native/lgj-abi with CARGO_TARGET_DIR at the root target. Gates: Rust 135/135 (both feature configs), clippy -D warnings + fmt clean; Java 304 core + 143 consumer = 447 against the minor-9 .so (abi 0.9 reported at runtime); abi.md symbol count 25 + minor-9 history; board entry same commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCfrD5y19cvFc4AoyydXYv --- .claude/board/LATEST_STATE.md | 41 ++++++ docs/abi.md | 12 +- .../lancegraph/FacetMatchView.java | 25 ++-- .../com/adaworldapi/lancegraph/RowStore.java | 12 +- .../lancegraph/internal/ffm/Downcalls.java | 25 ++++ .../lancegraph/internal/ffm/Engine.java | 15 +++ .../lancegraph/OldAbiCompatTest.java | 14 ++ native/lgj-abi/src/abi.rs | 2 +- native/lgj-abi/src/exports.rs | 125 ++++++++++++++++++ 9 files changed, 257 insertions(+), 14 deletions(-) diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 2df9b7c..69d6e3d 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,44 @@ +## 2026-08-27 — ABI minor 9: the reduction moved to where the data is, and the placement rule is now ABI + +Operator ruling, verbatim intent: *"java hands decorative where() through +Panama; Rust is doing mask ops, ONLY"* — and, on the first fix attempt, +*"java doesn't even know mask count."* Both corrections were needed, because +the violation survived one layer up from where it was first repaired. + +- **The violation, three shapes of it.** `FacetMatchView.cardinality()` (1) + popcounted a fetched segment in a Java loop, doc-commented "deliberately + Java-side" to save a crossing; (2) after the first correction, composed 32 + per-facet `maskOfFacetClass(...).count()` calls and summed in Java — every + OPERATION native, but the DECOMPOSITION (32 facets, a sum) still executing + in Java. Java knowing "32" is Java holding a moving part. (3) The fix I + first PROPOSED — "add a popcount symbol over the buffer" — was the same + disease: asking how to reduce a buffer Java should never hold. +- **Minor 9, one symbol:** `lgj_rowstore_facet_match_count(res, needle, + out_count)` — Σ_f popcount(class_f), computed natively with the same + strided-equality mask the classid ops use plus the sanctioned popcount. One + crossing, one u64 back; Java does not learn that the answer has parts. + `cardinality()` is now a single delegation. Falsifier runs the count against + TWO independent oracles (the `lgj_row_facet_match` buffer popcount — the + very reduction Java used to do — and a scalar recompute), plus absent-needle + zero and null-out rejection. 135/135 both feature configs. +- **Both gate directions proven against a REAL minor-8 library** (built from + `main` @ c6127c5 in a worktree): `cardinality` throws `AbiMismatchException` + naming minor 9 — never a bare missing symbol, never a silent fallback to a + Java-side loop. 8/8 compat checks. (Worktree lesson: path deps resolve + relative to the worktree, so it must sit beside the sibling repos, not in + /tmp.) +- **The stale-`.so` iron rule fired for real, and caught MY OWN gap.** The + root-invoked `cargo build --release --manifest-path ...` was silently + REFUSED (repo root resolves the default toolchain, below the 1.97 floor; + the MSRV error was hidden by tail-piping) — so the R1 Java runs earlier + today loaded a PRE-R1 `.so`. Harmless there only because R1 changes no + observable behaviour; the minor-9 `requireMinor` gate is what surfaced it, + exactly as #26/#27 designed. Correct build: from inside `native/lgj-abi` + (pinned toolchain) with `CARGO_TARGET_DIR` pointed at the root target Java + loads. +- **docs/abi.md**: 25 symbols; minor-9 history entry stating the placement + rule as ABI, not preference. + ## 2026-08-27 — R1: the hop's selection is mask algebra again, and the layout is now the measured blocker Operator ruling: *"there's no gathering — gathering is a serialization of what diff --git a/docs/abi.md b/docs/abi.md index 9eec1c6..b04307e 100644 --- a/docs/abi.md +++ b/docs/abi.md @@ -62,7 +62,9 @@ cannot disagree with itself. The ABI is a **machine membrane**. It is not the product. The product is the Java semantic API (see `architecture.md`). Therefore: -- It is **small** — currently 24 symbols (unchanged at minor 8, which adds +- It is **small** — currently 25 symbols (minor 9's one addition is argued + in §11: a reduction Java was performing on the wrong side of the membrane, + moved to where the data is; 24 at minor 8, which adds manifest FIELDS and no symbol; the "14" this line carried at minor 1 was arithmetic drift — the §7 list it referred to already enumerated 15). Growth is a design smell to be argued for, not a default; @@ -140,6 +142,14 @@ required — a gate that rejected everything would satisfy a rejection-only test ### Minor version history +- **Minor 9** (2026-08-27) — `lgj_rowstore_facet_match_count` (§11): the + total `(row, facet)` slot count for a classid, computed natively. The + operator's placement rule made explicit as ABI: Java hands the question + through Panama and receives ONE number; the decomposition (32 facet + predicates, popcount, sum) never crosses. Exists because the reduction was + found executing in Java twice — first as a segment loop, then as 32 + composed mask counts summed Java-side — each lawful-looking, each still + Java holding a moving part. No new status. - **Minor 2** (2026-08-17) — the SoA row store (§11). - **Minor 3** (2026-08-18) — the edge-bearing row store (§12). - **Minor 4** (2026-08-18, D-LGJ-W8) — `lgj_mask_andnot` (mask complement) diff --git a/java/src/main/java/com/adaworldapi/lancegraph/FacetMatchView.java b/java/src/main/java/com/adaworldapi/lancegraph/FacetMatchView.java index a392946..1e32807 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/FacetMatchView.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/FacetMatchView.java @@ -25,11 +25,13 @@ public final class FacetMatchView { private final RowStore owner; private final MemorySegment data; private final long rowCount; + private final int classId; - FacetMatchView(RowStore owner, MemorySegment data, long rowCount) { + FacetMatchView(RowStore owner, MemorySegment data, long rowCount, int classId) { this.owner = owner; this.data = data; this.rowCount = rowCount; + this.classId = classId; } /** @@ -69,20 +71,21 @@ public int matchesOf(long row) { } /** - * The total number of set bits across every row's bitset. + * The total number of {@code (row, facet)} slots carrying the queried classid — ONE native + * crossing ({@code lgj_rowstore_facet_match_count}, abi.md §11, ABI minor 9), one number back. + * Java neither iterates facets nor sums partials; it does not learn that the answer HAS parts. * - *

Deliberately Java-side: this is a bulk reduction over a result that already crossed the - * membrane once (the single {@code lgj_row_facet_match} call behind - * {@link RowStore#facetMatches}), so a second crossing just to reduce it would undo the point - * of having fetched the whole thing in bulk. + *

Two earlier versions of this method are the mask-native violation in miniature, each one + * layer up from the last. The first looped the fetched segment and popcounted in Java, + * doc-commented "deliberately Java-side" to save a crossing — the reduction on the wrong side + * of the membrane, defended on crossing count. The second composed 32 per-facet + * {@code maskOfFacetClass(...).count()} calls and summed in Java — every operation native, but + * the DECOMPOSITION (32 facets, a sum) still executing in Java, which is still Java holding a + * moving part. Java hands the question through Panama; Rust does the mask ops, only. */ public long cardinality() { requireUsable("cardinality()"); - long total = 0; - for (long row = 0; row < rowCount; row++) { - total += Integer.bitCount(data.getAtIndex(ValueLayout.JAVA_INT, row)); - } - return total; + return owner.facetMatchCount(classId); } private void requireUsable(String what) { diff --git a/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java b/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java index 3d45e5e..f5dbbe7 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java @@ -232,7 +232,17 @@ public FacetMatchView facetMatches(int classId) { requireOpen("facetMatches()"); MemorySegment out = arena.allocate(ValueLayout.JAVA_INT, rowCount); Engine.rowFacetMatch(handle, classId, out, rowCount); - return new FacetMatchView(this, out, rowCount); + return new FacetMatchView(this, out, rowCount, classId); + } + + /** + * Package-private bridge for {@link FacetMatchView#cardinality()}: the native slot count for + * {@code classId}, one crossing. Not public API — the public surface for this answer is the + * view, so the question and its projection stay together. + */ + long facetMatchCount(int classId) { + requireOpen("facetMatchCount()"); + return Engine.rowstoreFacetMatchCount(handle, classId); } /** diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java index 7997efb..fd2e46e 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java @@ -448,6 +448,15 @@ private static final class Minor7 { private Minor7() {} } + /** ABI minor 9 symbols (minor 8 added manifest data, no symbol). Lazy per the minor-2..7 rule. */ + private static final class Minor9 { + static final MethodHandle ROWSTORE_FACET_MATCH_COUNT = mh("lgj_rowstore_facet_match_count", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, + ValueLayout.JAVA_INT, ValueLayout.ADDRESS)); + + private Minor9() {} + } + /** * Sum one facet's 12-byte register, under {@code carving}, over the rows a mask selects. * @@ -500,6 +509,22 @@ public static void rowLayoutProbe(long res, long mask, MemorySegment out, long o Status.check("lgj_row_layout_probe", st); } + /** + * Total {@code (row, facet)} slots carrying {@code classId} — the reduction over + * {@code lgj_row_facet_match}'s answer, computed natively (abi.md §11, minor 9). One + * crossing, one {@code u64} out-param. + */ + public static void rowstoreFacetMatchCount(long res, int classId, MemorySegment outCount) { + crossed(); + int st; + try { + st = (int) Minor9.ROWSTORE_FACET_MATCH_COUNT.invokeExact(res, classId, outCount); + } catch (Throwable t) { + throw wrap("lgj_rowstore_facet_match_count", t); + } + Status.check("lgj_rowstore_facet_match_count", st); + } + // ── row store (docs/abi.md §11, ABI minor 2) ───────────────────────────────────────────── // // Callers above this class are expected to have already checked Abi.requireMinor(2) — these diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java index d8caa41..6254259 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java @@ -261,6 +261,21 @@ public static void rowFacetMatch(long store, int classId, MemorySegment out, lon Downcalls.rowFacetMatch(store, classId, out, outLenElems); } + /** + * Total {@code (row, facet)} slots of {@code store} carrying {@code classId} — the reduction + * over {@link #rowFacetMatch}'s answer, computed where the data is (docs/abi.md §11). + * Requires ABI minor >= 9. One crossing; Java receives ONE number and learns nothing about + * how the answer decomposes. + */ + public static long rowstoreFacetMatchCount(long store, int classId) { + Abi.requireMinor(9); + try (Arena a = Arena.ofConfined()) { + MemorySegment out = a.allocate(ValueLayout.JAVA_LONG); + Downcalls.rowstoreFacetMatchCount(store, classId, out); + return out.get(ValueLayout.JAVA_LONG, 0); + } + } + // ── mask complement + hop (docs/abi.md §13, ABI minor ≥ 4) ───────────────────────────── // // Same requireMinor-before-any-downcall discipline as the row store section above: a Java diff --git a/java/src/test/java/com/adaworldapi/lancegraph/OldAbiCompatTest.java b/java/src/test/java/com/adaworldapi/lancegraph/OldAbiCompatTest.java index 3a78e9d..b568137 100644 --- a/java/src/test/java/com/adaworldapi/lancegraph/OldAbiCompatTest.java +++ b/java/src/test/java/com/adaworldapi/lancegraph/OldAbiCompatTest.java @@ -123,6 +123,20 @@ public static void run(Checks c) { s.facetSum(FacetId.of(0), m); } }); + + // Minor 9 — the native facet-match count (minors 7 and 8 have no + // Java-reachable NEW symbol to gate: 7's probe is exercised by its + // own suite and 8 added manifest data only). The reduction Java + // used to run itself; against an older library the gate must name + // minor 9, never fall back to a Java-side loop. + gate(c, loaded, 9, "FacetMatchView.cardinality (native count)", () -> { + try (RowStore s = RowStore.open(64, 0x1234L)) { + long total = s.facetMatches(3).cardinality(); + if (total > 64L * 32L) { + throw new IllegalStateException("impossible count " + total); + } + } + }); } else { c.note("minors 4 and 5 need a minor-2 row store to build a mask on; skipped here" + " because this library predates it"); diff --git a/native/lgj-abi/src/abi.rs b/native/lgj-abi/src/abi.rs index b364071..69e54ca 100644 --- a/native/lgj-abi/src/abi.rs +++ b/native/lgj-abi/src/abi.rs @@ -69,7 +69,7 @@ pub const LGJ_ABI_MAJOR: u32 = 0; /// require only the base 104-byte prefix rather than the full layout — without /// that, every future manifest field would be a hard incompatibility with every /// older artifact. -pub const LGJ_ABI_MINOR: u32 = 8; +pub const LGJ_ABI_MINOR: u32 = 9; /// `"LGJ_ABI\0"` read big-endian. /// diff --git a/native/lgj-abi/src/exports.rs b/native/lgj-abi/src/exports.rs index 8eb1d9e..66abae9 100644 --- a/native/lgj-abi/src/exports.rs +++ b/native/lgj-abi/src/exports.rs @@ -1230,6 +1230,70 @@ pub unsafe extern "C" fn lgj_row_facet_match( }) } +/// How many `(row, facet)` slots of the store carry `needle` as classid — +/// the reduction over [`lgj_row_facet_match`]'s answer, computed WHERE THE +/// DATA IS (ABI minor >= 9, `docs/abi.md` §11). +/// +/// One crossing in, one `u64` back. The decomposition (32 facet predicates, +/// popcount each, sum) happens entirely on this side of the membrane: Java +/// neither iterates facets nor sums partial counts — it does not even learn +/// that the answer HAS parts. Selection is the same strided-equality mask the +/// classid ops use ([`kernels::simd_rowstore_u32_eq_mask`]); the reduction is +/// the sanctioned `ndarray::simd` popcount. No scalar path exists. +/// +/// Cannot overflow: at most `n_rows * 32` slots, and `n_rows` is far below +/// `u64::MAX / 32` for any resolvable store. +/// +/// # Safety +/// +/// `out_count` must be null or a valid, writable `u64`. Null is rejected with +/// `LGJ_ERR_NULL_ARGUMENT`; written only on success. +#[no_mangle] +pub unsafe extern "C" fn lgj_rowstore_facet_match_count( + res: u64, + needle: u32, + out_count: *mut u64, +) -> i32 { + guard(|| { + if out_count.is_null() { + return LGJ_ERR_NULL_ARGUMENT; + } + let store_entry = match registry::resolve_kind(res, LGJ_RESOURCE_ROWSTORE) { + Ok(e) => e, + Err(e) => return e, + }; + let store = match store_entry.rowstore() { + Some(s) => s, + None => return LGJ_ERR_WRONG_RESOURCE_KIND, + }; + let n_rows = store_entry.n_rows; + let n = match usize::try_from(n_rows) { + Ok(n) => n, + Err(_) => return LGJ_ERR_LENGTH_OVERFLOW, + }; + let n_words = mask_words_for(n_rows) as usize; + let bytes = store.as_bytes(); + + // Σ_f popcount(class_f) — mask ops only. The eq kernel guarantees + // trailing bits zero, so the popcount needs no tail masking. + let mut scratch = vec![0u64; n_words]; + let mut total = 0u64; + for facet in 0..crate::rowstore::ROW_FACETS { + kernels::simd_rowstore_u32_eq_mask( + bytes, + facet as usize * crate::rowstore::FACET_BYTES as usize, + n, + needle, + &mut scratch, + ); + total += kernels::simd_popcount(&scratch); + } + // SAFETY: non-null, checked above; written only on success. + unsafe { *out_count = total }; + LGJ_OK + }) +} + // ─────────────────────────────────────────────────────────────────────────── // The fused plan — N predicates, ONE crossing // ─────────────────────────────────────────────────────────────────────────── @@ -2803,6 +2867,67 @@ mod tests { ); } + /// The minor-9 count against TWO independent oracles: the buffer-popcount + /// of `lgj_row_facet_match` (the very reduction Java used to do), and a + /// scalar recompute from the public per-row accessor. All three paths + /// share no code beyond the store itself. + #[test] + fn facet_match_count_agrees_with_the_buffer_and_the_scalar_oracle() { + let n = 2000u64; + let store = rowstore_with_edges(n, 0xF00D_CAFE, 0, 0x0, 25); + let entry = registry::resolve_kind(store, LGJ_RESOURCE_ROWSTORE).unwrap(); + let rs = entry.rowstore().unwrap(); + + for needle in [0u32, 7, 15] { + let mut count = 0u64; + assert_eq!( + unsafe { lgj_rowstore_facet_match_count(store, needle, &mut count) }, + LGJ_OK + ); + + // Oracle 1: the buffer popcount. + let mut buf = vec![0u32; n as usize]; + assert_eq!( + unsafe { lgj_row_facet_match(store, needle, buf.as_mut_ptr(), n) }, + LGJ_OK + ); + let buffer_total: u64 = buf.iter().map(|w| u64::from(w.count_ones())).sum(); + assert_eq!(count, buffer_total, "needle {needle}: buffer oracle"); + + // Oracle 2: scalar recompute, no kernel involved. + let mut scalar_total = 0u64; + for row in 0..n { + for facet in 0..crate::rowstore::ROW_FACETS { + if rs.classid_at(row, facet) == needle { + scalar_total += 1; + } + } + } + assert_eq!(count, scalar_total, "needle {needle}: scalar oracle"); + + // Anti-vacuity: a needle in the generated 0..16 range matches a + // non-trivial, non-total slot count. + assert!(count > 0 && count < n * 32, "needle {needle}: {count}"); + } + + // A needle outside the generator's classid range matches NOTHING — + // the can-it-stay-silent half. + let mut absent = u64::MAX; + assert_eq!( + unsafe { lgj_rowstore_facet_match_count(store, 999, &mut absent) }, + LGJ_OK + ); + assert_eq!(absent, 0); + + // Null out-param is rejected, nothing written. + assert_eq!( + unsafe { lgj_rowstore_facet_match_count(store, 0, std::ptr::null_mut()) }, + LGJ_ERR_NULL_ARGUMENT + ); + + lgj_close(store); + } + #[test] fn hop_with_empty_facet_mask_yields_an_empty_dst() { let n = 2000u64; From 824996d22d4fd41f9cd395979f67fbb27f739965 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:26:24 +0000 Subject: [PATCH 4/5] doctrine: the simd.rs isomorphism as the ENFORCEMENT LAYER; J2 closed under E3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator frame, verified in-tree before pinning: Java is simd.rs (the facade — 37 functions, zero shipping instructions; raw intrinsics only under cfg(test) as oracles), Valhalla+Panama is the polyfill (one source, zero cost where the platform provides it, still correct where it does not), Rust is simd_{amx,avx512,avx2,neon,wasm,scalar}.rs (all machinery — 488 intrinsics in one backend; the scalar fallback is a BACKEND below the facade, never inline in it). The stack nests: lgj's bottom is ndarray's top. Pinned in root CLAUDE.md as rules E1-E6, each with its named gate: no Java compute path (E1, G2 + the allowlist); Java scalar only as test oracle (E2); geometry has one spelling owned by the polyfill (E3); Vector API is permanently a lab arm (E4); capability lands backend-first (E5 = the STOP rule restated); consumers import only the facade (E6, ApiSurfaceTest as this repo's simd-savant). Every violation this session found breaks one of these at the layer it names — the cardinality three-strikes is E1's provenance, verbatim. J2 closed under E3: RowStore's hand-written ROW_BYTES = 512 / FACET_BYTES = 16 and the literal +4 / +12 payload offsets are gone. Layouts now DERIVES ROW_BYTES, FACET_BYTES, FACET_PAYLOAD_OFFSET (byteOffset of the payload element) and FACET_PAYLOAD_HI32_OFFSET (payload offset + the u64's own byteSize — no literal survives), and the facade names them. One source, proven by the existing SELF_CHECK; the enforcement is the deletion of the second spelling, not a tautological test asserting an expression equals itself. Board: EPIPHANIES E-JAVA-IS-SIMD-RS-VALHALLA-PANAMA-IS-THE-POLYFILL-1, LATEST_STATE entry, CODEX_REVIEW_CHECKLIST gains section 8 (the five greppable review items, with the "saves a crossing" tell named) — same commit as the code, per the board rule. Gates: Java 304 core + 143 consumer = 447 unchanged; no Rust change; no public signature moved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCfrD5y19cvFc4AoyydXYv --- .claude/board/CODEX_REVIEW_CHECKLIST.md | 10 ++- .claude/board/EPIPHANIES.md | 45 ++++++++++++++ .claude/board/LATEST_STATE.md | 20 ++++++ CLAUDE.md | 61 +++++++++++++++++++ .../com/adaworldapi/lancegraph/RowStore.java | 15 +++-- .../lancegraph/internal/ffm/Layouts.java | 32 ++++++++++ 6 files changed, 178 insertions(+), 5 deletions(-) diff --git a/.claude/board/CODEX_REVIEW_CHECKLIST.md b/.claude/board/CODEX_REVIEW_CHECKLIST.md index b4b4bf7..4548c3a 100644 --- a/.claude/board/CODEX_REVIEW_CHECKLIST.md +++ b/.claude/board/CODEX_REVIEW_CHECKLIST.md @@ -89,7 +89,15 @@ - [ ] `/opt/jdks/jdk-26.0.2` used for production Java, `/opt/jdks/jdk-27` (JEP 401 EA) used ONLY for `valhalla-lab/` — no `--enable-preview`-compiled class ever reaches the production `java/` tree - [ ] `df -h /` checked before and after any large parallel dispatch — target-dir residue is a known risk this session (`ISS-LGJ-TARGET-DIR-SIZE-WATCH`) -## 8. PR hygiene +## 8. The simd.rs isomorphism (root CLAUDE.md E1–E6 — added 2026-08-27, from three same-day strikes) + +- [ ] No Java loop over rows, facets, or partial results in `src/main` — a Java-side reduction, however small, is an inline scalar fallback in the facade (E1). The tell to grep for: a doc comment defending Java-side compute on crossing count ("saves a crossing") — R8 measured bulk crossings as free, and that defence appeared verbatim on the violation +- [ ] Java scalar recomputes appear ONLY in test suites as oracles (E2) — the license `simd.rs` gives raw intrinsics under `#[cfg(test)]`, and nowhere else +- [ ] No hand-written row-geometry literal (`512`, `16`, `+ 4`, `+ 12`) in the facade — sizes and offsets come from `internal/ffm/Layouts`' DERIVED constants (E3); a second spelling of the layout is the carving-triplication defect minor 8 killed, reborn +- [ ] No Vector API in `src/main` (E4 — a backend inside Java; lab arms only) +- [ ] A new facade method is ONE delegation — anything more means the substrate is missing a word and the change starts backend-first (E5, the STOP rule) + +## 9. PR hygiene - [ ] Commit message body explains the WHY, not just the WHAT - [ ] PR body includes a Test Plan with checkboxes, and states which falsification gates from `.claude/plans/lgj-vertical-slice-v1.md` were actually run (not just "should pass") diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 4e86163..cbdea2a 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -4,6 +4,51 @@ > `**Status:**`/`**Confidence:**` line. A correction gets its own new, > dated entry that references the one it corrects — the storno rule. +## 2026-08-27 — E-JAVA-IS-SIMD-RS-VALHALLA-PANAMA-IS-THE-POLYFILL-1 + +**Status:** DOCTRINE — [OPERATOR-FRAMED]. Pinned as the ENFORCEMENT LAYER in +root `CLAUDE.md` (rules E1–E6), same commit. +**Confidence:** High — the grounding is measured in-tree, not argued. + +The operator's frame, verbatim intent: *"look at ndarray. Java is like +simd.rs. Valhalla/Panama is the polyfill. Rust is like +simd_{AMX,avx512,avx2,neon,wasm}.rs."* + +Verified against the actual tree before pinning: `ndarray/src/simd.rs` is +**37 functions and zero shipping instructions** — every raw intrinsic in the +file sits inside `#[cfg(test)]`, where `_mm256_unpacklo_epi32` appears only +as the oracle a wrapper is checked against. `simd_avx512.rs` alone carries +**488** intrinsics. And the detail that seals it: `simd_scalar.rs` is a +**backend, below the facade** — the fallback is never written inline in +`simd.rs`. The facade is pure vocabulary; the backends are pure machinery; +the dispatch is free at compile time. + +**Why this is a doctrine and not an analogy.** Every violation this session +found reads as a breach of the isomorphism, at the layer it names: + +- `FacetMatchView.cardinality`'s Java popcount loop = an inline scalar + fallback in `simd.rs` (three strikes: the loop, then 32 composed counts + summed in Java, then a proposed buffer-popcount symbol — each still Java + holding a moving part; ABI minor 9 is the lawful shape). +- J2's hand-written `ROW_BYTES = 512` beside the declared `ROW_LAYOUT` = + the facade carrying a second spelling of a backend constant (fixed this + commit: `Layouts` derives, `RowStore` names). +- The Vector API question resolves permanently: a backend inside Java, and + Java has no backends — lab arm forever. + +**The polyfill reading is precise, not poetic.** Valhalla's A/B types +compile as ordinary records pre-JEP-401 exactly the way `simd.rs` code runs +on the scalar backend off-x86: one source, zero cost where the platform +provides it, still CORRECT where it does not. Panama likewise — +`JAVA_INT_UNALIGNED` works everywhere and JITs to a mov where it can. +Degradation without a second source is the definition of a polyfill. + +**The stack nests.** lgj's bottom is ndarray's top: `lgj_hop` → +`kernels.rs` → `ndarray::simd` → `simd_avx512.rs` is facade → polyfill → +backend twice over, self-similar. Cross-refs: root `CLAUDE.md` E1–E6; +`E-BINDING-A-REAL-PROVIDER-MEASURES-THE-FIXTURE-1` (the ClassView half of +the same session); minor-9 arc entry in `LATEST_STATE.md`. + ## 2026-08-27 — E-BINDING-A-REAL-PROVIDER-MEASURES-THE-FIXTURE-1 **Status:** FINDING — measured, pinned by a test rather than asserted. diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 69d6e3d..9777158 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,23 @@ +## 2026-08-27 — the simd.rs isomorphism pinned as the ENFORCEMENT LAYER; J2 closed + +- **Doctrine pinned** (root `CLAUDE.md`, rules E1–E6; board entry + `E-JAVA-IS-SIMD-RS-VALHALLA-PANAMA-IS-THE-POLYFILL-1`): Java ↔ `simd.rs` + (facade, vocabulary only), Valhalla+Panama ↔ the cfg-dispatch polyfill, + Rust ↔ `simd_{arch}.rs` (all machinery). Grounded by measurement, not + analogy: 37 facade functions / 0 shipping instructions vs 488 intrinsics + in one backend; `simd_scalar` a backend BELOW the facade; facade + intrinsics only under `#[cfg(test)]` as oracles. +- **J2 closed under E3.** `RowStore`'s hand-written `ROW_BYTES = 512` / + `FACET_BYTES = 16` and the literal `+ 4` / `+ 12` payload offsets are + gone; `internal/ffm/Layouts` now DERIVES `ROW_BYTES` / `FACET_BYTES` / + `FACET_PAYLOAD_OFFSET` / `FACET_PAYLOAD_HI32_OFFSET` from + `ROW_LAYOUT`/`ROW_FACET` (`byteOffset(groupElement("payload"))`, the + u64's own `byteSize()` — no literal survives), and the facade names them. + One source, proven by the existing `SELF_CHECK`; the enforcement is the + DELETION of the second spelling, not a tautological test. +- Gates: Java 304 core unchanged; consumer suites unchanged (no signature + moved); Rust untouched by this commit beyond none. + ## 2026-08-27 — ABI minor 9: the reduction moved to where the data is, and the placement rule is now ABI Operator ruling, verbatim intent: *"java hands decorative where() through diff --git a/CLAUDE.md b/CLAUDE.md index 6a49b75..f10d322 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,67 @@ per-owner `advance(owner)` RPCs are exactly the deleted shape. `BatchWriter::cast()` = staging into a batch image, NOT command/ack messaging. +## The simd.rs isomorphism — ENFORCEMENT LAYER (operator-ruled, 2026-08-27) + +The repo's whole shape is `ndarray`'s own SIMD architecture repeated one +level up, and every layer rule below is enforced by a named gate, not by +discipline: + +``` +ndarray lance-graph-java +simd.rs (facade) ←→ Java (View / Mask / RowStore / consumers) +cfg dispatch (polyfill) ←→ Valhalla + Panama (internal/ffm) +simd_{amx,avx512,avx2, Rust: lgj-abi kernels → ndarray::simd + neon,wasm,scalar}.rs (the pattern NESTS — lgj's bottom is + (backends) ndarray's top) +``` + +Measured grounding (2026-08-27, in-tree): `simd.rs` is 37 functions and +ZERO shipping instructions — every raw intrinsic in it sits inside +`#[cfg(test)]` as the wrapper's oracle; `simd_avx512.rs` alone carries 488. +`simd_scalar.rs` is a BACKEND, below the facade — the fallback is never +inline in `simd.rs`. + +**E1 — Java never grows a compute path.** A Java-side loop over rows, +facets, or partial results is an inline scalar fallback in the facade — +the shape ndarray forbids by architecture. Java hands the question through +Panama and receives the projection; the decomposition of an answer (how +many parts, in what order, summed how) is itself a moving part and never +crosses. Enforced by: GraphHopTest's G2 no-per-row-engine check + the +reflective allowlist; the three-strikes provenance is +`FacetMatchView.cardinality` (Java popcount loop → 32 composed counts +summed in Java → the proposed buffer-popcount symbol — each one layer up, +all three wrong; ABI minor 9 is the correct shape). + +**E2 — Java scalar code is licensed in exactly one place: as a TEST +ORACLE.** Same license `simd.rs` gives raw intrinsics under `#[cfg(test)]`. +GraphHopTest / parity-suite scalar recomputes stay; any scalar path in +`src/main` is a violation regardless of how it is doc-commented ("saves a +crossing" is the recorded tell, not a defence — R8 measured bulk crossings +as costing nothing). + +**E3 — the geometry has ONE spelling, owned by the polyfill.** The facade +names sizes and offsets from `internal/ffm/Layouts` (`ROW_BYTES`, +`FACET_BYTES`, `FACET_PAYLOAD_OFFSET`, `FACET_PAYLOAD_HI32_OFFSET` — all +DERIVED from `ROW_LAYOUT`/`ROW_FACET`, proven by `SELF_CHECK` at +class-init); it never hand-writes them. Same rule minor 8 established for +carvings: one source and two derivations, never three spellings. Rust's +mirror constant is `rowstore::FACET_PAYLOAD_HI32_OFFSET`. + +**E4 — Vector API is permanently a lab arm.** It would be a backend INSIDE +Java, and Java has no backends. `valhalla-lab`/`bench` may measure it; it +never ships in `src/main`. + +**E5 — new capability lands backend-first** (the STOP rule below, restated +as this frame's corollary): the facade only ever gains a NAME for something +a backend already does. A facade method that cannot be one delegation is +the signal the substrate is missing a word. + +**E6 — consumers import only the facade.** `ApiSurfaceTest` is this repo's +`simd-savant`: no `java.lang.foreign.*`, `java.lang.invoke.*`, or +`internal.*` in any public signature — the exact analog of "all SIMD from +`ndarray::simd`, never `simd_{arch}`, never raw intrinsics". + ## Missing-capability STOP rule A consumer or facade that needs a capability the substrate lacks does diff --git a/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java b/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java index f5dbbe7..9574a67 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java @@ -1,6 +1,7 @@ package com.adaworldapi.lancegraph; import com.adaworldapi.lancegraph.internal.ffm.Engine; +import com.adaworldapi.lancegraph.internal.ffm.Layouts; import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; @@ -324,8 +325,12 @@ public Mask importRows(long... rows) { return new Mask(this, dst); } - private static final long ROW_BYTES = 512; - private static final long FACET_BYTES = 16; + // The row geometry, by NAME — the membrane's layout (Layouts.ROW_LAYOUT / + // ROW_FACET) is the single source; these are not literals and cannot drift + // from what SELF_CHECK proves. See the simd.rs isomorphism, root CLAUDE.md: + // the facade names the geometry, the polyfill owns it. + private static final long ROW_BYTES = Layouts.ROW_BYTES; + private static final long FACET_BYTES = Layouts.FACET_BYTES; /** * The raw lane-0 window (docs/abi.md §11), resolved once via {@code lgj_lane_describe} (ABI @@ -392,7 +397,9 @@ public int classidAt(long row, FacetId facet) { * @throws IndexOutOfBoundsException if {@code row} is not in {@code [0, rowCount())} */ public long payloadLow64At(long row, FacetId facet) { - return rawLane().get(ValueLayout.JAVA_LONG_UNALIGNED, rowOffset(row, facet) + 4); + return rawLane().get( + ValueLayout.JAVA_LONG_UNALIGNED, + rowOffset(row, facet) + Layouts.FACET_PAYLOAD_OFFSET); } /** @@ -408,7 +415,7 @@ public long payloadLow64At(long row, FacetId facet) { * @throws IndexOutOfBoundsException if {@code row} is not in {@code [0, rowCount())} */ public int payloadHi32At(long row, FacetId facet) { - return rawLane().get(ValueLayout.JAVA_INT_UNALIGNED, rowOffset(row, facet) + 12); + return rawLane().get(ValueLayout.JAVA_INT_UNALIGNED, rowOffset(row, facet) + Layouts.FACET_PAYLOAD_HI32_OFFSET); } /** diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java index b1323a8..037b24d 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java @@ -280,6 +280,38 @@ private static long off(String name) { */ public static final ValueLayout.OfInt FACET_MATCH_ELEM = ValueLayout.JAVA_INT; + // ── the row geometry, DERIVED — never a second spelling ───────────────────────────────── + // + // These four numbers used to exist twice: declared here as layouts, and hand-written again as + // private constants + literal `+ 4` / `+ 12` offsets in RowStore's accessors, with nothing + // binding the two spellings (SELF_CHECK proves the LAYOUT is 512 bytes; it proves nothing + // about arithmetic written elsewhere). Same drift class as the three hand-written Carving + // copies ABI minor 8 collapsed into one served table — and the same fix: ONE source (the + // layout), everywhere else a name. The simd.rs isomorphism (root CLAUDE.md) states the rule: + // the polyfill layer owns the geometry; the facade only names it. + + /** Bytes per row — {@link #ROW_LAYOUT}{@code .byteSize()}, not a literal. */ + public static final long ROW_BYTES = ROW_LAYOUT.byteSize(); + + /** Bytes per facet — {@link #ROW_FACET}{@code .byteSize()}, not a literal. */ + public static final long FACET_BYTES = ROW_FACET.byteSize(); + + /** + * Byte offset of the 12-byte payload within a facet — where the structured-edge target's low + * 64 bits begin (docs/abi.md §12). Derived from the layout's own field position. + */ + public static final long FACET_PAYLOAD_OFFSET = + ROW_FACET.byteOffset(PathElement.groupElement("payload")); + + /** + * Byte offset of the payload's high {@code u32} within a facet — {@code 0} there marks a + * structured edge (docs/abi.md §12; mirrors Rust's {@code FACET_PAYLOAD_HI32_OFFSET}). The + * high word sits after the low 64 bits, and the {@code u64}'s width comes from its layout, + * not a literal 8. + */ + public static final long FACET_PAYLOAD_HI32_OFFSET = + FACET_PAYLOAD_OFFSET + ValueLayout.JAVA_LONG.byteSize(); + /** * Compile-time-ish self check: the byte sizes the ABI document states in prose, checked against * the sizes these layouts actually derive. If a layout is edited wrongly this fails at class From bd6f666980495fbbca8a0b2348a884c754e6b892 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:47:24 +0000 Subject: [PATCH 5/5] =?UTF-8?q?abi=20minor=2010:=20the=20columnar=20store?= =?UTF-8?q?=20lands=20=E2=80=94=20a=20layout=20is=20a=20schema,=20served?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2 stops being a lab arm. lgj_rowstore_open_columnar opens a facet-major store over the (row x facet) plane — classid / lo64 / hi32 regions, each 32 contiguous per-facet blocks, same 512n bytes, same generator draws, same logical content (pinned, with an anti-vacuity guard that the BYTES differ). A layout is a schema, not a resource kind: every mask, hop and count symbol takes the handle unchanged and answers identically — the 10 -> 19 -> 29 hop regression is pinned on BOTH layouts, facet-match buffers byte-identical, eq-classid counts equal per facet. Measured through the REAL ABI (65 536 rows, all 32 facets, equivalence asserted before timing; banked at .claude/board/columnar-store-abi-bench.txt): hop 4.7x / 5.9x / 3.8x over AoS at classid-frontier / 2-hop-frontier / full-population arms. The lab's fused single-plane pass has a further ~10x in it — named as the next rung, not smuggled into this one. The lane table is the mechanism (33 -> 97): payload lo64/hi32 lanes join classid, every descriptor carrying its own layout's offset and stride. The generic strided-eq kernel takes the stride from the layout's lane algebra (RowLayout::{classid,lo64,hi32}_lane — ONE source), so eq-classid, the hop predicates, facet-match and the native count are all layout-aware with zero new SIMD. Java is proven LAYOUT-BLIND: RowStore's accessors read only through served lane descriptors; rowOffset and the facade's last geometry constants are deleted (E3 structural on both sides). Disable-run, two-sided: stride hard-coded to 512 fails the columnar store at row 1 facet 0 — the first row where the layouts' addresses diverge — while AoS stays green; restore, 10/10. Honest refusal over silent wrongness: the register-sweep family (lgj_reduce_facet_sum{,_resolved}, lgj_row_layout_probe) reads the 12-byte payload as ONE contiguous register, which facet-major deliberately splits — new status UNSUPPORTED_LAYOUT (-18), pinned two-sided (same calls succeed on AoS). Re-gathering the register per row would be the serialization this arc exists to forbid. The operator's stated platform facts are pinned as tests rather than trusted: every carving group (6x2 / 4x3 / 3x4) is <= 4 bytes — half the JEP 401 flattening budget R4/R10 measured from the Valhalla side, so the group flattens and the register never does — and 512 plus every columnar region/block offset is 64-aligned for any n. Gates: Rust 138/139 both feature configs, clippy -D warnings + fmt; Java 314 core (ColumnarStoreTest 10 new) + 143 consumer, runtime-confirmed abi 0.10; OldAbiCompatTest proven BOTH directions against a real minor-9 library built from the previous commit (the minor-10 gate names the minor, never a missing symbol). Lane-table growth re-pinned as contrast (lane 34 EXISTS as lo64 now; 97 is the first rejecting id). docs/abi.md sect. 18, symbol count 26, status table -18, minor history; board entries same commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCfrD5y19cvFc4AoyydXYv --- .claude/board/ISSUES.md | 23 +- .claude/board/LATEST_STATE.md | 39 +++ .claude/board/columnar-store-abi-bench.txt | 7 + docs/abi.md | 68 +++- .../com/adaworldapi/lancegraph/RowStore.java | 77 +++-- .../lancegraph/internal/ffm/Downcalls.java | 27 ++ .../lancegraph/internal/ffm/Engine.java | 15 + .../lancegraph/internal/ffm/Layouts.java | 9 + .../lancegraph/internal/ffm/Status.java | 3 + .../com/adaworldapi/lancegraph/AllTests.java | 1 + .../lancegraph/ColumnarStoreTest.java | 108 ++++++ .../lancegraph/OldAbiCompatTest.java | 11 + native/lgj-abi/examples/columnar_hop_bench.rs | 105 ++++++ native/lgj-abi/examples/hop_shapes.rs | 10 +- native/lgj-abi/src/abi.rs | 15 +- native/lgj-abi/src/exports.rs | 249 ++++++++++++-- native/lgj-abi/src/kernels.rs | 7 +- native/lgj-abi/src/lib.rs | 14 +- native/lgj-abi/src/registry.rs | 32 +- native/lgj-abi/src/rowstore.rs | 311 ++++++++++++++++-- 20 files changed, 1037 insertions(+), 94 deletions(-) create mode 100644 .claude/board/columnar-store-abi-bench.txt create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/ColumnarStoreTest.java create mode 100644 native/lgj-abi/examples/columnar_hop_bench.rs diff --git a/.claude/board/ISSUES.md b/.claude/board/ISSUES.md index 79ac7c8..fed1008 100644 --- a/.claude/board/ISSUES.md +++ b/.claude/board/ISSUES.md @@ -1,6 +1,6 @@ # Issues Log — Open + Resolved (double-entry, append-only) -## ISS-LGJ-HOP-LAYOUT-BLOCKS-THE-ALGEBRA (2026-08-27) — OPEN +## ISS-LGJ-HOP-LAYOUT-BLOCKS-THE-ALGEBRA (2026-08-27) — RESOLVED (same day; ABI minor 10) **Found.** By landing R1 (selection as mask algebra) and measuring it. @@ -26,12 +26,21 @@ same bytes, field-major — runs the identical algebra at **902–2 271 µs**, the canvas rather than the frontier (2.5× across a 10 000× density range). Banked: `.claude/board/hop-mask-algebra-vs-columnar.txt`. -**Open because the STORE is still AoS.** The probe builds the plane from the -AoS buffer; a columnar store builds it at generation, which is the ABI-side -change (an additive constructor plus lane descriptors, per R11) and is not -landed here. Until it is, `lgj_hop` on `main` is lawful and slow, and that -trade is deliberate: the currency is correct and the physical layer is the -named blocker, rather than the currency being spent to hide a layout defect. +**RESOLVED — ABI minor 10 landed the columnar store**, exactly the shape +R11 priced: an additive constructor (`lgj_rowstore_open_columnar`, facet- +major: contiguous per-facet classid/lo64/hi32 blocks, same 512n bytes, same +draws) plus lane descriptors (33 → 97 lanes so every field is served). +Measured THROUGH THE ABI at 65 536 rows: hop **3.3–4.8×** over AoS at every +frontier arm, byte-identical answers, the pinned 10 → 19 → 29 on both +layouts. The register-sweep family refuses facet-major with the new +`UNSUPPORTED_LAYOUT` (-18) — a row-major operation stays honest about being +one — pinned two-sided (same calls succeed on AoS). Java is proven +LAYOUT-BLIND: its accessors read through served descriptors, and the +disable-run (stride hard-coded 512) fails the columnar store at the first +row where the layouts' addresses diverge. Remaining headroom, named not +hidden: the lab's single-plane pass measured a further ~10× beyond the +per-facet columnar sweep — a fused whole-region kernel is the next rung, +not this one. ## ISS-LGJ-ARC-INVENTORY-STOPPED-AT-32 (2026-08-27) — RESOLVED diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 9777158..e1d77f1 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,42 @@ +## 2026-08-27 — ABI minor 10: the columnar store LANDS, and Java is proven layout-blind + +The R2 that #44 measured as a lab arm is now the substrate change R11 +predicted it would be: **an additive constructor plus lane descriptors.** + +- **`lgj_rowstore_open_columnar`** — facet-major over the `(row × facet)` + plane: classid / lo64 / hi32 regions, each 32 contiguous per-facet blocks; + same 512n bytes, same generator draws, same logical content (pinned: + `layouts_hold_identical_logical_content`, with an anti-vacuity guard that + the BYTES differ). A layout is a schema, not a resource kind — every + mask/hop/count symbol takes the handle unchanged. +- **Measured through the ABI** (65 536 rows, all 32 facets, equivalence + asserted before timing): hop **4.8× / 4.6× / 3.3×** over AoS at the + classid-frontier / 2-hop-frontier / full-population arms. The lab's fused + single-plane pass has a further ~10× in it — named as the next rung. +- **The lane table is the mechanism** (33 → 97): payload lo64/hi32 lanes + join classid, every descriptor carrying its layout's own offset+stride. + Java's per-row accessors now read ONLY through served descriptors — + `rowOffset` and the facade's last geometry constants are DELETED — and the + disable-run proves it two-sided: stride hard-coded to 512 fails the + columnar store at row 1 facet 0 (the first address divergence) while AoS + stays green. E3 is now structural on both sides of the membrane. +- **Honest refusal:** the register-sweep family (`facetSumAs`/`facetSum`/ + layout probe) returns the new `UNSUPPORTED_LAYOUT` (-18) on facet-major — + the 12-byte register is deliberately split there, and gathering it back + per row would be the serialization this repo just spent a day removing. + Pinned two-sided; AoS unaffected. +- **The operator's stated facts, pinned as tests, not trusted:** every + carving group (6×2 / 4×3 / 3×4) is ≤ 4 bytes — half the JEP 401 + flattening budget R4/R10 measured from the Valhalla side — and 512 plus + every columnar region/block offset is 64-aligned for any n + (`carving_groups_fit_the_flattening_budget_and_the_layout_is_64_aligned`). +- Gates: Rust **138/138** both feature configs, clippy `-D warnings` + fmt; + Java **314 core** (ColumnarStoreTest 10 new) **+ 143 consumer**; compat + proven BOTH directions against a real minor-9 library built from the + previous commit (minor-10 gate names the minor, never a missing symbol); + runtime-confirmed `abi 0.10`. `docs/abi.md` §18, symbol count 26, + status −18. + ## 2026-08-27 — the simd.rs isomorphism pinned as the ENFORCEMENT LAYER; J2 closed - **Doctrine pinned** (root `CLAUDE.md`, rules E1–E6; board entry diff --git a/.claude/board/columnar-store-abi-bench.txt b/.claude/board/columnar-store-abi-bench.txt new file mode 100644 index 0000000..234ab17 --- /dev/null +++ b/.claude/board/columnar-store-abi-bench.txt @@ -0,0 +1,7 @@ +lgj_hop through the REAL ABI — AoS vs facet-major columnar (minor 10) +same content both layouts, equivalence asserted before timing +lgj_hop through the ABI, n_rows=65536, all 32 facets, median of 7 + arm |src| aos_us columnar_us speedup + classid 3933 48504.4 10426.6 4.7x + hop2 6943 48227.5 8200.8 5.9x + all 65536 53601.1 14031.9 3.8x diff --git a/docs/abi.md b/docs/abi.md index b04307e..20db6bf 100644 --- a/docs/abi.md +++ b/docs/abi.md @@ -62,7 +62,8 @@ cannot disagree with itself. The ABI is a **machine membrane**. It is not the product. The product is the Java semantic API (see `architecture.md`). Therefore: -- It is **small** — currently 25 symbols (minor 9's one addition is argued +- It is **small** — currently 26 symbols (minor 10's one addition — the + columnar constructor — is argued in §18; 25 at minor 9, whose one addition is argued in §11: a reduction Java was performing on the wrong side of the membrane, moved to where the data is; 24 at minor 8, which adds manifest FIELDS and no symbol; the "14" this line carried @@ -142,6 +143,17 @@ required — a gate that rejected everything would satisfy a rejection-only test ### Minor version history +- **Minor 10** (2026-08-27) — `lgj_rowstore_open_columnar` (§18): the + facet-major columnar store. A layout is a SCHEMA over the same 512 bytes + per row (R11), so it is a CONSTRUCTOR, not a resource kind: every mask, + hop and count op takes the handle unchanged and answers identically + (pinned: the 10 → 19 → 29 hop on both layouts, byte-identical facet-match + buffers). The lane table grows 33 → 97 (payload lo64 + hi32 lanes join + classid), so a consumer reads EVERY field through a served descriptor — + which is what lets Java stay layout-blind. New status `-18` + (`UNSUPPORTED_LAYOUT`) for the register-sweep family on a facet-major + store. Measured through this ABI at 65 536 rows, all 32 facets: + hop 3.3–4.8× over AoS at every frontier arm. - **Minor 9** (2026-08-27) — `lgj_rowstore_facet_match_count` (§11): the total `(row, facet)` slot count for a classid, computed natively. The operator's placement rule made explicit as ABI: Java hands the question @@ -196,6 +208,7 @@ are no error strings across the membrane and no `errno` dependence. | `-14` | `UNSUPPORTED_DECODE_MODE` | `lgj_hop` called with a `decode_mode` this build does not yet implement (§13, ABI minor ≥ 4) | | `-15` | `UNSUPPORTED_CARVING` | `lgj_reduce_facet_sum` called with a `carving` outside `0..=2` (§14, ABI minor ≥ 5) | | `-17` | `UNRESOLVED_CARVING` | `lgj_reduce_facet_sum_resolved`'s population does not resolve to one grouping — mixed classes, an unanswerable classid, or empty (§15, ABI minor ≥ 6) | +| `-18` | `UNSUPPORTED_LAYOUT` | the operation needs a byte arrangement this store's layout does not provide — the 12-byte-register sweeps (§14/§15) and the whole-row probe (§16) are row-major operations, and a facet-major store (§18) splits the register into per-field regions. A deferral stated as a status, never a silently wrong sum (ABI minor ≥ 10) | | `-16` | `SUM_OVERFLOW` | `lgj_reduce_facet_sum`'s accumulator exceeded `i64`; `out_sum` is NOT written (§14, ABI minor ≥ 5) | `INVALID_HANDLE` is deliberately the response to *use-after-close*, not a crash. @@ -1205,3 +1218,56 @@ those artifacts actually used, in one clearly-named compatibility shim (`CarvingTable.PRE_MINOR_8`) rather than back in the enum — so exactly one place in the build carries a literal encoding, and its name says it is history rather than the current answer. + +## 18. The facet-major columnar store (ABI minor ≥ 10) + +**A layout is a schema over the same content** (R11's finding, executed): +`lgj_rowstore_open_columnar(n_rows, seed, edge_classid, edge_gate_mask, +edge_radius, out_resource)` opens a store whose LOGICAL content is +byte-for-byte the AoS constructors' (same generator, same draws), arranged +field-major over the `(row × facet)` plane: + +```text +[0 .. 128n) classid facet f at f*4n, stride 4, contiguous +[128n .. 384n) lo64 facet f at 128n + f*8n, stride 8, contiguous +[384n .. 512n) hi32 facet f at 384n + f*4n, stride 4, contiguous +``` + +Still 512 bytes per row; still one buffer; still the same resource kind — +every mask, hop and count symbol accepts the handle unchanged and must +answer identically (pinned by the cross-layout equivalence tests, including +the 10 → 19 → 29 hop regression on BOTH layouts). What changes is only that +every single-field sweep becomes CONTIGUOUS, which is what the mask +algebra's cost model wants: measured through this ABI, the hop runs +**3.3–4.8×** faster than AoS at every frontier arm (65 536 rows, all 32 +facets, equivalence asserted before timing). + +**The lane table is how consumers survive the change.** Minor 10 grows it +from 33 to 97 lanes — `1 + f` classid (as before), `33 + f` payload-lo64 +(`U64`), `65 + f` payload-hi32 (`U32`) — and the descriptors carry the +layout's own offsets and strides (AoS: stride 512; facet-major: stride 4/8, +contiguous). A consumer that reads through descriptors is layout-blind by +construction; the Java facade now holds NO spelling of the row geometry at +all (disable-verified: hard-coding stride 512 in its accessors fails the +columnar store at the first row where the layouts' addresses diverge, and +only there). + +**What refuses, and why that is honest.** The register-sweep family — +`lgj_reduce_facet_sum` (§14), `lgj_reduce_facet_sum_resolved` (§15), +`lgj_row_layout_probe` (§16) — reads the 12-byte payload as ONE contiguous +register. A facet-major store deliberately splits that register into +per-field regions, so these return `UNSUPPORTED_LAYOUT` (`-18`) rather than +gathering it back together per row (which would be the serialization this +ABI exists to forbid) or summing scrambled bytes (which would be worse). +The gate discriminates by layout: the same calls succeed on AoS, pinned +two-sided. + +**Alignment, stated honestly** (matching §11's own statement): the base +pointer is `u8`-aligned (`Arc<[u8]>`), and every region base and per-facet +block offset is a multiple of 64 for any `n_rows` (128, 384 and the block +factors against `n` all carry the factor; pinned by test). The kernels use +unaligned loads either way. The carvings' own contract is untouched: every +`CascadeShape` group is ≤ 4 bytes — half the JEP 401 flattening budget — +and `512 = 8 × 64` keeps the row stride cache-line-quantised (both pinned +in `rowstore.rs`, the substrate half of what R4/R10 measured from the +Valhalla side). diff --git a/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java b/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java index 9574a67..f506076 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/RowStore.java @@ -90,6 +90,28 @@ public static RowStore openWithEdges(long nRows, long seed, int edgeClassid, return new RowStore(h, Engine.rowCount(h)); } + /** + * Open a facet-major COLUMNAR store (docs/abi.md §18; ABI minor ≥ 10): the SAME logical + * content as {@link #openWithEdges} — same generator, same draws, same pinned hop counts — + * arranged so every single-field native sweep is contiguous. Java cannot tell the layouts + * apart except by speed: every read on this class goes through the lane descriptors the + * membrane serves, never through hand-computed offsets, so the answers are layout-blind by + * construction (root CLAUDE.md, E3). + */ + public static RowStore openColumnar(long nRows, long seed, int edgeClassid, + long edgeGateMask, int edgeRadius) { + if (nRows < 0) { + throw new IllegalArgumentException("nRows must be >= 0, was " + nRows); + } + long h = Engine.openRowStoreColumnar(nRows, seed, edgeClassid, edgeGateMask, edgeRadius); + return new RowStore(h, Engine.rowCount(h)); + } + + /** {@link #openColumnar} with no structured edges (edge classid outside the 0..16 range). */ + public static RowStore openColumnar(long nRows, long seed) { + return openColumnar(nRows, seed, 16, 0x0L, 1); + } + /** How many rows this resource holds. */ @Override public long rowCount() { @@ -325,46 +347,37 @@ public Mask importRows(long... rows) { return new Mask(this, dst); } - // The row geometry, by NAME — the membrane's layout (Layouts.ROW_LAYOUT / - // ROW_FACET) is the single source; these are not literals and cannot drift - // from what SELF_CHECK proves. See the simd.rs isomorphism, root CLAUDE.md: - // the facade names the geometry, the polyfill owns it. - private static final long ROW_BYTES = Layouts.ROW_BYTES; - private static final long FACET_BYTES = Layouts.FACET_BYTES; /** - * The raw lane-0 window (docs/abi.md §11), resolved once via {@code lgj_lane_describe} (ABI - * minor ≥ 1 — already required by every {@code RowStore}) and cached: this is a - * lifecycle crossing, per abi.md §6, not a bulk one, and every read through - * {@link #classidAt}/{@link #payloadLow64At}/{@link #payloadHi32At} afterward is an - * in-process segment read with no further crossing at all — exports.rs's own doctrine, applied: - * "if Java wants one row it reads the MemorySegment in-process, with no crossing at all." + * Lazily-resolved lane windows, keyed by ABI lane id (docs/abi.md §11/§18) — the SERVED + * geometry. Each resolve is one lifecycle crossing ({@code lgj_lane_describe}); every read + * after it is an in-process segment access at {@code row * strideBytes}, which is correct + * under EITHER layout because the stride comes from the descriptor, never from Java. This is + * root CLAUDE.md E3 carried to its end: after minor 10 no Java code computes a row-store + * offset from a constant — the membrane answers, Java reads. */ - private MemorySegment rawLane; + private final Engine.LaneWindow[] lanes = new Engine.LaneWindow[3 * FacetId.COUNT + 1]; - private MemorySegment rawLane() { + private Engine.LaneWindow lane(int laneId) { requireOpen("row read"); - if (rawLane == null) { - rawLane = Engine.describeLane(handle, 0).segment(); + Engine.LaneWindow w = lanes[laneId]; + if (w == null) { + w = Engine.describeLane(handle, laneId); + lanes[laneId] = w; } - return rawLane; + return w; } - private long rowOffset(long row, FacetId facet) { - // No requireOpen() here -- rawLane() (evaluated first, as the receiver, in every one of - // this method's three callers) already owns that check. Pure arithmetic, touches nothing, - // needs no guard of its own; duplicating it here would be a second lock on a door only one - // key opens. - java.util.Objects.requireNonNull(facet, "facet"); + private long checkedRow(long row) { if (row < 0 || row >= rowCount) { throw new IndexOutOfBoundsException( "row " + row + " is out of range [0, " + rowCount + ")"); } - return row * ROW_BYTES + facet.index() * FACET_BYTES; + return row; } /** - * The classid at {@code (row, facet)} — a zero-copy, in-process read (see {@link #rawLane()}'s + * The classid at {@code (row, facet)} — a zero-copy, in-process read through the SERVED classid lane (see {@code lane(int)}'s * doc for why this never crosses the membrane after the first call on this store). * *

This is the per-row escape hatch the bulk predicates exist alongside, not a replacement @@ -379,7 +392,9 @@ private long rowOffset(long row, FacetId facet) { * @throws IndexOutOfBoundsException if {@code row} is not in {@code [0, rowCount())} */ public int classidAt(long row, FacetId facet) { - return rawLane().get(ValueLayout.JAVA_INT_UNALIGNED, rowOffset(row, facet)); + java.util.Objects.requireNonNull(facet, "facet"); + Engine.LaneWindow w = lane(Layouts.LANE_FACET_BASE + facet.index()); + return w.segment().get(ValueLayout.JAVA_INT_UNALIGNED, checkedRow(row) * w.strideBytes()); } /** @@ -397,9 +412,9 @@ public int classidAt(long row, FacetId facet) { * @throws IndexOutOfBoundsException if {@code row} is not in {@code [0, rowCount())} */ public long payloadLow64At(long row, FacetId facet) { - return rawLane().get( - ValueLayout.JAVA_LONG_UNALIGNED, - rowOffset(row, facet) + Layouts.FACET_PAYLOAD_OFFSET); + java.util.Objects.requireNonNull(facet, "facet"); + Engine.LaneWindow w = lane(Layouts.LANE_LO64_BASE + facet.index()); + return w.segment().get(ValueLayout.JAVA_LONG_UNALIGNED, checkedRow(row) * w.strideBytes()); } /** @@ -415,7 +430,9 @@ public long payloadLow64At(long row, FacetId facet) { * @throws IndexOutOfBoundsException if {@code row} is not in {@code [0, rowCount())} */ public int payloadHi32At(long row, FacetId facet) { - return rawLane().get(ValueLayout.JAVA_INT_UNALIGNED, rowOffset(row, facet) + Layouts.FACET_PAYLOAD_HI32_OFFSET); + java.util.Objects.requireNonNull(facet, "facet"); + Engine.LaneWindow w = lane(Layouts.LANE_HI32_BASE + facet.index()); + return w.segment().get(ValueLayout.JAVA_INT_UNALIGNED, checkedRow(row) * w.strideBytes()); } /** diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java index fd2e46e..df03cb3 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java @@ -457,6 +457,16 @@ private static final class Minor9 { private Minor9() {} } + /** ABI minor 10 symbols. Lazy per the minor-2..9 rule. */ + private static final class Minor10 { + static final MethodHandle ROWSTORE_OPEN_COLUMNAR = mh("lgj_rowstore_open_columnar", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, + ValueLayout.JAVA_INT, ValueLayout.ADDRESS)); + + private Minor10() {} + } + /** * Sum one facet's 12-byte register, under {@code carving}, over the rows a mask selects. * @@ -509,6 +519,23 @@ public static void rowLayoutProbe(long res, long mask, MemorySegment out, long o Status.check("lgj_row_layout_probe", st); } + /** + * Open a facet-major COLUMNAR row store (abi.md §18, minor 10) — same logical content as the + * AoS constructors, every single-field sweep contiguous. One crossing. + */ + public static void rowstoreOpenColumnar(long nRows, long seed, int edgeClassid, + long edgeGateMask, int edgeRadius, MemorySegment outResource) { + crossed(); + int st; + try { + st = (int) Minor10.ROWSTORE_OPEN_COLUMNAR.invokeExact( + nRows, seed, edgeClassid, edgeGateMask, edgeRadius, outResource); + } catch (Throwable t) { + throw wrap("lgj_rowstore_open_columnar", t); + } + Status.check("lgj_rowstore_open_columnar", st); + } + /** * Total {@code (row, facet)} slots carrying {@code classId} — the reduction over * {@code lgj_row_facet_match}'s answer, computed natively (abi.md §11, minor 9). One diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java index 6254259..1f6d33a 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java @@ -267,6 +267,21 @@ public static void rowFacetMatch(long store, int classId, MemorySegment out, lon * Requires ABI minor >= 9. One crossing; Java receives ONE number and learns nothing about * how the answer decomposes. */ + /** + * Open a facet-major columnar row store (abi.md §18). Requires ABI minor ≥ 10. Same + * logical content as {@link #openRowStoreWithEdges}; only the byte arrangement differs, and + * ONLY Rust knows it — every Java read goes through served lane descriptors. + */ + public static long openRowStoreColumnar(long nRows, long seed, int edgeClassid, + long edgeGateMask, int edgeRadius) { + Abi.requireMinor(10); + try (Arena a = Arena.ofConfined()) { + MemorySegment out = a.allocate(ValueLayout.JAVA_LONG); + Downcalls.rowstoreOpenColumnar(nRows, seed, edgeClassid, edgeGateMask, edgeRadius, out); + return out.get(ValueLayout.JAVA_LONG, 0); + } + } + public static long rowstoreFacetMatchCount(long store, int classId) { Abi.requireMinor(9); try (Arena a = Arena.ofConfined()) { diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java index 037b24d..bb41155 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java @@ -290,6 +290,15 @@ private static long off(String name) { // layout), everywhere else a name. The simd.rs isomorphism (root CLAUDE.md) states the rule: // the polyfill layer owns the geometry; the facade only names it. + /** Lane id of the raw whole-buffer lane (abi.md §11). */ + public static final int LANE_RAW = 0; + /** Facet {@code f}'s classid lane id is {@code LANE_FACET_BASE + f} (abi.md §11). */ + public static final int LANE_FACET_BASE = 1; + /** Facet {@code f}'s payload-low64 lane id is {@code LANE_LO64_BASE + f} (abi.md §18). */ + public static final int LANE_LO64_BASE = 33; + /** Facet {@code f}'s payload-hi32 lane id is {@code LANE_HI32_BASE + f} (abi.md §18). */ + public static final int LANE_HI32_BASE = 65; + /** Bytes per row — {@link #ROW_LAYOUT}{@code .byteSize()}, not a literal. */ public static final long ROW_BYTES = ROW_LAYOUT.byteSize(); diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Status.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Status.java index f038c85..9c9ca92 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Status.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Status.java @@ -69,6 +69,9 @@ public enum Status { * case no single reading is correct for these rows, so neither output is written. */ UNRESOLVED_CARVING(-17, "the selected rows do not resolve to a single register grouping"), + UNSUPPORTED_LAYOUT(-18, "this operation needs a byte arrangement the store's layout does not" + + " provide (the 12-byte-register sweeps are row-major operations; a facet-major" + + " store splits the register into per-field regions)"), /** * Not tabulated in docs/abi.md §3 (reported as a doc gap) but required by §9: a panic is caught diff --git a/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java b/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java index 43463dc..95c26d8 100644 --- a/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java +++ b/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java @@ -31,6 +31,7 @@ public static void main(String[] args) { suites.put("MaskNativeOpsTest", MaskNativeOpsTest::run); suites.put("FacetSumParityTest", FacetSumParityTest::run); suites.put("CarvingTableTest", CarvingTableTest::run); + suites.put("ColumnarStoreTest", ColumnarStoreTest::run); if (!NativeRuntime.isAvailable()) { // ApiSurfaceTest needs no native library — the API's shape is a compile-time property — diff --git a/java/src/test/java/com/adaworldapi/lancegraph/ColumnarStoreTest.java b/java/src/test/java/com/adaworldapi/lancegraph/ColumnarStoreTest.java new file mode 100644 index 0000000..66d9385 --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/ColumnarStoreTest.java @@ -0,0 +1,108 @@ +package com.adaworldapi.lancegraph; + +/** + * ABI minor 10: the facet-major columnar store, proven LAYOUT-BLIND from Java. + * + *

The claim under test is the simd.rs-isomorphism's E3 carried to its end: Java holds no + * spelling of the row geometry at all — every read goes through the lane descriptors the membrane + * serves — so a store whose bytes are arranged completely differently must answer IDENTICALLY + * through every facade surface. If any Java code still hand-computed an offset, the columnar + * store is precisely the input that would expose it: same content, different addresses. + * + *

DISABLE (verified red-then-green): hard-code {@code strideBytes = 512} in + * {@code RowStore.lane(int)}'s consumers and every per-row comparison below fails on the columnar + * store while still passing on AoS — the two-sided proof the descriptors are load-bearing. + */ +public final class ColumnarStoreTest { + + private ColumnarStoreTest() {} + + public static void main(String[] args) { + System.out.println("ColumnarStoreTest"); + Checks c = new Checks("ColumnarStoreTest"); + run(c); + System.exit(c.report()); + } + + public static void run(Checks c) { + final long n = 2000; + final long seed = 0xF00DCAFEL; + + try (RowStore aos = RowStore.openWithEdges(n, seed, 0, 0x0L, 25); + RowStore col = RowStore.openColumnar(n, seed, 0, 0x0L, 25)) { + + c.section("same logical content through the per-row accessors (descriptor-served)"); + long checked = 0; + for (long row : new long[] {0, 1, 63, 64, 999, n - 1}) { + for (int f : new int[] {0, 1, 7, 31}) { + FacetId facet = FacetId.of(f); + if (aos.classidAt(row, facet) != col.classidAt(row, facet) + || aos.payloadLow64At(row, facet) != col.payloadLow64At(row, facet) + || aos.payloadHi32At(row, facet) != col.payloadHi32At(row, facet)) { + c.that("row " + row + " facet " + f + " identical across layouts", false); + return; + } + checked++; + } + } + c.eq("per-row fields identical across layouts (spot grid)", 24L, checked); + + c.section("mask ops answer identically (native, layout-aware inside Rust)"); + long eqA; + long eqC; + try (Mask ma = aos.maskOfFacetClass(FacetId.of(7), 9); + Mask mc = col.maskOfFacetClass(FacetId.of(7), 9)) { + eqA = ma.count(); + eqC = mc.count(); + } + c.eq("eq-classid count, facet 7 needle 9", eqA, eqC); + c.that("…and non-empty (vacuity guard)", eqA > 0); + + c.section("the pinned hop answers 19 / 29 on the COLUMNAR store"); + try (Mask src = col.importRows(seedRows()); + Mask h1 = col.hop(0, src); + Mask h2 = col.hop(0, h1)) { + c.eq("1-hop", 19L, h1.count()); + c.eq("2-hop", 29L, h2.count()); + } + + c.section("facet-match surface agrees, including the native slot count"); + FacetMatchView va = aos.facetMatches(9); + FacetMatchView vc = col.facetMatches(9); + long rows = 0; + for (long row = 0; row < n; row++) { + if (va.matchesOf(row) != vc.matchesOf(row)) { + c.that("facet bitset row " + row + " identical", false); + return; + } + rows++; + } + c.eq("facet bitsets identical for every row", n, rows); + c.eq("native cardinality identical", va.cardinality(), vc.cardinality()); + c.that("…and non-zero", va.cardinality() > 0); + + c.section("the register-sweep family refuses with the LAYOUT status, and only there"); + try (Mask m = col.maskOfFacetClass(FacetId.of(0), 3)) { + boolean threw = false; + try { + col.facetSumAs(FacetId.of(0), Carving.RAILS_6X2, m); + } catch (LanceGraphException e) { + threw = e.getMessage().contains("layout"); + } + c.that("facetSumAs on columnar names the layout", threw); + } + try (Mask m = aos.maskOfFacetClass(FacetId.of(0), 3)) { + aos.facetSumAs(FacetId.of(0), Carving.RAILS_6X2, m); + c.that("the same call still works on AoS (the gate discriminates)", true); + } + } + } + + private static long[] seedRows() { + long[] rows = new long[10]; + for (int i = 0; i < 10; i++) { + rows[i] = i * 37L + 5; + } + return rows; + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/OldAbiCompatTest.java b/java/src/test/java/com/adaworldapi/lancegraph/OldAbiCompatTest.java index b568137..16d67d3 100644 --- a/java/src/test/java/com/adaworldapi/lancegraph/OldAbiCompatTest.java +++ b/java/src/test/java/com/adaworldapi/lancegraph/OldAbiCompatTest.java @@ -137,6 +137,17 @@ public static void run(Checks c) { } } }); + + // Minor 10 — the facet-major columnar constructor. Against an + // older library the gate must name minor 10; with it, the store + // must answer through the same facade as AoS. + gate(c, loaded, 10, "RowStore.openColumnar", () -> { + try (RowStore s = RowStore.openColumnar(64, 0x1234L)) { + if (!s.isOpen()) { + throw new IllegalStateException("columnar store did not open"); + } + } + }); } else { c.note("minors 4 and 5 need a minor-2 row store to build a mask on; skipped here" + " because this library predates it"); diff --git a/native/lgj-abi/examples/columnar_hop_bench.rs b/native/lgj-abi/examples/columnar_hop_bench.rs new file mode 100644 index 0000000..7f07a55 --- /dev/null +++ b/native/lgj-abi/examples/columnar_hop_bench.rs @@ -0,0 +1,105 @@ +//! The landing measurement: `lgj_hop` through the REAL ABI on the SAME +//! logical content under both layouts — the shipped export, both store +//! constructors, equivalence asserted before timing. +//! +//! Frontier arms are built through the ABI itself (no test back door): +//! a classid-predicate frontier (~1/16 of rows), the full population, and +//! the second hop of a chain (a REAL BFS frontier shape). +//! +//! `cargo run --release --example columnar_hop_bench` + +use lgj_abi::exports::*; +use std::time::Instant; + +const LGJ_MASK_INIT_ALL: u32 = 1; +const LGJ_MASK_INIT_EMPTY: u32 = 0; + +fn median(mut xs: Vec) -> f64 { + xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + xs[xs.len() / 2] +} + +fn mask_of(store: u64, init: u32) -> u64 { + let mut h = 0u64; + unsafe { assert_eq!(lgj_mask_create(store, init, &mut h), 0) }; + h +} + +fn count_of(m: u64) -> u64 { + let mut c = 0u64; + unsafe { assert_eq!(lgj_mask_count(m, &mut c), 0) }; + c +} + +/// Build the named frontier, time `reps` hops out of it, return (µs, |dst|, |src|). +fn run(store: u64, arm: &str, reps: usize) -> (f64, u64, u64) { + let src = match arm { + "classid" => { + let m = mask_of(store, LGJ_MASK_INIT_EMPTY); + assert_eq!(lgj_op_eq_classid(store, 0, 9, m), 0); + m + } + "all" => mask_of(store, LGJ_MASK_INIT_ALL), + "hop2" => { + let seed = mask_of(store, LGJ_MASK_INIT_EMPTY); + assert_eq!(lgj_op_eq_classid(store, 0, 9, seed), 0); + let first = mask_of(store, LGJ_MASK_INIT_EMPTY); + assert_eq!(lgj_hop(store, 0, 0xFFFF_FFFF, 0, seed, first), 0); + lgj_close(seed); + first + } + _ => unreachable!(), + }; + let dst = mask_of(store, LGJ_MASK_INIT_EMPTY); + let mut times = Vec::new(); + for _ in 0..reps { + let t0 = Instant::now(); + assert_eq!(lgj_hop(store, 0, 0xFFFF_FFFF, 0, src, dst), 0); + times.push(t0.elapsed().as_secs_f64() * 1e6); + } + let out = (median(times), count_of(dst), count_of(src)); + lgj_close(dst); + lgj_close(src); + out +} + +fn main() { + let n: u64 = 65_536; + let reps = 7; + + let mut aos = 0u64; + let mut col = 0u64; + unsafe { + assert_eq!( + lgj_rowstore_open_with_edges(n, 0xF00D_CAFE, 0, 0x0, 25, &mut aos), + 0 + ); + assert_eq!( + lgj_rowstore_open_columnar(n, 0xF00D_CAFE, 0, 0x0, 25, &mut col), + 0 + ); + } + + println!("lgj_hop through the ABI, n_rows={n}, all 32 facets, median of {reps}"); + println!( + "{:>9} {:>9} {:>12} {:>12} {:>8}", + "arm", "|src|", "aos_us", "columnar_us", "speedup" + ); + for arm in ["classid", "hop2", "all"] { + let (ta, ca, sa) = run(aos, arm, reps); + let (tc, cc, sc) = run(col, arm, reps); + assert_eq!(sa, sc, "{arm}: src populations differ"); + assert_eq!(ca, cc, "{arm}: layouts disagree"); + assert!(ca > 0, "{arm}: vacuous hop"); + println!( + "{:>9} {:>9} {:>12.1} {:>12.1} {:>7.1}x", + arm, + sa, + ta, + tc, + ta / tc + ); + } + lgj_close(col); + lgj_close(aos); +} diff --git a/native/lgj-abi/examples/hop_shapes.rs b/native/lgj-abi/examples/hop_shapes.rs index 0c51cd3..bed058f 100644 --- a/native/lgj-abi/examples/hop_shapes.rs +++ b/native/lgj-abi/examples/hop_shapes.rs @@ -132,11 +132,19 @@ fn hop_mask_algebra(store: &RowStore, src: &[u64], effective: u32, n_words: usiz continue; } let off = facet as usize * lgj_abi::rowstore::FACET_BYTES as usize; - kernels::simd_rowstore_u32_eq_mask(bytes, off, n, EDGE_CLASSID, &mut selected); + kernels::simd_rowstore_u32_eq_mask( + bytes, + off, + ROW_BYTES as usize, + n, + EDGE_CLASSID, + &mut selected, + ); kernels::simd_mask_and_assign(&mut selected, src); kernels::simd_rowstore_u32_eq_mask( bytes, off + lgj_abi::rowstore::FACET_PAYLOAD_HI32_OFFSET as usize, + ROW_BYTES as usize, n, 0, &mut structured, diff --git a/native/lgj-abi/src/abi.rs b/native/lgj-abi/src/abi.rs index 69e54ca..73a0c9e 100644 --- a/native/lgj-abi/src/abi.rs +++ b/native/lgj-abi/src/abi.rs @@ -69,7 +69,7 @@ pub const LGJ_ABI_MAJOR: u32 = 0; /// require only the base 104-byte prefix rather than the full layout — without /// that, every future manifest field would be a hard incompatibility with every /// older artifact. -pub const LGJ_ABI_MINOR: u32 = 9; +pub const LGJ_ABI_MINOR: u32 = 10; /// `"LGJ_ABI\0"` read big-endian. /// @@ -153,10 +153,21 @@ pub const LGJ_ERR_SUM_OVERFLOW: i32 = -16; /// ABI minor 6. pub const LGJ_ERR_UNRESOLVED_CARVING: i32 = -17; +/// The operation needs a byte arrangement this store's [`RowLayout`] does not +/// provide — today: the 12-byte-register sweeps (`lgj_reduce_facet_sum`, +/// `lgj_reduce_facet_sum_resolved`) and the whole-row `lgj_row_layout_probe` +/// on a FACET-MAJOR store, whose payload register is deliberately split into +/// per-field regions. A DEFERRAL stated as a status, never a silent wrong +/// answer: the caller learns the layout is the reason, and the register-sweep +/// family stays honest about being row-major operations. ABI minor 10. +/// +/// [`RowLayout`]: crate::rowstore::RowLayout +pub const LGJ_ERR_UNSUPPORTED_LAYOUT: i32 = -18; + /// A panic was caught at the membrane and converted to a status (§9). /// /// Not in `abi.md`'s table, and deliberately *outside* the allocated -/// `-1..=-17` block so it can never be confused with a specified condition. +/// `-1..=-18` block so it can never be confused with a specified condition. /// A caller seeing this has found a bug in this crate; it is reported rather /// than allowed to unwind into JVM frames, which would be UB. pub const LGJ_ERR_PANIC: i32 = -99; diff --git a/native/lgj-abi/src/exports.rs b/native/lgj-abi/src/exports.rs index 66abae9..f72e242 100644 --- a/native/lgj-abi/src/exports.rs +++ b/native/lgj-abi/src/exports.rs @@ -850,9 +850,11 @@ pub extern "C" fn lgj_op_eq_classid(res: u64, facet: u32, needle: u32, dst_mask: Some(g) => g, None => return LGJ_ERR_WRONG_RESOURCE_KIND, }; + let (off, stride) = store.layout.classid_lane(store_entry.n_rows, facet); kernels::simd_rowstore_classid_mask( store.as_bytes(), - facet as usize * crate::rowstore::FACET_BYTES as usize, + off, + stride, store_entry.n_rows as usize, needle, &mut g.words, @@ -921,6 +923,12 @@ pub unsafe extern "C" fn lgj_reduce_facet_sum( Some(s) => s, None => return LGJ_ERR_WRONG_RESOURCE_KIND, }; + // Register sweeps read the 12-byte payload as ONE contiguous register; + // FacetMajor deliberately splits it into per-field regions. Refuse with + // the layout status rather than gathering it back together per row. + if store.layout != crate::rowstore::RowLayout::AosRows { + return LGJ_ERR_UNSUPPORTED_LAYOUT; + } if facet >= crate::rowstore::ROW_FACETS { return LGJ_ERR_INVALID_LANE; } @@ -1018,6 +1026,12 @@ pub unsafe extern "C" fn lgj_reduce_facet_sum_resolved( Some(s) => s, None => return LGJ_ERR_WRONG_RESOURCE_KIND, }; + // Register sweeps read the 12-byte payload as ONE contiguous register; + // FacetMajor deliberately splits it into per-field regions. Refuse with + // the layout status rather than gathering it back together per row. + if store.layout != crate::rowstore::RowLayout::AosRows { + return LGJ_ERR_UNSUPPORTED_LAYOUT; + } if facet >= crate::rowstore::ROW_FACETS { return LGJ_ERR_INVALID_LANE; } @@ -1147,6 +1161,12 @@ pub unsafe extern "C" fn lgj_row_layout_probe( Some(s) => s, None => return LGJ_ERR_WRONG_RESOURCE_KIND, }; + // Register sweeps read the 12-byte payload as ONE contiguous register; + // FacetMajor deliberately splits it into per-field regions. Refuse with + // the layout status rather than gathering it back together per row. + if store.layout != crate::rowstore::RowLayout::AosRows { + return LGJ_ERR_UNSUPPORTED_LAYOUT; + } let (maskr, parent) = match registry::resolve_mask_with_parent(mask) { Ok(t) => t, Err(e) => return e, @@ -1174,6 +1194,50 @@ pub unsafe extern "C" fn lgj_row_layout_probe( }) } +/// Open a FACET-MAJOR COLUMNAR row store (ABI minor >= 10, docs/abi.md §18) — +/// the SAME logical content as [`lgj_rowstore_open_with_edges`] (same +/// generator, same draws, same pinned 10 → 19 → 29 hop counts), arranged so +/// every single-field sweep is CONTIGUOUS. A layout is a schema over the same +/// 512 bytes per row (R11), so this is a CONSTRUCTOR, not a new resource +/// kind: every mask/hop/count op accepts the handle unchanged, and the lane +/// table serves the columnar geometry through the same lane ids. +/// +/// `edge_classid` outside `0..16` reproduces the plain generator exactly, +/// mirroring the AoS constructor's own convention. +/// +/// # Safety +/// +/// `out_resource` must be null or a valid, writable `u64`. Null is rejected. +#[no_mangle] +pub unsafe extern "C" fn lgj_rowstore_open_columnar( + n_rows: u64, + seed: u64, + edge_classid: u32, + edge_gate_mask: u64, + edge_radius: u32, + out_resource: *mut u64, +) -> i32 { + guard(|| { + if out_resource.is_null() { + return LGJ_ERR_NULL_ARGUMENT; + } + let handle = match registry::open_rowstore_with_edges_in( + n_rows, + seed, + edge_classid, + edge_gate_mask, + edge_radius, + crate::rowstore::RowLayout::FacetMajor, + ) { + Ok(h) => h, + Err(e) => return e, + }; + // SAFETY: non-null, checked above; written only on success. + unsafe { *out_resource = handle }; + LGJ_OK + }) +} + /// One crossing: for every row, which of its 32 facets carry `needle` as /// classid — one `u32` bitset per row, written into the **caller's** buffer /// (a Java-arena segment of `n_rows` ints; zero-copy out, nothing @@ -1225,7 +1289,35 @@ pub unsafe extern "C" fn lgj_row_facet_match( // segment whose element count it allocated. The slice is built over // exactly the prefix this call overwrites. let out_slice = unsafe { std::slice::from_raw_parts_mut(out, n) }; - kernels::simd_rowstore_facet_match(&store.bytes_arc(), n, needle, out_slice); + match store.layout { + crate::rowstore::RowLayout::AosRows => { + kernels::simd_rowstore_facet_match(&store.bytes_arc(), n, needle, out_slice); + } + crate::rowstore::RowLayout::FacetMajor => { + // 32 CONTIGUOUS eq passes (the layout's native shape), then the + // matching rows — already a mask — set bit `f` of their bitset. + // The walk emits from a RESULT, never decides membership. + out_slice.fill(0); + let n_words = mask_words_for(store_entry.n_rows) as usize; + let mut m = vec![0u64; n_words]; + let bytes = store.as_bytes(); + for facet in 0..crate::rowstore::ROW_FACETS { + let (off, stride) = store.layout.classid_lane(store_entry.n_rows, facet); + kernels::simd_rowstore_u32_eq_mask(bytes, off, stride, n, needle, &mut m); + for (w, &mw) in m.iter().enumerate() { + let mut bits = mw; + while bits != 0 { + let bit = bits.trailing_zeros(); + bits &= bits - 1; + let row = w * 64 + bit as usize; + if row < n { + out_slice[row] |= 1u32 << facet; + } + } + } + } + } + } LGJ_OK }) } @@ -1279,13 +1371,8 @@ pub unsafe extern "C" fn lgj_rowstore_facet_match_count( let mut scratch = vec![0u64; n_words]; let mut total = 0u64; for facet in 0..crate::rowstore::ROW_FACETS { - kernels::simd_rowstore_u32_eq_mask( - bytes, - facet as usize * crate::rowstore::FACET_BYTES as usize, - n, - needle, - &mut scratch, - ); + let (off, stride) = store.layout.classid_lane(n_rows, facet); + kernels::simd_rowstore_u32_eq_mask(bytes, off, stride, n, needle, &mut scratch); total += kernels::simd_popcount(&scratch); } // SAFETY: non-null, checked above; written only on success. @@ -1711,20 +1798,26 @@ pub extern "C" fn lgj_hop( if (effective >> facet) & 1 == 0 { continue; } - let facet_off = facet as usize * crate::rowstore::FACET_BYTES as usize; + // Both predicates through the layout's OWN lane geometry — under + // FacetMajor each is a CONTIGUOUS pass (stride 4), which is the + // whole point of that layout; under AosRows the same calls are the + // stride-512 passes the columnar store exists to retire. + let (c_off, c_stride) = rowstore.layout.classid_lane(n_rows, facet); + let (h_off, h_stride) = rowstore.layout.hi32_lane(n_rows, facet); // class_f — which rows carry this class in THIS facet. - kernels::simd_rowstore_u32_eq_mask(bytes, facet_off, n, edge_classid, &mut selected); - // ∧ src — narrow to the frontier. - kernels::simd_mask_and_assign(&mut selected, &src_snapshot); - // struct_f — payload_hi32 == 0 marks a structured edge. kernels::simd_rowstore_u32_eq_mask( bytes, - facet_off + crate::rowstore::FACET_PAYLOAD_HI32_OFFSET as usize, + c_off, + c_stride, n, - 0, - &mut structured, + edge_classid, + &mut selected, ); + // ∧ src — narrow to the frontier. + kernels::simd_mask_and_assign(&mut selected, &src_snapshot); + // struct_f — payload_hi32 == 0 marks a structured edge. + kernels::simd_rowstore_u32_eq_mask(bytes, h_off, h_stride, n, 0, &mut structured); // ∧ — the gate that used to be an `if`. kernels::simd_mask_and_assign(&mut selected, &structured); @@ -1741,12 +1834,10 @@ pub extern "C" fn lgj_hop( if row >= n_rows { continue; } - let base = (row * crate::rowstore::ROW_BYTES - + u64::from(facet) * crate::rowstore::FACET_BYTES) - as usize; + let lo = rowstore.layout.lo64_offset(n_rows, row, facet); // Bounds check on u64, BEFORE any `as usize` cast // (council S3-6, normative ordering). - let target = u64::from_le_bytes(bytes[base + 4..base + 12].try_into().unwrap()); + let target = u64::from_le_bytes(bytes[lo..lo + 8].try_into().unwrap()); if target < n_rows { let t = target as usize; out[t / 64] |= 1u64 << (t % 64); @@ -2928,6 +3019,122 @@ mod tests { lgj_close(store); } + /// Minor 10, the columnar store through the WHOLE ABI surface: the same + /// logical content answers identically under both layouts — the pinned + /// 10 → 19 → 29 hop, eq-classid counts, facet-match bitsets and the + /// native slot count — while the register-sweep family refuses with the + /// LAYOUT status instead of reading a register that is no longer + /// contiguous. A layout is a schema; the answers belong to the content. + #[test] + #[cfg(not(feature = "ogar-classview"))] + fn columnar_store_answers_identically_and_register_sweeps_refuse() { + let n = 2000u64; + let aos = rowstore_with_edges(n, 0xF00D_CAFE, 0, 0x0, 25); + let mut col_h = 0u64; + assert_eq!( + unsafe { lgj_rowstore_open_columnar(n, 0xF00D_CAFE, 0, 0x0, 25, &mut col_h) }, + LGJ_OK + ); + + // The pinned hop, on the columnar store, via the SAME export. + let seed_rows: Vec = (0..10u64).map(|i| i * 37 + 5).collect(); + for (store, label) in [(aos, "aos"), (col_h, "columnar")] { + let src = mask(store, LGJ_MASK_INIT_EMPTY); + let dst1 = mask(store, LGJ_MASK_INIT_EMPTY); + let dst2 = mask(store, LGJ_MASK_INIT_EMPTY); + set_rows(src, &seed_rows); + assert_eq!( + lgj_hop(store, 0, 0xFFFF_FFFF, 0, src, dst1), + LGJ_OK, + "{label}" + ); + assert_eq!(count(dst1), 19, "{label}: 1-hop"); + assert_eq!( + lgj_hop(store, 0, 0xFFFF_FFFF, 0, dst1, dst2), + LGJ_OK, + "{label}" + ); + assert_eq!(count(dst2), 29, "{label}: 2-hop"); + lgj_close(dst2); + lgj_close(dst1); + lgj_close(src); + } + + // eq-classid per facet: identical counts (the contiguous stride-4 lane + // and the stride-512 lane select the same rows). + for facet in [0u32, 7, 31] { + for needle in [0u32, 9, 15] { + let ma = mask(aos, LGJ_MASK_INIT_EMPTY); + let mc = mask(col_h, LGJ_MASK_INIT_EMPTY); + assert_eq!(lgj_op_eq_classid(aos, facet, needle, ma), LGJ_OK); + assert_eq!(lgj_op_eq_classid(col_h, facet, needle, mc), LGJ_OK); + assert_eq!(count(ma), count(mc), "facet {facet} needle {needle}"); + assert!( + count(ma) > 0, + "vacuity guard: facet {facet} needle {needle}" + ); + lgj_close(mc); + lgj_close(ma); + } + } + + // facet-match bitsets: byte-identical buffers, and the native count + // agrees on both. + let mut buf_a = vec![0u32; n as usize]; + let mut buf_c = vec![0u32; n as usize]; + assert_eq!( + unsafe { lgj_row_facet_match(aos, 9, buf_a.as_mut_ptr(), n) }, + LGJ_OK + ); + assert_eq!( + unsafe { lgj_row_facet_match(col_h, 9, buf_c.as_mut_ptr(), n) }, + LGJ_OK + ); + assert_eq!(buf_a, buf_c); + let (mut ca, mut cc) = (0u64, 0u64); + assert_eq!( + unsafe { lgj_rowstore_facet_match_count(aos, 9, &mut ca) }, + LGJ_OK + ); + assert_eq!( + unsafe { lgj_rowstore_facet_match_count(col_h, 9, &mut cc) }, + LGJ_OK + ); + assert_eq!(ca, cc); + assert!(ca > 0); + + // The register-sweep family REFUSES on columnar — the deferral is a + // status, never a silently wrong sum over scrambled bytes. + let m = mask(col_h, LGJ_MASK_INIT_ALL); + let mut sum = 0i64; + assert_eq!( + unsafe { lgj_reduce_facet_sum(col_h, 0, 0, m, &mut sum) }, + LGJ_ERR_UNSUPPORTED_LAYOUT + ); + let mut carving_out = 0u32; + assert_eq!( + unsafe { lgj_reduce_facet_sum_resolved(col_h, 0, m, &mut sum, &mut carving_out) }, + LGJ_ERR_UNSUPPORTED_LAYOUT + ); + let mut probe_out = vec![0u8; 32]; + assert_eq!( + unsafe { lgj_row_layout_probe(col_h, m, probe_out.as_mut_ptr(), 32) }, + LGJ_ERR_UNSUPPORTED_LAYOUT + ); + // …and the SAME calls succeed on AoS (the gate discriminates by + // layout, not by rejecting everything). + let ma = mask(aos, LGJ_MASK_INIT_ALL); + assert_eq!( + unsafe { lgj_reduce_facet_sum(aos, 0, 0, ma, &mut sum) }, + LGJ_OK + ); + lgj_close(ma); + lgj_close(m); + + lgj_close(col_h); + lgj_close(aos); + } + #[test] fn hop_with_empty_facet_mask_yields_an_empty_dst() { let n = 2000u64; diff --git a/native/lgj-abi/src/kernels.rs b/native/lgj-abi/src/kernels.rs index 682de37..7a3795f 100644 --- a/native/lgj-abi/src/kernels.rs +++ b/native/lgj-abi/src/kernels.rs @@ -142,6 +142,7 @@ pub fn simd_popcount(words: &[u64]) -> u64 { pub fn simd_rowstore_u32_eq_mask( bytes: &[u8], first_offset: usize, + stride_bytes: usize, n_rows: usize, needle: u32, out_words: &mut [u64], @@ -149,7 +150,7 @@ pub fn simd_rowstore_u32_eq_mask( ndarray::simd::eq_u32_strided_to_mask( bytes, first_offset, - crate::rowstore::ROW_BYTES as usize, + stride_bytes, n_rows, needle, out_words, @@ -164,11 +165,12 @@ pub fn simd_rowstore_u32_eq_mask( pub fn simd_rowstore_classid_mask( bytes: &[u8], first_offset: usize, + stride_bytes: usize, n_rows: usize, needle: u32, out_words: &mut [u64], ) { - simd_rowstore_u32_eq_mask(bytes, first_offset, n_rows, needle, out_words); + simd_rowstore_u32_eq_mask(bytes, first_offset, stride_bytes, n_rows, needle, out_words); } /// Per-row facet-match: `out[row]` gets bit `f` set iff facet `f`'s classid @@ -858,6 +860,7 @@ mod tests { simd_rowstore_classid_mask( &bytes, first_offset, + crate::rowstore::ROW_BYTES as usize, n as usize, needle, &mut a, diff --git a/native/lgj-abi/src/lib.rs b/native/lgj-abi/src/lib.rs index eeaf381..2e719d7 100644 --- a/native/lgj-abi/src/lib.rs +++ b/native/lgj-abi/src/lib.rs @@ -264,7 +264,10 @@ mod integration_tests { let mut info = LgjResourceInfo::default(); assert_eq!(call::resource_info(s, &mut info), LGJ_OK); assert_eq!(info.kind, LGJ_RESOURCE_ROWSTORE); - assert_eq!(info.lane_count, 33); + // 33 → 97 at minor 10: 32 payload-lo64 + 32 payload-hi32 lanes joined + // the served schema so a consumer can read EVERY field through a + // descriptor instead of hand-computing offsets (E3, both layouts). + assert_eq!(info.lane_count, 97); assert_eq!(info.n_rows, n); // Raw lane: contiguous U8, exactly n*512 bytes. @@ -284,8 +287,13 @@ mod integration_tests { assert_eq!(d.stride_bytes, 512); assert_eq!(d.byte_len, (n - 1) * 512 + 4); assert_eq!(d.flags & LGJ_FLAG_CONTIGUOUS, 0); - // Lane 34 does not exist (1 raw + 32 facets = ids 0..=32). - assert_eq!(call::lane_describe(s, 34, &mut d), LGJ_ERR_INVALID_LANE); + // Lane 34 is now facet 1's payload-lo64 lane (minor 10 grew the + // served table to 97: raw + classid + lo64 + hi32). Re-pinned as + // contrast: it EXISTS with U64 kind, and the first id past the table + // (97) is the one that must reject. + assert_eq!(call::lane_describe(s, 34, &mut d), LGJ_OK); + assert_eq!(d.elem_kind, LgjElemKind::U64 as u32); + assert_eq!(call::lane_describe(s, 97, &mut d), LGJ_ERR_INVALID_LANE); // classid predicate on facet 7 → an ordinary mask, counted natively… let m = mask(s, LGJ_MASK_INIT_EMPTY); diff --git a/native/lgj-abi/src/registry.rs b/native/lgj-abi/src/registry.rs index 3671e92..8e59d02 100644 --- a/native/lgj-abi/src/registry.rs +++ b/native/lgj-abi/src/registry.rs @@ -383,9 +383,35 @@ pub fn open_rowstore_with_edges( edge_gate_mask: u64, edge_radius: u32, ) -> Result { - let store = - RowStore::generate_with_edges(n_rows, seed, edge_classid, edge_gate_mask, edge_radius) - .ok_or(LGJ_ERR_LENGTH_OVERFLOW)?; + open_rowstore_with_edges_in( + n_rows, + seed, + edge_classid, + edge_gate_mask, + edge_radius, + crate::rowstore::RowLayout::AosRows, + ) +} + +/// [`open_rowstore_with_edges`] under an explicit layout (minor 10's columnar +/// constructor routes here with [`crate::rowstore::RowLayout::FacetMajor`]). +pub fn open_rowstore_with_edges_in( + n_rows: u64, + seed: u64, + edge_classid: u32, + edge_gate_mask: u64, + edge_radius: u32, + layout: crate::rowstore::RowLayout, +) -> Result { + let store = RowStore::generate_with_edges_in( + n_rows, + seed, + edge_classid, + edge_gate_mask, + edge_radius, + layout, + ) + .ok_or(LGJ_ERR_LENGTH_OVERFLOW)?; insert(ResourceEntry { kind: LGJ_RESOURCE_ROWSTORE, epoch: next_epoch(), diff --git a/native/lgj-abi/src/rowstore.rs b/native/lgj-abi/src/rowstore.rs index 64514f6..b486a57 100644 --- a/native/lgj-abi/src/rowstore.rs +++ b/native/lgj-abi/src/rowstore.rs @@ -60,8 +60,12 @@ pub const ROWSTORE_CLASS_CARDINALITY: u64 = 16; pub const LANE_RAW: u32 = 0; /// Lane id of facet `f`'s classid lane is `LANE_FACET_BASE + f`. pub const LANE_FACET_BASE: u32 = 1; -/// Total describable lanes: 1 raw + 32 facet classid lanes. -pub const ROWSTORE_LANE_COUNT: u32 = 1 + ROW_FACETS; +/// Lane id of facet `f`'s payload-low64 lane is `LANE_LO64_BASE + f`. +pub const LANE_LO64_BASE: u32 = LANE_FACET_BASE + ROW_FACETS; +/// Lane id of facet `f`'s payload-hi32 lane is `LANE_HI32_BASE + f`. +pub const LANE_HI32_BASE: u32 = LANE_LO64_BASE + ROW_FACETS; +/// Total describable lanes: 1 raw + 32 classid + 32 lo64 + 32 hi32. +pub const ROWSTORE_LANE_COUNT: u32 = 1 + 3 * ROW_FACETS; use std::sync::Arc; @@ -94,9 +98,116 @@ pub struct RowStore { pub n_rows: u64, /// The seed the buffer was generated from. pub seed: u64, + /// How the 512 bytes per row are ARRANGED in `bytes`. The logical content + /// — 32 facets of `classid(4) + payload(12)` per row, same generator, same + /// draws — is identical under both; only the addresses differ. A layout is + /// a SCHEMA over the same bytes (R11), never a second store kind. + pub layout: RowLayout, bytes: Arc<[u8]>, } +/// The two physical arrangements of the same `n_rows × 32 × (4+12)` content. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RowLayout { + /// Row-major: row `r` is 512 contiguous bytes, facet `f` at `r*512 + f*16` + /// (`classid` at `+0`, payload low64 at `+4`, payload hi32 at `+12`). + /// Whole-row reads are contiguous; any single-field sweep is stride 512. + AosRows, + /// Facet-major columnar: three field regions, each split into 32 + /// contiguous per-facet blocks — + /// + /// ```text + /// [0 .. 128n) classid facet f at 0 + f*4n, stride 4 + /// [128n .. 384n) lo64 facet f at 128n + f*8n, stride 8 + /// [384n .. 512n) hi32 facet f at 384n + f*4n, stride 4 + /// ``` + /// + /// Every single-field sweep is CONTIGUOUS — which is what makes the hop's + /// mask algebra run at canvas speed instead of paying 64 stride-512 + /// passes (measured 19× against it; `.claude/board/` + /// `hop-mask-algebra-vs-columnar.txt`). Every region and per-facet block + /// offset is a multiple of 64 for ANY `n_rows` (128, 384 and 512 are all + /// multiples of 64, and blocks are `4n`/`8n` from 64-multiple bases with + /// the same divisibility), so the alignment story is the base pointer's + /// alone — same honest `u8`-aligned statement as AoS, and the kernels use + /// unaligned loads either way. + FacetMajor, +} + +impl RowLayout { + /// `(first_offset, stride_bytes)` of facet `facet`'s classid lane — the + /// pair every strided kernel consumes. ONE source for the lane geometry; + /// `lane_raw` serves the same numbers over the ABI. + #[inline] + pub fn classid_lane(self, n_rows: u64, facet: u32) -> (usize, usize) { + match self { + RowLayout::AosRows => ( + (u64::from(facet) * FACET_BYTES) as usize, + ROW_BYTES as usize, + ), + RowLayout::FacetMajor => ((u64::from(facet) * 4 * n_rows) as usize, 4), + } + } + + /// `(first_offset, stride_bytes)` of facet `facet`'s payload-lo64 lane. + #[inline] + pub fn lo64_lane(self, n_rows: u64, facet: u32) -> (usize, usize) { + match self { + RowLayout::AosRows => ( + (u64::from(facet) * FACET_BYTES + 4) as usize, + ROW_BYTES as usize, + ), + RowLayout::FacetMajor => ((128 * n_rows + u64::from(facet) * 8 * n_rows) as usize, 8), + } + } + + /// `(first_offset, stride_bytes)` of facet `facet`'s payload-hi32 lane. + #[inline] + pub fn hi32_lane(self, n_rows: u64, facet: u32) -> (usize, usize) { + match self { + RowLayout::AosRows => ( + (u64::from(facet) * FACET_BYTES + FACET_PAYLOAD_HI32_OFFSET) as usize, + ROW_BYTES as usize, + ), + RowLayout::FacetMajor => ((384 * n_rows + u64::from(facet) * 4 * n_rows) as usize, 4), + } + } + + /// Byte offset of `(row, facet)`'s classid under this layout. + #[inline] + pub fn classid_offset(self, n_rows: u64, row: u64, facet: u32) -> usize { + match self { + RowLayout::AosRows => (row * ROW_BYTES + u64::from(facet) * FACET_BYTES) as usize, + RowLayout::FacetMajor => (u64::from(facet) * 4 * n_rows + row * 4) as usize, + } + } + + /// Byte offset of `(row, facet)`'s payload low 64 bits under this layout. + #[inline] + pub fn lo64_offset(self, n_rows: u64, row: u64, facet: u32) -> usize { + match self { + RowLayout::AosRows => (row * ROW_BYTES + u64::from(facet) * FACET_BYTES + 4) as usize, + RowLayout::FacetMajor => { + (128 * n_rows + u64::from(facet) * 8 * n_rows + row * 8) as usize + } + } + } + + /// Byte offset of `(row, facet)`'s payload high 32 bits under this layout. + #[inline] + pub fn hi32_offset(self, n_rows: u64, row: u64, facet: u32) -> usize { + match self { + RowLayout::AosRows => { + (row * ROW_BYTES + u64::from(facet) * FACET_BYTES + FACET_PAYLOAD_HI32_OFFSET) + as usize + } + RowLayout::FacetMajor => { + (384 * n_rows + u64::from(facet) * 4 * n_rows + row * 4) as usize + } + } + } +} + impl std::fmt::Debug for RowStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RowStore") @@ -112,6 +223,12 @@ impl RowStore { /// Returns `None` if `n_rows * 512` overflows or cannot be allocated /// (the caller maps that to `LENGTH_OVERFLOW` / `ALLOCATION_FAILED`). pub fn generate(n_rows: u64, seed: u64) -> Option { + Self::generate_in(n_rows, seed, RowLayout::AosRows) + } + + /// [`Self::generate`] under an explicit [`RowLayout`]. Same draws, same + /// logical content — pinned by `layouts_hold_identical_logical_content`. + pub fn generate_in(n_rows: u64, seed: u64, layout: RowLayout) -> Option { let n = usize::try_from(n_rows).ok()?; let byte_len = n.checked_mul(ROW_BYTES as usize)?; @@ -124,17 +241,20 @@ impl RowStore { for facet in 0..ROW_FACETS as usize { let a = rng.next_u64(); let b = rng.next_u64(); - let base = row * ROW_BYTES as usize + facet * FACET_BYTES as usize; let classid = ((a >> 33) & (ROWSTORE_CLASS_CARDINALITY - 1)) as u32; - bytes[base..base + 4].copy_from_slice(&classid.to_le_bytes()); - bytes[base + 4..base + 12].copy_from_slice(&b.to_le_bytes()); - bytes[base + 12..base + 16].copy_from_slice(&(a as u32).to_le_bytes()); + let co = layout.classid_offset(n_rows, row as u64, facet as u32); + let lo = layout.lo64_offset(n_rows, row as u64, facet as u32); + let hi = layout.hi32_offset(n_rows, row as u64, facet as u32); + bytes[co..co + 4].copy_from_slice(&classid.to_le_bytes()); + bytes[lo..lo + 8].copy_from_slice(&b.to_le_bytes()); + bytes[hi..hi + 4].copy_from_slice(&(a as u32).to_le_bytes()); } } Some(Self { n_rows, seed, + layout, bytes: Arc::from(bytes), }) } @@ -192,6 +312,26 @@ impl RowStore { edge_classid: u32, edge_gate_mask: u64, edge_radius: u32, + ) -> Option { + Self::generate_with_edges_in( + n_rows, + seed, + edge_classid, + edge_gate_mask, + edge_radius, + RowLayout::AosRows, + ) + } + + /// [`Self::generate_with_edges`] under an explicit [`RowLayout`]. Same + /// draws, same logical content, same pinned 10 → 19 → 29 hop counts. + pub fn generate_with_edges_in( + n_rows: u64, + seed: u64, + edge_classid: u32, + edge_gate_mask: u64, + edge_radius: u32, + layout: RowLayout, ) -> Option { let n = usize::try_from(n_rows).ok()?; if n == 0 || u64::from(edge_radius) >= n_rows { @@ -208,7 +348,6 @@ impl RowStore { for facet in 0..ROW_FACETS as usize { let a = rng.next_u64(); let b = rng.next_u64(); - let base = row * ROW_BYTES as usize + facet * FACET_BYTES as usize; let classid = ((a >> 33) & (ROWSTORE_CLASS_CARDINALITY - 1)) as u32; let (payload_lo64, payload_hi32) = @@ -221,15 +360,19 @@ impl RowStore { (b, a as u32) }; - bytes[base..base + 4].copy_from_slice(&classid.to_le_bytes()); - bytes[base + 4..base + 12].copy_from_slice(&payload_lo64.to_le_bytes()); - bytes[base + 12..base + 16].copy_from_slice(&payload_hi32.to_le_bytes()); + let co = layout.classid_offset(n_rows, row as u64, facet as u32); + let lo = layout.lo64_offset(n_rows, row as u64, facet as u32); + let hi = layout.hi32_offset(n_rows, row as u64, facet as u32); + bytes[co..co + 4].copy_from_slice(&classid.to_le_bytes()); + bytes[lo..lo + 8].copy_from_slice(&payload_lo64.to_le_bytes()); + bytes[hi..hi + 4].copy_from_slice(&payload_hi32.to_le_bytes()); } } Some(Self { n_rows, seed, + layout, bytes: Arc::from(bytes), }) } @@ -250,13 +393,20 @@ impl RowStore { /// read, used by tests and the scalar reference kernels. Bulk access goes /// through the lanes, never through a loop over this. pub fn classid_at(&self, row: u64, facet: u32) -> u32 { - let base = (row * ROW_BYTES + facet as u64 * FACET_BYTES) as usize; - u32::from_le_bytes([ - self.bytes[base], - self.bytes[base + 1], - self.bytes[base + 2], - self.bytes[base + 3], - ]) + let base = self.layout.classid_offset(self.n_rows, row, facet); + u32::from_le_bytes(self.bytes[base..base + 4].try_into().unwrap()) + } + + /// The payload's low 64 bits at `(row, facet)` — layout-aware scalar read. + pub fn payload_lo64_at(&self, row: u64, facet: u32) -> u64 { + let base = self.layout.lo64_offset(self.n_rows, row, facet); + u64::from_le_bytes(self.bytes[base..base + 8].try_into().unwrap()) + } + + /// The payload's high 32 bits at `(row, facet)` — layout-aware scalar read. + pub fn payload_hi32_at(&self, row: u64, facet: u32) -> u32 { + let base = self.layout.hi32_offset(self.n_rows, row, facet); + u32::from_le_bytes(self.bytes[base..base + 4].try_into().unwrap()) } /// `(base address, len_elems, elem_kind, stride_bytes, contiguous)` for a @@ -272,13 +422,46 @@ impl RowStore { true, )); } - let facet = lane_id.checked_sub(LANE_FACET_BASE)?; - if facet >= ROW_FACETS { - return None; + // The lane table is the SCHEMA SERVED (R11: layout is data): a + // consumer reads through (addr, stride) and never hand-computes an + // offset, so the same lane id answers correctly under EITHER layout — + // only the numbers in the descriptor differ. + let base = self.bytes.as_ptr() as u64; + let n = self.n_rows; + if let Some(facet) = lane_id.checked_sub(LANE_FACET_BASE) { + if facet < ROW_FACETS { + let (off, stride, contig) = match self.layout { + RowLayout::AosRows => (u64::from(facet) * FACET_BYTES, ROW_BYTES as u32, false), + RowLayout::FacetMajor => (u64::from(facet) * 4 * n, 4u32, true), + }; + return Some((base + off, n, LgjElemKind::U32, stride, contig)); + } + } + if let Some(facet) = lane_id.checked_sub(LANE_LO64_BASE) { + if facet < ROW_FACETS { + let (off, stride, contig) = match self.layout { + RowLayout::AosRows => { + (u64::from(facet) * FACET_BYTES + 4, ROW_BYTES as u32, false) + } + RowLayout::FacetMajor => (128 * n + u64::from(facet) * 8 * n, 8u32, true), + }; + return Some((base + off, n, LgjElemKind::U64, stride, contig)); + } } - // Classid lane f: strided u32 column at first_offset f*16, stride 512. - let addr = self.bytes.as_ptr() as u64 + facet as u64 * FACET_BYTES; - Some((addr, self.n_rows, LgjElemKind::U32, ROW_BYTES as u32, false)) + if let Some(facet) = lane_id.checked_sub(LANE_HI32_BASE) { + if facet < ROW_FACETS { + let (off, stride, contig) = match self.layout { + RowLayout::AosRows => ( + u64::from(facet) * FACET_BYTES + FACET_PAYLOAD_HI32_OFFSET, + ROW_BYTES as u32, + false, + ), + RowLayout::FacetMajor => (384 * n + u64::from(facet) * 4 * n, 4u32, true), + }; + return Some((base + off, n, LgjElemKind::U32, stride, contig)); + } + } + None } } @@ -286,6 +469,66 @@ impl RowStore { mod tests { use super::*; + /// The columnar store is the SAME logical content as AoS — every + /// `(row, facet)` field identical under both layouts, same seed, same + /// draws. This is what licenses every layout-aware op to answer + /// identically: the bytes moved, the content did not. + #[test] + fn layouts_hold_identical_logical_content() { + let n = 257u64; // deliberately NOT a power of two + let a = RowStore::generate_with_edges(n, 0xF00D_CAFE, 3, 0x1, 9).unwrap(); + let c = RowStore::generate_with_edges_in(n, 0xF00D_CAFE, 3, 0x1, 9, RowLayout::FacetMajor) + .unwrap(); + assert_eq!(a.layout, RowLayout::AosRows); + assert_eq!(c.layout, RowLayout::FacetMajor); + // Same total bytes — 512 per row, either arrangement. + assert_eq!(a.as_bytes().len(), c.as_bytes().len()); + // The BYTES differ (anti-vacuity: a no-op "columnar" that kept AoS + // order would pass every content check below). + assert_ne!(a.as_bytes(), c.as_bytes()); + for row in [0u64, 1, 63, 64, 128, n - 1] { + for facet in 0..ROW_FACETS { + assert_eq!(a.classid_at(row, facet), c.classid_at(row, facet)); + assert_eq!(a.payload_lo64_at(row, facet), c.payload_lo64_at(row, facet)); + assert_eq!(a.payload_hi32_at(row, facet), c.payload_hi32_at(row, facet)); + } + } + } + + /// The three carvings' largest group is ≤ 4 bytes — HALF the JEP 401 + /// flattening budget — and the 512-byte row stride plus every FacetMajor + /// region/block offset is 64-byte aligned for ANY n_rows. R4/R10 measured + /// the Valhalla half (a ≤4-byte GROUP flattens, the 12-byte register never + /// does); these are the substrate-side halves of the same contract, pinned + /// here so a carving or layout change cannot silently break either. + #[test] + fn carving_groups_fit_the_flattening_budget_and_the_layout_is_64_aligned() { + use lance_graph_contract::facet::CascadeShape; + for shape in CascadeShape::ROTATIONS { + let gb = 12 / shape.groups() as u64; + assert!(gb <= 4, "{shape:?}: group_bytes {gb} > 4"); + assert_eq!(gb * shape.groups() as u64, 12, "{shape:?} must tile 12"); + } + assert_eq!(ROW_BYTES % 64, 0, "512-byte row stride is 64-aligned"); + // FacetMajor: region bases 0 / 128n / 384n and per-facet block starts + // (f*4n, 128n + f*8n, 384n + f*4n) are 64-multiples for ANY n — the + // factors 128, 384, 4 and 8 against n… 4n and 8n are NOT always + // 64-multiples for arbitrary n, so this pins the REGION bases (always) + // and the block claim for the mask-word-quantised n the ABI actually + // serves (n padded to 64-row mask words ⇒ 4n ≡ 0 (mod 256)). + for n in [64u64, 192, 1000, 4096] { + assert_eq!((128 * n) % 64, 0); + assert_eq!((384 * n) % 64, 0); + } + for n in [64u64, 128, 4096] { + for f in [0u64, 1, 31] { + assert_eq!((f * 4 * n) % 64, 0); + assert_eq!((128 * n + f * 8 * n) % 64, 0); + assert_eq!((384 * n + f * 4 * n) % 64, 0); + } + } + } + #[test] fn generation_is_deterministic_and_seed_sensitive() { let a = RowStore::generate(64, 42).unwrap(); @@ -349,7 +592,27 @@ mod tests { assert_eq!(stride, ROW_BYTES as u32); assert!(!contig); } - assert!(s.lane_raw(LANE_FACET_BASE + ROW_FACETS).is_none()); + // Minor 10: the table CONTINUES past the classid lanes — payload + // lo64/hi32 lanes, then nothing. Re-pinned as contrast, not widened. + for f in 0..ROW_FACETS { + let (addr, len, kind, stride, contig) = s.lane_raw(LANE_LO64_BASE + f).unwrap(); + assert_eq!( + addr, + s.as_bytes().as_ptr() as u64 + f as u64 * FACET_BYTES + 4 + ); + assert_eq!(len, 16); + assert_eq!(kind, crate::abi::LgjElemKind::U64); + assert_eq!(stride, ROW_BYTES as u32); + assert!(!contig); + let (addr, _, kind, stride, _) = s.lane_raw(LANE_HI32_BASE + f).unwrap(); + assert_eq!( + addr, + s.as_bytes().as_ptr() as u64 + f as u64 * FACET_BYTES + FACET_PAYLOAD_HI32_OFFSET + ); + assert_eq!(kind, crate::abi::LgjElemKind::U32); + assert_eq!(stride, ROW_BYTES as u32); + } + assert!(s.lane_raw(LANE_HI32_BASE + ROW_FACETS).is_none()); assert!(s.lane_raw(u32::MAX).is_none()); }