From 183745ef39202304281753660da618e934014d7b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:59:11 +0000 Subject: [PATCH 1/3] lgj-abi: map the new mask-risc ExecError variants to existing status codes Foreign/ForeignLane out-of-range and kind, and TerminalNeedsOut, land on the codes their Planes-side twins already use. No new codes, no ABI bump. Depends on lance-graph #1256 (the variants live there). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG --- native/lgj-abi/src/exports.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/native/lgj-abi/src/exports.rs b/native/lgj-abi/src/exports.rs index 14bcdbe..6f7af80 100644 --- a/native/lgj-abi/src/exports.rs +++ b/native/lgj-abi/src/exports.rs @@ -1734,7 +1734,11 @@ fn exec_error_to_status(e: ExecError) -> i32 { ExecError::LaneOutOfRange(_) => LGJ_ERR_INVALID_LANE, ExecError::LaneKind { .. } => LGJ_ERR_LANE_KIND_MISMATCH, ExecError::LenMismatch { .. } => LGJ_ERR_MASK_LENGTH_MISMATCH, - ExecError::PlaneOutOfRange(_) | ExecError::PlaneTail(_) => LGJ_ERR_INVALID_HANDLE, + ExecError::PlaneOutOfRange(_) + | ExecError::PlaneTail(_) + | ExecError::ForeignOutOfRange(_) => LGJ_ERR_INVALID_HANDLE, + ExecError::ForeignLaneOutOfRange(_) => LGJ_ERR_INVALID_LANE, + ExecError::ForeignLaneKind { .. } => LGJ_ERR_LANE_KIND_MISMATCH, ExecError::SumRowBound { .. } => LGJ_ERR_SUM_OVERFLOW, ExecError::ScratchTooSmall { .. } | ExecError::ScratchBufferTooSmall { .. } @@ -1743,6 +1747,7 @@ fn exec_error_to_status(e: ExecError) -> i32 { | ExecError::ScratchReadBeforeWrite { .. } | ExecError::ScratchWords { .. } | ExecError::BlendNeedsOut + | ExecError::TerminalNeedsOut { .. } | ExecError::GateAliasesDst { .. } | ExecError::RangeOutOfBounds { .. } => LGJ_ERR_ALLOCATION_FAILED, } From a64fb70f822d334606dc7f3f4a3a0c9e720c5c8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 21:33:30 +0000 Subject: [PATCH 2/3] lgj-abi: plan_eval runs tiled and keeps the mask straight into the destination resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destination mask is the demanded result, so the lowering ends in Keep and the executor writes it tile by tile into the resource's own words (Out::Mask); the count is a popcount over that sink. The scratch is SLOTS × tile_words_for(rows) — no population-sized scratch, no copy from a slot into the resource. The scalar oracle arm answers the same way through reference_execute_into. Validation stays total before the first tile, so an error leaves dst_mask untouched as before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG --- native/lgj-abi/src/exports.rs | 64 +++++++++++-------- .../src/exports/tests/lowering_convergence.rs | 20 ++++-- native/lgj-abi/src/plan_lower.rs | 6 +- 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/native/lgj-abi/src/exports.rs b/native/lgj-abi/src/exports.rs index 6f7af80..269ddee 100644 --- a/native/lgj-abi/src/exports.rs +++ b/native/lgj-abi/src/exports.rs @@ -26,8 +26,8 @@ use std::cell::RefCell; use std::panic::{catch_unwind, AssertUnwindSafe}; use lance_graph_mask_risc::{ - execute, reference_execute, reference_scratch, scratch_words_for, ExecError, LaneRef, Planes, - Scratch, Value, + execute_into, reference_execute_into, scratch_words_for, tile_words_for, ExecError, Foreign, + LaneRef, Out, Planes, Scratch, Value, }; use crate::abi::*; @@ -1869,33 +1869,53 @@ fn plan_eval_impl( match path { Path::Simd => { - let need = match scratch_words_for(n_words, plan_lower::SLOTS as usize) { + // TILED: the scratch is `SLOTS × tile_words_for(rows)` words + // however many rows the resource holds, and the kept mask is + // written tile by tile into the destination resource's own words + // — the demanded sink, and the only population-sized write. + // Validation is total before the first tile, so an error leaves + // `dst_mask` byte-for-byte as it was, exactly as before. + let tile = tile_words_for(rows); + let need = match scratch_words_for(tile, plan_lower::SLOTS as usize) { Some(n) => n, None => return LGJ_ERR_LENGTH_OVERFLOW, }; + let mut g = match mask.write_mask() { + Some(g) => g, + None => return LGJ_ERR_WRONG_RESOURCE_KIND, + }; + if g.words.len() != n_words { + return LGJ_ERR_MASK_LENGTH_MISMATCH; + } PLAN_SCRATCH.with(|cell| { let mut buf = cell.borrow_mut(); if buf.len() < need { buf.resize(need, 0); } let mut scratch = - match Scratch::over(&mut buf[..need], n_words, plan_lower::SLOTS as usize) { + match Scratch::over(&mut buf[..need], tile, plan_lower::SLOTS as usize) { Ok(s) => s, Err(e) => return exec_error_to_status(e), }; - let count = match execute(&program, &planes, &mut scratch, None) { - Ok(Value::Count(c)) => c as u64, + match execute_into( + &program, + &planes, + &Foreign::NONE, + &mut scratch, + Out::Mask(&mut g.words), + ) { + Ok(Value::Mask(_)) => {} // The lowering emits exactly one terminal and it is - // `Count`; any other value means this file built a + // `Keep`; any other value means this file built a // program it did not intend to. Ok(_) => return LGJ_ERR_ALLOCATION_FAILED, Err(e) => return exec_error_to_status(e), - }; - let words = match scratch.slot(plan_lower::ACC_SLOT) { - Some(w) => w, - None => return LGJ_ERR_ALLOCATION_FAILED, - }; - publish(&mask, words, count, out_count) + } + let count = kernels::simd_popcount(&g.words); + drop(g); + // SAFETY: non-null (checked by the caller); written only on success. + unsafe { *out_count = count }; + LGJ_OK }) } // Fork A: the scalar symbol runs mask-risc's row-at-a-time oracle, so @@ -1905,20 +1925,14 @@ fn plan_eval_impl( // bit-packing bug — which is why the allocation gate names // `lgj_plan_eval` and only it. Path::Scalar => { - let count = match reference_execute(&program, &planes, None) { - Ok(Value::Count(c)) => c as u64, + let mut kept = vec![0u64; n_words]; + match reference_execute_into(&program, &planes, &Foreign::NONE, Out::Mask(&mut kept)) { + Ok(Value::Mask(_)) => {} Ok(_) => return LGJ_ERR_ALLOCATION_FAILED, Err(e) => return exec_error_to_status(e), - }; - let slots = match reference_scratch(&program, &planes) { - Ok(s) => s, - Err(e) => return exec_error_to_status(e), - }; - let words = match slots.get(plan_lower::ACC_SLOT as usize) { - Some(w) => w.as_slice(), - None => return LGJ_ERR_ALLOCATION_FAILED, - }; - publish(&mask, words, count, out_count) + } + let count = kernels::simd_popcount(&kept); + publish(&mask, &kept, count, out_count) } } } diff --git a/native/lgj-abi/src/exports/tests/lowering_convergence.rs b/native/lgj-abi/src/exports/tests/lowering_convergence.rs index f4a2631..b9bda78 100644 --- a/native/lgj-abi/src/exports/tests/lowering_convergence.rs +++ b/native/lgj-abi/src/exports/tests/lowering_convergence.rs @@ -156,12 +156,22 @@ fn cmp_of(o: &LgjOpDesc) -> Cmp { /// function on both sides is the whole point of comparing LOWERINGS rather /// than comparing EXECUTIONS. fn run(p: &Program, planes: &Planes) -> usize { - let words = planes.n_rows.div_ceil(64); - let slots = p.scratch_slots as usize; - let mut buf = vec![0u64; scratch_words_for(words, slots).expect("sized")]; - let mut scratch = Scratch::over(&mut buf, words, slots).expect("carves"); - match execute(p, planes, &mut scratch, None).expect("runs") { + // The tiled default scratch. This crate's own lowering ends in `Keep` + // (the destination mask is the demanded sink; the count is its + // popcount), the quack lowering in `Count` — both are read here. + let mut scratch = Scratch::for_program(p, planes.n_rows).expect("addressable"); + let mut kept = vec![0u64; planes.n_rows.div_ceil(64)]; + match execute_into( + p, + planes, + &Foreign::NONE, + &mut scratch, + Out::Mask(&mut kept), + ) + .expect("runs") + { Value::Count(c) => c, + Value::Mask(_) => ndarray::simd::popcount_batch_u64(&kept) as usize, other => panic!("not a count: {other:?}"), } } diff --git a/native/lgj-abi/src/plan_lower.rs b/native/lgj-abi/src/plan_lower.rs index 91607da..d1e3743 100644 --- a/native/lgj-abi/src/plan_lower.rs +++ b/native/lgj-abi/src/plan_lower.rs @@ -96,9 +96,13 @@ pub(crate) fn lower_plan(ops: &[LgjOpDesc]) -> Option { }); } + // `Keep`, not `Count`: the destination mask IS the demanded result, so + // the executor writes it tile by tile straight into the resource's own + // words (`Out::Mask`) and the count is a popcount over that sink. Under + // tiled execution no population-sized scratch exists to count from. Some(Lowered::Program(Program::new( program_ops, - Terminal::Count { + Terminal::Keep { mask: Operand::Scratch(ACC_SLOT), }, ))) From 4cae6da0733d35119f53989a40dd7e9357d5d17f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 22:21:35 +0000 Subject: [PATCH 3/3] lgj-abi: map ExecError::LaneNotOrdered to LGJ_ERR_LANE_KIND_MISMATCH mask-risc's `CountKeyRunsU32` now refuses a key lane out of key order; the ABI reports it as the lane-shape mismatch it is. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG --- native/lgj-abi/src/exports.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/native/lgj-abi/src/exports.rs b/native/lgj-abi/src/exports.rs index 269ddee..b2fd0a0 100644 --- a/native/lgj-abi/src/exports.rs +++ b/native/lgj-abi/src/exports.rs @@ -1733,6 +1733,9 @@ fn exec_error_to_status(e: ExecError) -> i32 { match e { ExecError::LaneOutOfRange(_) => LGJ_ERR_INVALID_LANE, ExecError::LaneKind { .. } => LGJ_ERR_LANE_KIND_MISMATCH, + // The lane is the right width but not in key order: a lane-shape + // precondition of the fold, reported as the same class of mismatch. + ExecError::LaneNotOrdered { .. } => LGJ_ERR_LANE_KIND_MISMATCH, ExecError::LenMismatch { .. } => LGJ_ERR_MASK_LENGTH_MISMATCH, ExecError::PlaneOutOfRange(_) | ExecError::PlaneTail(_)