From a79eb50be00d58902fafb066e8bb3f40092a842b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:12:53 +0000 Subject: [PATCH 1/9] board: #1245 merged (83369cad) -- arc entry + LATEST_STATE row Post-merge hygiene for the facet PEEK-chain revert. Hygiene-only: adds no type, plan, deliverable or epiphany, so per the termination clause it generates no arc entry of its own. Supersession index regenerated last, unchanged; citation-decay 0 new. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HScwwezRdMxFfTs3WLG19d --- .claude/board/LATEST_STATE.md | 16 +++++++++ .claude/board/PR_ARC_INVENTORY.md | 55 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index efd6cba4a..4f1bc7f05 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,19 @@ +## 2026-09-17 — PR #1245 merged (`83369cad`): the facet per-axis LCP is the `shared6` byte chain again, verbatim pre-#1241 — NO contract inventory delta + +`FacetCascade::{hi_distance, lo_distance}` are back to the by-value +`shared6` chain, byte-identical to `95e28637^`; #1241's masked `u128` +readout survives only as `masked_axis_oracle` under `#[cfg(test)]`. No +type added or removed; the public signature is exactly pre-#1241. Measured +in-tree (`examples/facet_axis_lcp_probe.rs`, oracle over every pair): chain +1.72 ns vs masked 3.64 on 64K random — #1241's ordering inverted on its own +carrier. Doctrine: `.claude/knowledge/three-prefix-fold-carriers.md` — +three prefix-fold carriers (bit-planes / nibble path / facet cascade), and +the rule *masking wins when the slice is granular, PEEK wins when the slice +is addressed*. The real masking opportunity is carrier 2 +(`NiblePath::common_prefix_depth`), filed as +`ISS-NIBLEPATH-FOLD-IS-CARRIER-2-UNMASKED`, gated on its own probe. Arc: +`PR_ARC_INVENTORY.md` 2026-09-17 #1245. + ## 2026-09-16 — PR #1241 merged (`d5d3f7ab`): the facet's per-axis LCP is one masked register readout — NO contract inventory delta `FacetCascade::hi_distance` / `lo_distance` now read diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 29346e9c1..f33b0580e 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -1,3 +1,58 @@ +## 2026-09-17 — lance-graph PR #1245 (merged `83369cad`, branch `claude/great-pascal-k96kok`) — the facet's per-axis LCP goes BACK to the `shared6` byte chain, verbatim; #1241's masked readout is the test oracle now; three prefix-fold carriers named + +- **Added:** `examples/facet_axis_lcp_probe.rs` — four-arm benchmark + (A chain PEEK / B pack-to-`u64` / C masked `u128` / D shared-load PEEK), + 64K pairs × 8 workloads, min of 7, oracle over EVERY pair before timing, + depth-knob and anti-vacuity gates that `assert!` (a flat depth-0→5 slope + fails the process). `.claude/knowledge/three-prefix-fold-carriers.md` + (doctrine); `.claude/plans/three-carrier-blast-radius-v1.md` (read-only + census, gates only); `ISS-NIBLEPATH-FOLD-IS-CARRIER-2-UNMASKED`; EPIPHANIES + (17) `E-THREE-CARRIERS-THREE-FOLDS-1` + partial strike appended to (16); + STATUS_BOARD D-TCF-1..5. 8 files, 5 commits. +- **Reversed (of #1241):** `shared_axis(xor, mask)` + `HI_BYTES`/`LO_BYTES` + are gone from the shipped path. `shared6([u8; 6], [u8; 6])` and the + by-value `hi_distance(self, other: Self)` / `lo_distance` are restored + **byte-identical to `95e28637^`** (diff-verified; the first revert commit + had shipped a semantically-equivalent respelling — `6e79c784` corrected + it). The falsifier is REVERSED, not deleted: the masked form lives on as + `masked_axis_oracle` under `#[cfg(test)]`, so the test compares shipped + against oracle rather than against a restatement of itself. Public API is + exactly pre-#1241. +- **Locked:** *masking wins when the slice is granular; PEEK wins when the + slice is addressed.* A measurement is a statement about + `(operation, carrier, workload)`; drop the carrier and it is a slogan. + Three carriers: bit-planes (`mailbox_soa` `&[u64]`, mask — untouched); + nibble path (`NiblePath`, packed `u64`, mask — NOT DONE, walks + nibble-by-nibble); facet cascade (`FacetCascade`, 6×2×8 byte-addressed, + PEEK — this PR). Before moving a fold, name its carrier. +- **Measured (64K random pairs, release, min of 7, both axes):** A chain + **1.72 ns** vs C masked 3.64 — the opposite of #1241's "12.5 → 5.8"; A + wins at every depth 0..5 and on identical; both pack-to-register arms are + slowest (the author's prediction that B would win was wrong). A's + depth-0→5 slope +81–111% across runs is the anti-vacuity evidence. + Disassembly: LLVM never materializes the `[u8; 6]` — `movzbl`/`cmp` + straight against memory/`jne`; arm C issues the same loads then + reassembles, `movabs` mask, two `tzcnt` + `cmove`, `−32`, no early exit. + *"The gather dominated"* is refuted. What #1241's 12.5 ns timed is + unknown (no harness shipped with it); entry (17) gives the carrier-2 + account as the strongest available and labels it so. +- **Deferred / named:** `as_u128()` is NOT a reinterpret — it rebuilds + through `to_bytes()` while `as_bytes()` (the documented no-op) sits unused + on that path; the E-carve (`{axis} × {near, far}`, stride 2) and the + fixed-width-register sentence for doctrine §1; carrier-2's masked rewrite + (gated on its own probe, D-TCF-4); the census passes (D-TCF-5). None + built. +- **Review:** Codex 2× P2 on the probe (oracle checked 256 of 65,536; the + anti-vacuity check warned and exited 0) — both real, both fixed in + `c3de1d2e`. Two board gates red on the first push — `D-3CF-*` does not + match the D-id pattern (two letters first) → `D-TCF-*`; two `ISSUES.md` + line-number citations → stable symbol anchors. CodeRabbit had not + finished when the operator merged. +- **Confidence:** HIGH on the numbers (in-tree, reproducible, oracle-first, + three independent runs same ordering). HIGH that the restore is verbatim + (diff). The three-carrier taxonomy is a reading of the tree offered as + explanation, not a proof of what #1241 timed. + ## 2026-09-16 — lance-graph PR #1241 (merged `d5d3f7ab`, branch `claude/c64-6502-falsifier-shztkk`) — the facet's per-axis LCP reads the single register; the `"{0}{1}" -f` fold is done ONCE at mint - **Added:** `FacetCascade::{HI_BYTES, LO_BYTES}` (const byte masks over the From c3931a1a216359255da6ab800a4e2c2e253c6b0c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:31:43 +0000 Subject: [PATCH 2/9] mask-risc: Pred::Range -- the contiguous-range write the IR could not name ndarray::simd::mask_set_range has been on the facade (simd_masking_ops.rs) while lance-graph-mask-risc's IR had no op that reaches it, so lance-graph-quack's Filter::prefix_u64 lowers an address prefix -- which on an address-ordered lane is one CONTIGUOUS range -- to a full ternary sweep, and its own doc says so: "NOT yet a range WRITE ... waits on the primitive". The primitive was there; the IR name was not. Pred::Range { lo: u32, hi: u32 } rows lo <= i < hi, reads no lane - exec: mask_set_range(dst, lo, hi); under a gate, then mask_and_assign. - reference: row-index predicate; pred_lane_and_kind returns Option so the one lane-free Pred is explicit at the validator, not special-cased. - value: ExecError::RangeOutOfBounds { lo, hi, n_rows } -- lo > hi or hi > n_rows refused by the validator both paths share, BEFORE any write, so mask_set_range's own asserts are unreachable and the oracle never indexes a row that does not exist. - The IR does not decide whether a lane is address-ordered; that is the planner's knowledge. Documented on the variant. Tests (tests/differential.rs), all executor-vs-oracle across the eight row counts incl. 0, 1, 63, 64, 65: - range_matches_the_oracle_at_every_word_edge: 15 (lo,hi) shapes per n (empty, single-word, straddling, 63/64/65-aligned, full), ungated / under a plane / under a scratch gate, values AND scratch words. - range_population_is_exactly_hi_minus_lo: == not >=, counted through the EXECUTOR (Fixture::count goes through the oracle -- a disable-run with an off-by-one in exec's Range arm PASSED the oracle-counted first draft, so the test now has its own exec_count). Silent half on a non-trivial input: a non-empty range under its own complement counts 0. - range_out_of_bounds_is_refused_identically: both paths return the same RangeOutOfBounds for lo > hi and hi > n_rows. quack (the one in-tree consumer) builds and its 14 tests pass; it constructs Pred, never matches it, so the variant is additive there. Not in this commit: lowering quack's prefix_u64 to Range -- that needs the planner to KNOW the lane is address-ordered, which quack cannot. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HScwwezRdMxFfTs3WLG19d --- crates/lance-graph-mask-risc/src/exec.rs | 15 +- crates/lance-graph-mask-risc/src/ir.rs | 22 +++ crates/lance-graph-mask-risc/src/reference.rs | 28 ++- crates/lance-graph-mask-risc/src/value.rs | 5 + .../tests/differential.rs | 173 ++++++++++++++++++ 5 files changed, 233 insertions(+), 10 deletions(-) diff --git a/crates/lance-graph-mask-risc/src/exec.rs b/crates/lance-graph-mask-risc/src/exec.rs index 70b7ea40a..374c63b4e 100644 --- a/crates/lance-graph-mask-risc/src/exec.rs +++ b/crates/lance-graph-mask-risc/src/exec.rs @@ -27,10 +27,10 @@ use ndarray::simd::{ ge_i32_to_mask, ge_i32_to_mask_under, gt_i32_to_mask, gt_i32_to_mask_under, le_i32_to_mask, le_i32_to_mask_under, lt_i32_to_mask, lt_i32_to_mask_under, mask_all, mask_and, mask_and_assign, mask_andnot, mask_andnot_assign, mask_any, mask_not, mask_not_assign, mask_or, - mask_or_assign, mask_xor, mask_xor_assign, masked_max_i32, masked_min_i32, masked_sum_i32, - ne_i32_to_mask, ne_i32_to_mask_under, ne_u32_to_mask, ne_u32_to_mask_under, popcount_batch_u64, - ternary_match_u32_to_mask, ternary_match_u32_to_mask_under, ternary_match_u64_to_mask, - ternary_match_u64_to_mask_under, + mask_or_assign, mask_set_range, mask_xor, mask_xor_assign, masked_max_i32, masked_min_i32, + masked_sum_i32, ne_i32_to_mask, ne_i32_to_mask_under, ne_u32_to_mask, ne_u32_to_mask_under, + popcount_batch_u64, ternary_match_u32_to_mask, ternary_match_u32_to_mask_under, + ternary_match_u64_to_mask, ternary_match_u64_to_mask_under, }; use crate::ir::{LaneRef, MaskOp, Operand, Planes, Pred, Program, Terminal, MAX_SCRATCH_SLOTS}; @@ -535,6 +535,13 @@ fn run_pred<'a>( read(planes, s, u), dst, ), + // `lo <= hi <= n_rows` was validated, so the bounds are in-range for + // the scratch words and `mask_set_range`'s own asserts cannot fire. + (Pred::Range { lo, hi }, None) => mask_set_range(dst, lo as usize, hi as usize), + (Pred::Range { lo, hi }, Some(u)) => { + mask_set_range(dst, lo as usize, hi as usize); + mask_and_assign(dst, read(planes, s, u)); + } } } diff --git a/crates/lance-graph-mask-risc/src/ir.rs b/crates/lance-graph-mask-risc/src/ir.rs index 6f362f419..0917dfc3d 100644 --- a/crates/lance-graph-mask-risc/src/ir.rs +++ b/crates/lance-graph-mask-risc/src/ir.rs @@ -80,6 +80,28 @@ pub enum Pred { MatchU32 { lane: u16, pattern: u32, care: u32 }, /// `((lane[i] ^ pattern) & care) == 0` over a `u64` lane. MatchU64 { lane: u16, pattern: u64, care: u64 }, + /// `lo <= i < hi` — a predicate on the ROW INDEX, reading no lane. + /// + /// The contiguous-range write. On an address-ordered lane an address + /// prefix names a contiguous subtree, so the prefix's mask is a range, + /// not a sweep: `ndarray::simd::mask_set_range` fills it in three passes + /// over the words (zero before, ones inside, zero after, two computed + /// edge words) with no per-row compare at all. This is the op + /// `lance-graph-quack`'s `Filter::prefix_u64` doc named as *"NOT yet a + /// range WRITE … waits on the primitive"* — the primitive is in ndarray; + /// this is the IR name for it (`ISS-MASK-RISC-HAD-NO-RANGE-OP`). + /// + /// The IR does NOT decide whether a lane is address-ordered — that is the + /// planner's knowledge (a V3 table's row address is its rail). A caller + /// that lowers a prefix to `Range` on an unordered lane gets a wrong + /// answer, not an error; the oracle agrees with the executor on the range + /// itself, which is all either can check. + /// + /// Validated: `lo <= hi` and `hi <= n_rows` + /// ([`crate::ExecError::RangeOutOfBounds`]), so the write can never reach + /// the tail. `lo == hi` is a legal empty range (an all-zero write, not a + /// no-op). With `under`, the result is `range & gate`. + Range { lo: u32, hi: u32 }, } /// One instruction. Destinations are always [`Operand::Scratch`]; input planes diff --git a/crates/lance-graph-mask-risc/src/reference.rs b/crates/lance-graph-mask-risc/src/reference.rs index b464a6e5a..997c4ef31 100644 --- a/crates/lance-graph-mask-risc/src/reference.rs +++ b/crates/lance-graph-mask-risc/src/reference.rs @@ -31,14 +31,16 @@ fn kind_of(lane: &LaneRef<'_>) -> LaneKind { } } -/// The lane a predicate reads, and the width that lane must have. +/// The lane a predicate reads, and the width that lane must have — `None` +/// for a predicate that reads no lane at all ([`Pred::Range`] is on the row +/// index). /// /// One place where `Pred`'s variants map to lane widths, so the executor and /// the oracle cannot disagree about which column a predicate touches. Adding a /// `Pred` variant without extending this match is a compile error, which is /// the point of listing the variants explicitly rather than matching a field. -fn pred_lane_and_kind(pred: Pred) -> (u16, LaneKind) { - match pred { +fn pred_lane_and_kind(pred: Pred) -> Option<(u16, LaneKind)> { + Some(match pred { Pred::GtI32 { lane, .. } | Pred::LtI32 { lane, .. } | Pred::GeI32 { lane, .. } @@ -49,7 +51,8 @@ fn pred_lane_and_kind(pred: Pred) -> (u16, LaneKind) { (lane, LaneKind::U32) } Pred::MatchU64 { lane, .. } => (lane, LaneKind::U64), - } + Pred::Range { .. } => return None, + }) } /// One operand's index, against the two address spaces it could name. @@ -282,8 +285,19 @@ pub(crate) fn validate( } written(u)?; } - let (lane, kind) = pred_lane_and_kind(pred); - check_lane(planes, lane, kind)?; + if let Some((lane, kind)) = pred_lane_and_kind(pred) { + check_lane(planes, lane, kind)?; + } + if let Pred::Range { lo, hi } = pred { + let hi_fits = usize::try_from(hi).is_ok_and(|h| h <= planes.n_rows); + if lo > hi || !hi_fits { + return Err(ExecError::RangeOutOfBounds { + lo, + hi, + n_rows: planes.n_rows, + }); + } + } check_operand(p, planes, Operand::Scratch(dst))?; } MaskOp::And { a, b, dst } @@ -409,6 +423,8 @@ fn eval_pred(planes: &Planes<'_>, pred: Pred, row: usize) -> bool { pattern, care, } => (u64_at(lane) ^ pattern) & care == 0, + // `hi <= n_rows` and `lo <= hi` were validated, so both fit a usize. + Pred::Range { lo, hi } => (lo as usize..hi as usize).contains(&row), } } diff --git a/crates/lance-graph-mask-risc/src/value.rs b/crates/lance-graph-mask-risc/src/value.rs index 2133aaf52..def20dab8 100644 --- a/crates/lance-graph-mask-risc/src/value.rs +++ b/crates/lance-graph-mask-risc/src/value.rs @@ -103,4 +103,9 @@ pub enum ExecError { /// A gated predicate whose gate IS its destination: the facade cannot /// read the gate while overwriting it, so the program is refused. GateAliasesDst { dst: u16 }, + /// [`crate::Pred::Range`] with `lo > hi` or `hi > n_rows`. Refused by the + /// validator both paths share, so the executor never hands + /// `mask_set_range` a bound past the scratch words (its own assert would + /// panic) and the oracle never indexes a row that does not exist. + RangeOutOfBounds { lo: u32, hi: u32, n_rows: usize }, } diff --git a/crates/lance-graph-mask-risc/tests/differential.rs b/crates/lance-graph-mask-risc/tests/differential.rs index da47df739..e3a96ef8e 100644 --- a/crates/lance-graph-mask-risc/tests/differential.rs +++ b/crates/lance-graph-mask-risc/tests/differential.rs @@ -408,3 +408,176 @@ fn every_terminal() { } } } + +// ───────────────────────────────────────────────────────────────────────────── +// `Pred::Range` — the contiguous-range write (`ISS-MASK-RISC-HAD-NO-RANGE-OP`). +// ───────────────────────────────────────────────────────────────────────────── + +/// Every `(lo, hi)` the word geometry can make interesting at `n` rows: empty, +/// single-word, word-straddling, edge-aligned at 63/64/65, and the full table. +fn range_cases(n: usize) -> Vec<(u32, u32)> { + let n32 = n as u32; + let mut v = vec![(0, 0), (0, n32), (n32, n32)]; + for &(lo, hi) in &[ + (0, 1), + (1, 2), + (62, 63), + (63, 64), + (63, 65), + (64, 65), + (60, 70), + (1, 127), + (64, 128), + (65, 129), + (100, 900), + (7, 65_000), + ] { + if hi <= n32 { + v.push((lo, hi)); + } + } + v +} + +/// FAILS IF: the executor's `mask_set_range` path and the oracle's row-index +/// predicate disagree on any range at any row count — ungated, under a plane, +/// or under a scratch gate — on values OR scratch words. +#[test] +fn range_matches_the_oracle_at_every_word_edge() { + for n in ROWS { + let f = Fixture::new(n, 23); + for (lo, hi) in range_cases(n) { + let pred = Pred::Range { lo, hi }; + for under in [None, Some(P0), Some(S1)] { + let mut ops = Vec::new(); + if under == Some(S1) { + ops.push(MaskOp::Not { a: P1, dst: 1 }); + } + ops.push(MaskOp::Pred { + pred, + under, + dst: 0, + }); + let p = Program::new(ops, Terminal::Keep { mask: S0 }); + f.run(&p, &format!("{pred:?} under {under:?}")); + } + } + } +} + +/// Count through the EXECUTOR. `Fixture::count` goes through the oracle, which +/// is the right side for fixture-validity guards but the wrong side for a +/// population check on the `mask_set_range` path: a disable-run (an +/// off-by-one in `exec.rs`'s `Range` arm) passed the oracle-counted version of +/// this test, which is exactly the vacuity it now cannot have. +fn exec_count(f: &Fixture, p: &Program) -> usize { + let masks: Vec<&[u64]> = f.masks.iter().map(|m| m.as_slice()).collect(); + let lanes = [LaneRef::I32(&f.i32s)]; + let planes = Planes { + n_rows: f.n, + masks: &masks, + lanes: &lanes, + }; + let mut scratch = Scratch::for_program(p, f.n).expect("addressable"); + match execute(p, &planes, &mut scratch, None) { + Ok(Value::Count(c)) => c, + other => panic!("expected a count, got {other:?}"), + } +} + +/// FAILS IF: the executor's range population is not EXACTLY `hi - lo` — the +/// can-it-fire half (a non-empty range selects something) and the +/// can-it-stay-silent half (an empty range selects nothing, and it selects +/// nothing OUTSIDE its bounds) in one equality, `==` not `>=`. +#[test] +fn range_population_is_exactly_hi_minus_lo() { + for n in ROWS { + let f = Fixture::new(n, 29); + for (lo, hi) in range_cases(n) { + let c = Program::new( + vec![MaskOp::Pred { + pred: Pred::Range { lo, hi }, + under: None, + dst: 0, + }], + Terminal::Count { mask: S0 }, + ); + assert_eq!( + exec_count(&f, &c), + (hi - lo) as usize, + "Range {{ {lo}, {hi} }} @ n={n}" + ); + } + // Silent half with a non-trivial input: a range gated by its own + // complement selects nothing, and this is only informative when the + // range is non-empty. + if n >= 2 { + let (lo, hi) = (0u32, (n / 2) as u32); + let disjoint = Program::new( + vec![ + MaskOp::Pred { + pred: Pred::Range { lo, hi }, + under: None, + dst: 1, + }, + MaskOp::Not { a: S1, dst: 2 }, + MaskOp::Pred { + pred: Pred::Range { lo, hi }, + under: Some(Operand::Scratch(2)), + dst: 0, + }, + ], + Terminal::Count { mask: S0 }, + ); + assert_eq!( + exec_count(&f, &disjoint), + 0, + "range under its complement @ n={n}" + ); + } + } +} + +/// FAILS IF: an out-of-bounds range is accepted by either path, or the two +/// paths refuse it with different errors. `lo > hi` and `hi > n_rows` are the +/// two ways to be out of bounds; both must be refused BEFORE any write. +#[test] +fn range_out_of_bounds_is_refused_identically() { + use lance_graph_mask_risc::ExecError; + for n in ROWS { + let f = Fixture::new(n, 31); + let n32 = n as u32; + for (lo, hi) in [(n32 + 1, n32 + 1), (0, n32 + 1), (3, 2), (n32, n32 + 5)] { + if lo <= hi && hi <= n32 { + continue; // not actually out of bounds at this n + } + let p = Program::new( + vec![MaskOp::Pred { + pred: Pred::Range { lo, hi }, + under: None, + dst: 0, + }], + Terminal::Count { mask: S0 }, + ); + let masks: Vec<&[u64]> = f.masks.iter().map(|m| m.as_slice()).collect(); + let lanes = [LaneRef::I32(&f.i32s)]; + let planes = Planes { + n_rows: n, + masks: &masks, + lanes: &lanes, + }; + let mut scratch = Scratch::for_program(&p, n).expect("addressable"); + let got = execute(&p, &planes, &mut scratch, None); + let want = reference_execute(&p, &planes, None); + assert_eq!( + got, want, + "Range {{ {lo}, {hi} }} @ n={n}: refusal must match" + ); + assert_eq!( + got, + Err(ExecError::RangeOutOfBounds { lo, hi, n_rows: n }), + "Range {{ {lo}, {hi} }} @ n={n}: must be refused as out of bounds" + ); + } + } +} From 9b43f47d478a7341f438ad32fd880c6618554db3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:33:24 +0000 Subject: [PATCH 3/9] board: ISS-MASK-RISC-HAD-NO-RANGE-OP filed; D-MRX-7 row; quack doc says what still waits - ISSUES: the chain as found (ndarray has mask_set_range -> mask-risc could not name it -> quack sweeps), what shipped (Pred::Range), and the arm that stays open: quack's prefix_u64 lowering waits on an ORDERING witness on the planner side, not on any primitive. Also names the missing mask_set_range_under fused form. - STATUS_BOARD: D-MRX-7 In PR, with the disable-run receipt. - LATEST_STATE: the entry, incl. the vacuous-first-draft receipt. - quack lib.rs: the prefix_u64 doc paragraph that said "absent from ndarray::simd ... waits for the primitive" gets an appended correction -- both halves moved; only the ordering knowledge is missing now. Supersession index regenerated last, unchanged. citation-decay 0 new. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HScwwezRdMxFfTs3WLG19d --- .claude/board/ISSUES.md | 40 +++++++++++++++++++++++++++++ .claude/board/LATEST_STATE.md | 29 +++++++++++++++++++++ .claude/board/STATUS_BOARD.md | 1 + crates/lance-graph-quack/src/lib.rs | 10 ++++++++ 4 files changed, 80 insertions(+) diff --git a/.claude/board/ISSUES.md b/.claude/board/ISSUES.md index c4699f7d3..d51d28d09 100644 --- a/.claude/board/ISSUES.md +++ b/.claude/board/ISSUES.md @@ -1,3 +1,43 @@ +## ISS-MASK-RISC-HAD-NO-RANGE-OP (2026-09-17) — PARTLY RESOLVED: the IR op shipped; the quack lowering still waits on ordering knowledge + +**The chain, as found:** `ndarray::simd::mask_set_range(out_words, lo, hi)` is on the +facade (`simd_masking_ops.rs`, `pub fn mask_set_range`) — a three-pass contiguous fill, +no per-row compare. `lance-graph-mask-risc`'s IR had **no op that reaches it** (`ir.rs` +`Pred` was the ten lane predicates and nothing else). So `lance-graph-quack`'s +`Filter::prefix_u64` — *"the closest DuckDB comes to the V3 address"*, matrix row R5 — +lowered an address prefix to a full `MatchU64` ternary SWEEP, and its own doc said +*"NOT yet a range WRITE … waits on the primitive."* The primitive existed; the IR +name did not. **ndarray has it → mask-risc can't name it → quack sweeps.** + +Why it matters: on an address-ordered lane a prefix names ONE contiguous subtree, and +that is the whole reason V3 has the survivor-skip lever at all — `adaptive_order_probe`'s +clustered regime skips **99.61 %** of words precisely because the prefix term's +survivors fill one block. A range write makes that selection free instead of a sweep +that happens to be cheap. It is also the "mask from root, O(1) adjacent" half of the +similarity question (`E-…-POPCOUNT-×-SELF`, LATEST_STATE 2026-09-15 (6)–(8)) as an IR +op rather than a per-row match. + +**Resolved 2026-09-17 (this branch):** `Pred::Range { lo: u32, hi: u32 }` — rows +`lo <= i < hi`, reads no lane; exec = `mask_set_range` (+ `mask_and_assign` under a +gate); oracle = row-index predicate; `ExecError::RangeOutOfBounds` for `lo > hi` or +`hi > n_rows`, refused by the shared validator before any write. Three differential +tests across the eight row counts (word-edge shapes incl. 63/64/65; `== hi − lo` +counted through the EXECUTOR; identical refusal). Disable-verified: an off-by-one in +the exec arm fails two of the three. + +**Still OPEN — the lowering.** `Filter::prefix_u64` still emits the sweep, and +correctly so: the IR does not know whether a lane is address-ordered (a V3 table's +row address is its rail; a borrowed `&[u64]` of edge targets is not), and quack's +`Filter` carries no ordering evidence. Lowering a prefix to `Range` on an unordered +lane is a wrong answer, not an error. The remaining arm is therefore on the planner +side: an ordering witness on the lane (or on `Col`) that licenses `prefix → Range`. +Until it exists the sweep is the honest lowering, and the quack doc now says exactly +that. + +**Also named, not built:** `mask_set_range` has no `_under` sibling on the facade; +the gated form here is two passes (`set_range` then `and_assign`). One fused pass is +a T1 follow-up, measured-then-pinned like every other facade member. + ## ISS-NIBLEPATH-FOLD-IS-CARRIER-2-UNMASKED (2026-09-17) — OPEN, the one prefix fold that genuinely wants a mask, and does not have one `NiblePath::common_prefix_depth` (`crates/lance-graph-contract/src/hhtl.rs`, `fn common_prefix_depth`) is diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 4f1bc7f05..80ecd3702 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,32 @@ +## 2026-09-17 (2) — `Pred::Range` lands in mask-risc: the contiguous-range write the IR could not name — one IR variant + one error, NO contract inventory delta + +- **State consumers should know:** `lance-graph-mask-risc` gains + `Pred::Range { lo: u32, hi: u32 }` (rows `lo <= i < hi`, reads no lane) and + `ExecError::RangeOutOfBounds`. Exec is `ndarray::simd::mask_set_range` + (+ `mask_and_assign` under a gate); the oracle is a row-index predicate; + `lo <= hi <= n_rows` is refused by the shared validator before any write. + `D-MRX-7`, in PR on `claude/great-pascal-k96kok` (`c3931a1a`). +- **Why:** `mask_set_range` was on the ndarray facade with nothing in the IR + able to reach it, so `lance-graph-quack`'s `Filter::prefix_u64` — an + address prefix, which on an ordered lane is ONE contiguous subtree — + lowered to a full ternary sweep and said *"waits on the primitive"*. The + primitive was there; the name was not (`ISS-MASK-RISC-HAD-NO-RANGE-OP`). + This is the "mask from root, O(1) adjacent" half of the similarity + question ((6)–(8) below) as an op, and the mechanism behind the 99.61 % + clustered word-skip in `adaptive_order_probe`. +- **What did NOT move:** quack still lowers `prefix_u64` to the sweep, and + should — the IR does not know whether a lane is address-ordered, and + `Filter` carries no ordering evidence. The remaining arm is an ordering + witness on the planner side; quack's doc now says so. The + `lance-graph-contract` inventory is untouched. +- **Process receipt:** the first draft of the population test counted + through the oracle (`Fixture::count`) and a disable-run with an + off-by-one in the exec arm PASSED it — vacuous against the side under + test. Fixed to count through `execute`; the disable-run now fails 2 of 3. + Same session also walked into the ruff "commit BEFORE you disable" trap + once (`git checkout` after a disable-run reverted uncommitted work); + re-applied, then committed first and re-ran. + ## 2026-09-17 — PR #1245 merged (`83369cad`): the facet per-axis LCP is the `shared6` byte chain again, verbatim pre-#1241 — NO contract inventory delta `FacetCascade::{hi_distance, lo_distance}` are back to the by-value diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 729cb257f..5bbded313 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -58,6 +58,7 @@ evaluates one; `execute` stays the consumer's call on a scratch it owns. | D-MRX-4 | `ternlog_dispatch.rs` — generated 256-arm match, regenerate-and-diff gated | Shipped (#1226 `0b1ebaa`; PR3; CI `--check` line wired) | all 256 immediates equal the bit-serial reference | | D-MRX-5 | the differential suite at 8 row counts × every op × both CI arms | Shipped (#1226 `0b1ebaa`; PR3; `x86-64-v3` in CI, `-v4` locally on 2026-09-14 — NEON/WASM/scalar unexercised) | anti-vacuity `survivors * 3 < n_rows` for the 8 non-`Ne` predicates, UNGATED, at `n_rows >= 3` (the two `Ne` shapes are selective by complement, and `n_rows ∈ {0, 1}` are execution-only — the bound is `0 < 0` there); the gate itself is pinned selective (`gated < ungated`) at `n_rows >= 64` | | D-MRX-6 | `examples/count_probe.rs` — reference / interpreted / fused, 0 B per execute | Shipped (#1226 `0b1ebaa`; PR3; four arms agree on the COUNT — 4855 at 64k rows; `interpreted` and `fused` gated at 0 B; timings printed from one run, not pinned) | counting allocator reads 0 after warm-up; three arms agree | +| D-MRX-7 | `Pred::Range { lo, hi }` — the contiguous-range write (`ndarray::simd::mask_set_range`) as an IR op; `ExecError::RangeOutOfBounds`; gate-aware | **In PR** (branch `claude/great-pascal-k96kok`, `c3931a1a`). Closes the IR half of `ISS-MASK-RISC-HAD-NO-RANGE-OP`; quack's `prefix_u64` lowering still waits on lane-ordering evidence (planner side) | three differential tests at the 8 row counts: word-edge shapes incl. 63/64/65 ungated / under plane / under scratch (values AND scratch words); `== hi − lo` through the EXECUTOR (the oracle-counted first draft was vacuous — disable-run passed it); identical `RangeOutOfBounds` on both paths. Disable-run: off-by-one in the exec arm fails 2 of 3 | ## bbb-nars-lowering (D-id minted 2026-09-07 with the operator ruling) diff --git a/crates/lance-graph-quack/src/lib.rs b/crates/lance-graph-quack/src/lib.rs index 320866766..228385a32 100644 --- a/crates/lance-graph-quack/src/lib.rs +++ b/crates/lance-graph-quack/src/lib.rs @@ -466,6 +466,16 @@ impl Filter { /// missing-capability STOP rule a consumer does not hand-roll it one layer /// up, so this crate spells the PREDICATE and waits for the primitive. /// + /// ⊘ 2026-09-17 (`ISS-MASK-RISC-HAD-NO-RANGE-OP`): both halves of that + /// paragraph have moved. `mask_set_range` IS on `ndarray::simd` + /// (`simd_masking_ops.rs`), and mask-risc now names it — + /// `Pred::Range { lo, hi }`, validated `lo <= hi <= n_rows`, gate-aware. + /// What this crate still lacks is the one thing the IR deliberately does + /// not carry: whether the lane is ADDRESS-ORDERED. A prefix is a range + /// only on an ordered lane, and `Filter` has no ordering evidence to + /// lower on, so this still lowers to the sweep. The lowering waits on + /// the planner's ordering knowledge now, not on any primitive. + /// /// That the primitive is real rather than wished for is the harvest's /// evidence, not this crate's opinion: DuckDB's own bit-plane carries /// `TemplatedValidityMask::SetRangeInvalid` (`validity_mask.hpp`, harvested From c6d48a53f322562535de8d53281dec1440c714ca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 22:14:58 +0000 Subject: [PATCH 4/9] canonical_node: EdgeBlock is a FacetCascade -- the second 16 bytes are just another content-blind facet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling (2026-09-17): "It's forbidden for the edge block to even know it's an edge block -- it's just another content blind facet cascade." pub type EdgeBlock = crate::facet::FacetCascade; The `EdgeBlock { in_family: [u8; 12], out_family: [u8; 4] }` struct is gone. The canon had already regraded the 12+4 carving as a V1-LEGACY READING (CLAUDE.md § CANON) -- but a struct with those field names IS that reading spelled as a type, and every `.in_family`/`.out_family` access re-asserted it. A regraded paragraph does not retire a reading while a struct still spells it. Same repr(C, align(16)), same 16 bytes, same byte positions: NODE_ROW_STRIDE, node_rows_from_le_bytes, ENVELOPE_LAYOUT_VERSION all unchanged. `EdgeBlock::default()` and `==` compile as before, so OGAR's lance_sink.rs (git main, default()/== only) is unaffected. Added `FacetCascade::as_bytes_mut` (same SAFETY pattern as `as_bytes`; every bit pattern is a valid content-blind facet) so in-place writers have a lens. `EdgeCodecFlavor` stays: it is how a ClassView READS the second facet, which was always its job. Every in-tree field site migrated MECHANICALLY to the same bytes (`as_bytes()[..12]` / `[12..]`, `as_bytes_mut()[i]`), none redesigned -- "alias only": contract tests, aiwar, soa_graph, callcenter graph_table, weather-poc canonical, lance-graph mailbox_scan, symbiont x3 (deprecated crate). The readers that still split at 12 on their own authority are the named residue: ISS-EDGE-BLOCK-WAS-A-SECOND-TYPE-FOR-THE-SAME-FACET. The test `edge_block_is_twelve_plus_four` is replaced by `edge_block_is_the_same_facet_type_as_the_key`; no test pins a 12+4 split any more. Withdrawn before landing, on the ruling: EdgeCodecFlavor::Refs16, EdgeRefs, the T8 facet shape, ColumnDescriptor::class_id, a le-contract §3c ruling -- all consequences of treating bytes 16..32 as something other than a facet. Nothing of them is on this branch. Verified: lance-graph-contract 1425 tests green, clippy + fmt clean; lance-graph-callcenter, lance-graph-planner, weather-poc check green. lance-graph (mailbox_scan.rs) and symbiont cannot be compiled in this container (lance-encoding's build script needs protoc) -- CI's linux-build/test rows compile lance-graph and are the gate for that file; symbiont is workspace-excluded and deprecated. Board: E-THE-SECOND-FACET-IS-NOT-AN-EDGE-BLOCK-1 (EPIPHANIES), ISS-EDGE-BLOCK-WAS-A-SECOND-TYPE-FOR-THE-SAME-FACET, LATEST_STATE contract-inventory delta, CLAUDE.md § CANON ⊘ note (append-only), le-contract.md §4 bullet. Supersession index regenerated last, unchanged; citation-decay 0 new. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HScwwezRdMxFfTs3WLG19d --- .claude/board/EPIPHANIES.md | 57 +++++++++++++++ .claude/board/ISSUES.md | 44 ++++++++++++ .claude/board/LATEST_STATE.md | 22 ++++++ .claude/v3/soa_layout/le-contract.md | 5 ++ CLAUDE.md | 9 +++ .../lance-graph-callcenter/src/graph_table.rs | 2 +- crates/lance-graph-contract/src/aiwar.rs | 2 +- .../src/canonical_node.rs | 72 ++++++++++++------- crates/lance-graph-contract/src/facet.rs | 12 ++++ crates/lance-graph-contract/src/soa_graph.rs | 8 +-- crates/lance-graph/src/graph/mailbox_scan.rs | 13 ++-- crates/symbiont/src/domino.rs | 15 ++-- crates/symbiont/src/kanban_loop.rs | 24 +++++-- crates/symbiont/src/key_render.rs | 6 +- crates/weather-poc/src/bake.rs | 43 +++-------- crates/weather-poc/src/canonical.rs | 46 ++++++------ 16 files changed, 268 insertions(+), 112 deletions(-) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 62d9fe646..b3a0080be 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,60 @@ +## 2026-09-17 (18) — E-THE-SECOND-FACET-IS-NOT-AN-EDGE-BLOCK-1 — bytes 16..32 are just another content-blind facet cascade; giving them their own type was how the V1 `12 + 4` carving survived its own retirement + +**Status:** OPERATOR-RULED (verbatim below) + SHIPPED (`pub type EdgeBlock = +FacetCascade;`, `FacetCascade::as_bytes_mut`, every field site migrated +byte-for-byte). Residue named in +`ISS-EDGE-BLOCK-WAS-A-SECOND-TYPE-FOR-THE-SAME-FACET`. +**Confidence:** HIGH — the ruling is the operator's; the mechanics are a +type alias over an identical `repr(C, align(16))` 16-byte layout, 1425 +contract tests unchanged, every in-tree consumer builds. + +### The ruling + +> *"It's forbidden for the edge block to even know it's an edge block — +> it's just another content blind facet cascade."* +> *"How hard can it be to fold `[u16;8]` / `[u8;16]` — same algorithm, +> just 6 vs 8, 12 vs 16."* +> *"What you call edge codec flavor, quoting 12+4 in/out family, is a V1 +> contamination."* + +### What it corrects, including in this session + +The canon had retired the `12 in-family + 4 out-of-family` carving as a +V1-LEGACY *reading* — but `EdgeBlock { in_family: [u8; 12], out_family: +[u8; 4] }` was that reading spelled as a *type*, and a type outlives a +regraded paragraph. Every `.in_family` / `.out_family` access re-asserted +it. This session then proposed, in order: a fourth `EdgeCodecFlavor` +(`Refs16`), a second view type (`EdgeRefs`), an 8×2×8 `T8` facet shape, a +`ColumnDescriptor::class_id` field, and a §3c ruling — each one a +consequence of treating the second 16 bytes as something other than a +facet. All of it was discarded on the ruling. What remains is one line. + +### The shape that is left + +`NodeRow = key: FacetCascade | edges: FacetCascade | value(480)`. The +second facet's 4-byte prefix says what its six `(u8:u8)` rails ARE for +this row — the predicate, the codebook — and the ClassView projects them. +"Sixteen refs" is `4 + 12` read as L1 rails; the classid that a `T8` shape +or a lane-level field was invented to carry was already in the register. +`EdgeCodecFlavor` is how a class *reads* the second facet, which is all it +ever was. The fold is `shared::` on `[u8; N]`, one algorithm, `6` or +`8` — the cascade discipline from `E-THREE-CARRIERS-THREE-FOLDS-1` applies +to both facets identically because they are the same type. + +### The rule, generalized + +**A second type for the same bytes is a carving pretending to be a +layout.** The content-blind invariant is not "the ClassView chooses the +reading"; it is "there is no type in which a reading could hide." Where +the canon has already regraded a reading as legacy, grep for the *type* +that still spells it — the paragraph did not retire it if the struct is +still there. + +Cross-ref: `E-V3-FACET-4-PLUS-12` (now visibly true of bytes 16..32 as +well), `E-V1-TAIL-FORBIDDEN-V3-IS-CONTENT-BLIND-1`, +`E-THREE-CARRIERS-THREE-FOLDS-1`, CLAUDE.md § CANON (⊘ note appended), +`le-contract.md` §4 (bullet appended). + ## 2026-09-17 (17) — E-THREE-CARRIERS-THREE-FOLDS-1 — one workspace holds THREE prefix-fold carriers; entry (16) measured a real win on one of them and shipped it into another, where it is 2.1× SLOWER **Status:** MEASURED (`crates/lance-graph-contract/examples/facet_axis_lcp_probe.rs`, diff --git a/.claude/board/ISSUES.md b/.claude/board/ISSUES.md index d51d28d09..f79091130 100644 --- a/.claude/board/ISSUES.md +++ b/.claude/board/ISSUES.md @@ -1,3 +1,47 @@ +## ISS-EDGE-BLOCK-WAS-A-SECOND-TYPE-FOR-THE-SAME-FACET (2026-09-17) — RESOLVED at the type; the readers that still split at 12 are the named residue + +**Operator ruling (verbatim, 2026-09-17):** *"It's forbidden for the edge block to even +know it's an edge block — it's just another content blind facet cascade."* And the diagnosis +that preceded it: *"what you call edge codec flavor … quoting 12+4 in/out family is a V1 +contamination."* + +**The contamination:** bytes 16..32 of the node row had their own type, `EdgeBlock +{ in_family: [u8; 12], out_family: [u8; 4] }`. The canon had already regraded the `12 + 4` +carving as *"NOT the layout … V1-LEGACY READING"* (CLAUDE.md § CANON), but a struct with +those two field names IS that reading, stated as a type — and every reader that touched +`.in_family` / `.out_family` re-asserted it. A session drafting an ontology edge reading +(this one) reached for a *fourth* `EdgeCodecFlavor` and a *second* view type beside it, +and wrote a test that pinned the 12+4 split as the baseline the new reading sat beside. +That draft was discarded; this is what replaced it. + +**Resolved at the type (this branch):** `pub type EdgeBlock = FacetCascade;` — the second +16 bytes are the same `4 + 12` content-blind facet as the key. Byte positions unchanged; +`NodeRow`, `node_rows_from_le_bytes`, every `EdgeBlock::default()` and every equality +compile unchanged (OGAR `lance_sink.rs` pulls the contract from git `main` and uses only +`default()` / `==`, so it is unaffected until merge and unbroken after). +`FacetCascade::as_bytes_mut` added (same `SAFETY` pattern as `as_bytes`) so the in-place +writers have a lens. `EdgeCodecFlavor` stays: it is how a ClassView READS the second facet, +which was always its job. + +**The residue — readers that still split the 16 bytes at 12 on their own authority** (all +migrated to `as_bytes()[..12]` / `[12..]` mechanically so the bytes they read are exactly +the bytes they read before; none was redesigned, per the "alias only" scope): + +| site | what it still believes | +|---|---| +| `lance-graph/src/graph/mailbox_scan.rs` `edge_slots_coarse` → `EdgeNeighbors { in_family, external }` | 12 local + 4 external | +| `lance-graph-contract/src/soa_graph.rs` render (`domain.in_family_edge` / `out_family_edge` labels) | two edge kinds by byte position | +| `lance-graph-contract/src/aiwar.rs` (writes bytes 12..16 as "the 4 canonical out-of-family slots") | 4 adapter slots | +| `symbiont/src/{domino,key_render,kanban_loop}.rs` (⊘ deprecated crate) | ring neighbour at byte 0, adapter at byte 12 | +| `lance-graph-callcenter/src/graph_table.rs` test fixture | adapter byte 0x0B at byte 12 | +| `deepnsm/examples/tri_fidelity_edges.rs`, `perturbation-sim/examples/reinforce.rs` (prose only) | "12 in-family + 4 out-of-family" | +| `EdgeCodecFlavor::CoarseOnly` doc comment | "the 12+4 block read literally" | + +Each becomes correct the moment its ClassView names what the second facet's prefix and +rails mean for that class — which is the whole point: the READING belongs to the class, +never to the slot. Closing this issue = every row above reads through the ClassView (or is +deleted with its crate). + ## ISS-MASK-RISC-HAD-NO-RANGE-OP (2026-09-17) — PARTLY RESOLVED: the IR op shipped; the quack lowering still waits on ordering knowledge **The chain, as found:** `ndarray::simd::mask_set_range(out_words, lo, hi)` is on the diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 80ecd3702..b7e34ea08 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,25 @@ +## 2026-09-17 (3) — CONTRACT INVENTORY DELTA: `EdgeBlock` is now `pub type EdgeBlock = FacetCascade` (the V1 `12 + 4` struct is gone); `FacetCascade::as_bytes_mut` added + +- **State consumers should know:** bytes 16..32 of `NodeRow` are the same + `4 + 12` content-blind facet type as the key. `EdgeBlock` survives as a + NAME only; `.in_family` / `.out_family` no longer exist — read through + `as_bytes()`, write through the new `as_bytes_mut()`. Byte positions are + unchanged, `NODE_ROW_STRIDE` unchanged, `node_rows_from_le_bytes` + unchanged, no `ENVELOPE_LAYOUT_VERSION` bump. `EdgeBlock::default()` and + equality compile as before (OGAR `lance_sink.rs` uses only those, via git + `main`). Every in-tree field site (symbiont ×3, soa_graph, aiwar, + callcenter, weather-poc, mailbox_scan, contract tests) migrated + mechanically to the same bytes. +- **Operator ruling:** *"It's forbidden for the edge block to even know it's + an edge block — it's just another content blind facet cascade."* Board: + `E-THE-SECOND-FACET-IS-NOT-AN-EDGE-BLOCK-1`. +- **Residue named, not touched:** the readers that still split at 12 on + their own authority — `ISS-EDGE-BLOCK-WAS-A-SECOND-TYPE-FOR-THE-SAME-FACET`. +- **Withdrawn this session before landing:** `EdgeCodecFlavor::Refs16`, + `EdgeRefs`, the `T8` facet shape, `ColumnDescriptor::class_id`, and a + `le-contract.md` §3c ruling — all consequences of the contamination, all + discarded on the ruling. Nothing of them is on `main` or on this branch. + ## 2026-09-17 (2) — `Pred::Range` lands in mask-risc: the contiguous-range write the IR could not name — one IR variant + one error, NO contract inventory delta - **State consumers should know:** `lance-graph-mask-risc` gains diff --git a/.claude/v3/soa_layout/le-contract.md b/.claude/v3/soa_layout/le-contract.md index ae245eb54..5a620ad12 100644 --- a/.claude/v3/soa_layout/le-contract.md +++ b/.claude/v3/soa_layout/le-contract.md @@ -325,6 +325,11 @@ move; jc pillars prove the READING preserves the semantics. stays authoritative for the 512-byte row: `key(16) | edges(16) | value(480)`. The facet atom is the unit INSIDE value lanes (and the key itself is 16 bytes with the same 4+12 rhythm — see §3 open items). +- **2026-09-17 (operator ruling, `E-THE-SECOND-FACET-IS-NOT-AN-EDGE-BLOCK-1`):** + `edges(16)` is not a third thing — it is the same `4 + 12` facet as the + key (`pub type EdgeBlock = FacetCascade`). Its prefix says what its rails + ARE for that row; the ClassView projects them; `EdgeCodecFlavor` is how + a class reads it. Nothing about the slot knows it holds "edges". - The 480-byte value slab holds facets per the tenant schema: `classid_read_mode(c).value_schema` selects which lanes/layouts a class carries (`tenants.md` catalogues them). diff --git a/CLAUDE.md b/CLAUDE.md index d29fb3821..22d768de3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1687,6 +1687,15 @@ stride change. > fidelity: `ndarray::hpc::reliability` (`edge_codec_compare`). Append-only: > the V1 line is regraded in place, not deleted. +> **⊘ 2026-09-17 — the TYPE is retired too (operator ruling, +> `E-THE-SECOND-FACET-IS-NOT-AN-EDGE-BLOCK-1`):** *"It's forbidden for the +> edge block to even know it's an edge block — it's just another content +> blind facet cascade."* `EdgeBlock` is now `pub type EdgeBlock = +> FacetCascade;` — bytes 16..32 are the same `4 + 12` facet as the key, and +> the `in_family` / `out_family` fields no longer exist. A regraded +> paragraph does not retire a reading while a struct still spells it. +> Readers that still split at 12: `ISS-EDGE-BLOCK-WAS-A-SECOND-TYPE-FOR-THE-SAME-FACET`. + **Zero-fallback ladder (monotonic — zero = fall through to the broader default):** - `classid == 0x0000_0000` → default class, no prefix routing (dormant) - `family == 0x00_0000` → default basin, no neighborhood grouping (dormant) diff --git a/crates/lance-graph-callcenter/src/graph_table.rs b/crates/lance-graph-callcenter/src/graph_table.rs index e17823c9f..b754dae52 100644 --- a/crates/lance-graph-callcenter/src/graph_table.rs +++ b/crates/lance-graph-callcenter/src/graph_table.rs @@ -151,7 +151,7 @@ mod tests { /// GraphSnapshot → arrow tables. End-to-end head → DataFusion. fn sample_snapshot() -> GraphSnapshot { let mut a_edges = EdgeBlock::default(); - a_edges.out_family[0] = 0x0B; + a_edges.as_bytes_mut()[12] = 0x0B; let rows = [ NodeRow { key: osint_key(1, 0xA, 1), diff --git a/crates/lance-graph-contract/src/aiwar.rs b/crates/lance-graph-contract/src/aiwar.rs index e91563d4c..42d212248 100644 --- a/crates/lance-graph-contract/src/aiwar.rs +++ b/crates/lance-graph-contract/src/aiwar.rs @@ -108,7 +108,7 @@ pub fn aiwar_node_rows(graph: &LiteralGraph) -> Vec { // 4 canonical out-of-family slots. let mut edges = EdgeBlock::default(); for (k, &b) in slots.iter().take(4).enumerate() { - edges.out_family[k] = b; + edges.as_bytes_mut()[12 + k] = b; } NodeRow { // Route through `mint_for` so the classid's registered diff --git a/crates/lance-graph-contract/src/canonical_node.rs b/crates/lance-graph-contract/src/canonical_node.rs index 87545aec6..1221fee42 100644 --- a/crates/lance-graph-contract/src/canonical_node.rs +++ b/crates/lance-graph-contract/src/canonical_node.rs @@ -666,19 +666,26 @@ impl core::fmt::Display for NodeGuid { } } -/// 16-byte canonical edge block: 12 in-family + 4 out-of-family. +/// Bytes 16..32 of the node row — **just another content-blind facet** +/// (operator ruling, 2026-09-17: *"It's forbidden for the edge block to even +/// know it's an edge block; it's just another content blind facet cascade"*). /// -/// Canonical, not mandatory: the 16 bytes are ALWAYS reserved (zeroed when unused). -/// A class never shrinks this block — opting out of edges is resolved via -/// classid → ClassView in the registry, never by changing the row stride. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[repr(C, align(16))] -pub struct EdgeBlock { - /// 12 local adjacency slots (basin-local), one byte each. - pub in_family: [u8; 12], - /// 4 inherited adapter slots (out-of-family interfaces), one byte each. - pub out_family: [u8; 4], -} +/// The same 16-byte `4 + 12` register as the key: a classid prefix that says +/// what the six `(u8:u8)` rails ARE for this row (the predicate, the codebook), +/// and rails the `ClassView` projects. Nothing about the slot knows it holds +/// "edges"; [`EdgeCodecFlavor`] is how a class *reads* this second facet. +/// +/// `EdgeBlock` is kept as a NAME for source compatibility only. The V1 +/// carving it used to present — `12 in-family + 4 out-of-family` one-byte +/// slots — is **retired**: it was never the layout (CLAUDE.md § CANON, the ⊘ +/// V1-LEGACY READING note), and giving those 16 bytes their own type with +/// those field names is what let the carving pass for one. Byte positions +/// are unchanged — a reader that still splits at 12 does so on its own +/// authority, and every such site is named in +/// `ISS-EDGE-BLOCK-WAS-A-SECOND-TYPE-FOR-THE-SAME-FACET`. New code says +/// `FacetCascade` and reads through `as_bytes()` / the ClassView; migration +/// pointer per I-LEGACY-API-FEATURE-GATED. +pub type EdgeBlock = crate::facet::FacetCascade; /// Which edge-codec flavor a class uses to *read* its node's edge block. /// @@ -2038,18 +2045,18 @@ mod tests { let rows = vec![ NodeRow { key: NodeGuid::new(NodeGuid::CLASSID_OSINT, 1, 2, 3, 0xAB, 0xCD), - edges: EdgeBlock { - in_family: [0xA1; 12], - out_family: [0xB2; 4], - }, + edges: EdgeBlock::from_bytes(&[ + 0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xB2, + 0xB2, 0xB2, 0xB2, + ]), value: [7u8; 480], }, NodeRow { key: NodeGuid::new(NodeGuid::CLASSID_PROJECT, 4, 5, 6, 0x11, 0x22), - edges: EdgeBlock { - in_family: [0xC3; 12], - out_family: [0xD4; 4], - }, + edges: EdgeBlock::from_bytes(&[ + 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xD4, + 0xD4, 0xD4, 0xD4, + ]), value: [9u8; 480], }, ]; @@ -2070,10 +2077,9 @@ mod tests { for (i, row) in rows.iter().enumerate() { let start = i * NODE_ROW_STRIDE + offset; let slice = &bytes[start..start + elems]; - // 16 bytes/row, straight out of the store: 12 in-family then - // 4 out-of-family (repr(C) field order within the block). - assert_eq!(&slice[..12], &row.edges.in_family); - assert_eq!(&slice[12..], &row.edges.out_family); + // 16 bytes/row, straight out of the store: the second facet's + // own bytes, in order. + assert_eq!(slice, row.edges.as_bytes()); } } @@ -2133,12 +2139,24 @@ mod tests { assert_eq!(&g.as_bytes()[10..16], &[0xAB, 0x00, 0x00, 0xCD, 0x00, 0x00]); } + /// The second facet IS a facet: same type, same 16 bytes, same alignment + /// as the key. A `12 + 4` carving appears nowhere here — that reading is + /// retired, not relocated. #[test] - fn edge_block_is_twelve_plus_four() { + fn edge_block_is_the_same_facet_type_as_the_key() { let e = EdgeBlock::default(); - assert_eq!(e.in_family.len(), 12); - assert_eq!(e.out_family.len(), 4); assert_eq!(core::mem::size_of_val(&e), 16); + assert_eq!( + core::mem::align_of::(), + core::mem::align_of::() + ); + let f: crate::facet::FacetCascade = e; // an alias, not a conversion + assert_eq!(f.as_bytes(), &[0u8; 16]); + let mut g = EdgeBlock::from_bytes(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + g.as_bytes_mut()[12] = 0; + assert_eq!(g.as_bytes()[11], 12); + assert_eq!(g.as_bytes()[12], 0); + assert_eq!(g.as_bytes()[13], 14); } #[test] diff --git a/crates/lance-graph-contract/src/facet.rs b/crates/lance-graph-contract/src/facet.rs index e4a62c631..5d79abe6c 100644 --- a/crates/lance-graph-contract/src/facet.rs +++ b/crates/lance-graph-contract/src/facet.rs @@ -174,6 +174,18 @@ impl FacetCascade { unsafe { &*(self as *const Self).cast::<[u8; 16]>() } } + /// Mutable twin of [`as_bytes`](Self::as_bytes): write the facet's own 16 + /// backing bytes in place. Every byte pattern is a valid facet (it is + /// content-blind by construction), so no invariant can be broken through + /// this view. + #[inline] + #[must_use] + pub fn as_bytes_mut(&mut self) -> &mut [u8; 16] { + // SAFETY: as for `as_bytes`; `&mut self` guarantees exclusivity, and + // all bit patterns of [u8; 16] are valid `FacetCascade` values. + unsafe { &mut *(self as *mut Self).cast::<[u8; 16]>() } + } + /// **Zero-copy borrow** of 16 slab bytes AS a facet — the literal no-op decode: the /// compiler reads fields/lanes straight from the slab, nothing materializes. Returns /// `None` if `b` is not 16-byte aligned (then copy via [`from_bytes`](Self::from_bytes)). diff --git a/crates/lance-graph-contract/src/soa_graph.rs b/crates/lance-graph-contract/src/soa_graph.rs index 563629019..e4035869d 100644 --- a/crates/lance-graph-contract/src/soa_graph.rs +++ b/crates/lance-graph-contract/src/soa_graph.rs @@ -323,7 +323,7 @@ pub fn project_snapshot(rows: &[NodeRow], domain: &DomainSpec) -> GraphSnapshot }); // 16 family-node adapters: 12 in-family + 4 out-of-family, each → a family. let eb = row.edges; - for &b in eb.in_family.iter().filter(|&&b| b != 0) { + for &b in eb.as_bytes()[..12].iter().filter(|&&b| b != 0) { if let Some(target) = resolve(b, fam) { edges.push(RenderEdge { source: g.to_string(), @@ -335,7 +335,7 @@ pub fn project_snapshot(rows: &[NodeRow], domain: &DomainSpec) -> GraphSnapshot }); } } - for &b in eb.out_family.iter().filter(|&&b| b != 0) { + for &b in eb.as_bytes()[12..].iter().filter(|&&b| b != 0) { if let Some(target) = resolve(b, fam) { edges.push(RenderEdge { source: g.to_string(), @@ -439,10 +439,10 @@ mod tests { use crate::canonical_node::classid_read_mode; let mut edges = EdgeBlock::default(); for (i, &b) in in_fam.iter().enumerate().take(12) { - edges.in_family[i] = b; + edges.as_bytes_mut()[i] = b; } for (i, &b) in out_fam.iter().enumerate().take(4) { - edges.out_family[i] = b; + edges.as_bytes_mut()[12 + i] = b; } NodeRow { // Route through `mint_for` so the domain classid's `tail_variant` diff --git a/crates/lance-graph/src/graph/mailbox_scan.rs b/crates/lance-graph/src/graph/mailbox_scan.rs index 9fc40d61b..a984f55e4 100644 --- a/crates/lance-graph/src/graph/mailbox_scan.rs +++ b/crates/lance-graph/src/graph/mailbox_scan.rs @@ -197,14 +197,12 @@ pub fn edge_slots_coarse( } let block = view.edge_block_at(row)?; Some(EdgeNeighbors { - in_family: block - .in_family + in_family: block.as_bytes()[..12] .iter() .copied() .filter(|&b| b != 0) .collect(), - external: block - .out_family + external: block.as_bytes()[12..] .iter() .copied() .filter(|&b| b != 0) @@ -469,10 +467,9 @@ mod tests { ], // row0 has in-family edges to refs 2,5 and one external ref 1; rest empty. blocks: vec![ - Some(EdgeBlock { - in_family: [2, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - out_family: [1, 0, 0, 0], - }), + Some(EdgeBlock::from_bytes(&[ + 2, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, + ])), Some(EdgeBlock::default()), None, None, diff --git a/crates/symbiont/src/domino.rs b/crates/symbiont/src/domino.rs index 0e0333a02..710b1996a 100644 --- a/crates/symbiont/src/domino.rs +++ b/crates/symbiont/src/domino.rs @@ -93,8 +93,8 @@ pub fn energy_of(row: &NodeRow) -> f32 { /// tenants), so seeding edges here is free for the AMX path. fn seed_board(idx: usize) -> NodeRow { let mut edges = EdgeBlock::default(); - edges.in_family[0] = ((idx % 255) + 1) as u8; // ring neighbour (always 1..=255) - edges.out_family[0] = (1 + (idx % 4)) as u8; // inherited-adapter slot (1..=4) + edges.as_bytes_mut()[0] = ((idx % 255) + 1) as u8; // ring neighbour (always 1..=255) + edges.as_bytes_mut()[12] = (1 + (idx % 4)) as u8; // byte 12 (the V1 "adapter slot 0" position) let mut row = NodeRow { key: NodeGuid::local(idx as u32), edges, @@ -155,7 +155,11 @@ fn domino_batch(boards: &mut [NodeRow], w: &[u16; K * BATCH], stages: usize) { /// The POC: build `n_boards` SoA boards, run a `stages`-deep Domino sweep in AMX /// 16-board batches, project onto the NaN-detection surface, report. pub fn run_poc(n_boards: usize, stages: usize) { - assert_eq!(n_boards % BATCH, 0, "n_boards must be a multiple of {BATCH}"); + assert_eq!( + n_boards % BATCH, + 0, + "n_boards must be a multiple of {BATCH}" + ); let w = weight(); let mut rows: Vec = (0..n_boards).map(seed_board).collect(); @@ -173,7 +177,10 @@ pub fn run_poc(n_boards: usize, stages: usize) { report.nonfinite ); } - assert!(report.is_clean(), "Domino sweep produced a non-finite board"); + assert!( + report.is_clean(), + "Domino sweep produced a non-finite board" + ); let path = if amx_available() { "AMX TDPBF16PS" diff --git a/crates/symbiont/src/kanban_loop.rs b/crates/symbiont/src/kanban_loop.rs index de6c579f0..2b9307e8d 100644 --- a/crates/symbiont/src/kanban_loop.rs +++ b/crates/symbiont/src/kanban_loop.rs @@ -254,7 +254,10 @@ mod tests { let mut board = SymbiontBoard::spawn(4096, 13); let trail = board.run_to_absorbing(&NextPhaseScheduler); - assert!(board.phase().is_absorbing(), "must reach the absorbing Commit"); + assert!( + board.phase().is_absorbing(), + "must reach the absorbing Commit" + ); assert!(!trail.is_empty(), "the arc must have advanced"); let energy = board.energy(); @@ -263,10 +266,19 @@ mod tests { let inf = energy.iter().filter(|e| e.is_infinite()).count(); let nonzero = energy.iter().filter(|&&e| e != 0.0).count(); - assert_eq!(nan, 0, "live-cycle NaN census: {nan}/{total} NaN over the arc"); - assert_eq!(inf, 0, "live-cycle Inf census: {inf}/{total} Inf over the arc"); + assert_eq!( + nan, 0, + "live-cycle NaN census: {nan}/{total} NaN over the arc" + ); + assert_eq!( + inf, 0, + "live-cycle Inf census: {inf}/{total} Inf over the arc" + ); // The sweep must have produced real, finite energy (not a no-op all-zeros). - assert!(nonzero > 0, "the BF16 Domino sweep produced no finite energy"); + assert!( + nonzero > 0, + "the BF16 Domino sweep produced no finite energy" + ); } #[test] @@ -286,8 +298,8 @@ mod tests { let eb = board .edge_block_at(3) .expect("owner materialises the edge block"); - assert_eq!(eb.in_family[0], 4); // ring edge (3 % 255) + 1 - assert_eq!(eb.out_family[0], 4); // adapter slot 1 + (3 % 4) + assert_eq!(eb.as_bytes()[0], 4); // ring edge (3 % 255) + 1 + assert_eq!(eb.as_bytes()[12], 4); // byte 12: 1 + (3 % 4) assert!( board.hhtl_path_at(3).is_some(), "owner materialises the HHTL path" diff --git a/crates/symbiont/src/key_render.rs b/crates/symbiont/src/key_render.rs index 7f05b7659..611be5f75 100644 --- a/crates/symbiont/src/key_render.rs +++ b/crates/symbiont/src/key_render.rs @@ -68,15 +68,13 @@ pub fn render_key_only(rows: &[NodeRow]) -> KeyGraph { // ── the ONLY two field reads: the 32-byte head ── let guid = row.key; let eb = row.edges; - let in_family: Vec<(u8, u8)> = eb - .in_family + let in_family: Vec<(u8, u8)> = eb.as_bytes()[..12] .iter() .enumerate() .filter(|(_, &b)| b != 0) .map(|(i, &b)| (i as u8, b)) .collect(); - let out_family: Vec<(u8, u8)> = eb - .out_family + let out_family: Vec<(u8, u8)> = eb.as_bytes()[12..] .iter() .enumerate() .filter(|(_, &b)| b != 0) diff --git a/crates/weather-poc/src/bake.rs b/crates/weather-poc/src/bake.rs index d8a9f57d2..a34a48a34 100644 --- a/crates/weather-poc/src/bake.rs +++ b/crates/weather-poc/src/bake.rs @@ -166,13 +166,9 @@ where let key = encode_key(classid, lat_idx, lon_idx); let mut facets = [[0u8; FACET_LEN]; W1_FACET_COUNT]; for (facet_idx, facet_bytes) in facets.iter_mut().enumerate() { - *facet_bytes = pack_facet( - classid, - facet_idx as u8, - manifest, - floors, - |entry| value_of(entry), - )?; + *facet_bytes = pack_facet(classid, facet_idx as u8, manifest, floors, |entry| { + value_of(entry) + })?; } Ok(PackedWeatherCell { @@ -213,14 +209,9 @@ where let mut written = 0usize; for lat_idx in 0..LAT_COUNT { for lon_idx in 0..LON_COUNT { - let cell = pack_cell( - classid, - lat_idx, - lon_idx, - manifest, - floors, - |entry| value_of(lat_idx, lon_idx, entry), - ) + let cell = pack_cell(classid, lat_idx, lon_idx, manifest, floors, |entry| { + value_of(lat_idx, lon_idx, entry) + }) .map_err(BakeStreamError::Bake)?; sink(cell).map_err(BakeStreamError::Sink)?; written += 1; @@ -330,24 +321,10 @@ mod tests { #[test] fn manifest_mutation_is_load_bearing_end_to_end() { - let a = pack_cell( - WEATHER_W1_CLASSID, - 10, - 20, - &manifest("a"), - &floors(), - value, - ) - .expect("a packs"); - let b = pack_cell( - WEATHER_W1_CLASSID, - 10, - 20, - &manifest("b"), - &floors(), - value, - ) - .expect("b packs"); + let a = pack_cell(WEATHER_W1_CLASSID, 10, 20, &manifest("a"), &floors(), value) + .expect("a packs"); + let b = pack_cell(WEATHER_W1_CLASSID, 10, 20, &manifest("b"), &floors(), value) + .expect("b packs"); assert_ne!( a.facet_image(), diff --git a/crates/weather-poc/src/canonical.rs b/crates/weather-poc/src/canonical.rs index fd0106593..8d7494a1d 100644 --- a/crates/weather-poc/src/canonical.rs +++ b/crates/weather-poc/src/canonical.rs @@ -166,8 +166,7 @@ pub fn assemble_row(cell: &PackedWeatherCell) -> Result [u8; NODE_ROW_STRIDE] { let mut out = [0u8; NODE_ROW_STRIDE]; out[0..16].copy_from_slice(row.key.as_bytes()); - out[16..28].copy_from_slice(&row.edges.in_family); - out[28..32].copy_from_slice(&row.edges.out_family); + out[16..32].copy_from_slice(row.edges.as_bytes()); out[VALUE_SLAB_ROW_OFFSET..].copy_from_slice(&row.value); out } @@ -202,9 +201,7 @@ where |lat, lon, entry| value_of(lat, lon, entry), |cell| { let row = assemble_row(&cell).map_err(SinkError::Canonical)?; - writer - .write_all(&row_bytes(&row)) - .map_err(SinkError::Io) + writer.write_all(&row_bytes(&row)).map_err(SinkError::Io) }, ); @@ -239,24 +236,26 @@ mod tests { fn fixture_floors() -> HashMap { let sample: Vec = (0..10_000).map(|i| i as f64 / 100.0).collect(); - HashMap::from([( - "f".to_string(), - calibrate(&sample).expect("fixture floor"), - )]) + HashMap::from([("f".to_string(), calibrate(&sample).expect("fixture floor"))]) } #[test] fn live_contract_places_weather_after_every_named_tenant() { let manifest = fixture_manifest(); let floors = fixture_floors(); - let cell = pack_cell(WEATHER_W1_CLASSID, 720, 1439, &manifest, &floors, |entry| { - match entry.variable.as_str() { + let cell = pack_cell( + WEATHER_W1_CLASSID, + 720, + 1439, + &manifest, + &floors, + |entry| match entry.variable.as_str() { "a" => Some(10.0), "b" => Some(20.0), "c" => Some(30.0), _ => None, - } - }) + }, + ) .expect("cell packs"); let row = assemble_row(&cell).expect("canonical row assembles"); @@ -265,18 +264,15 @@ mod tests { assert_eq!(weather_value_offset(), expected_offset); assert_eq!(row.key.as_bytes(), &cell.key); - assert!(row.edges.in_family.iter().all(|b| *b == 0)); - assert!(row.edges.out_family.iter().all(|b| *b == 0)); + assert!(row.edges.as_bytes().iter().all(|b| *b == 0)); assert!(row.value[..expected_offset].iter().all(|b| *b == 0)); assert_eq!( &row.value[expected_offset..expected_offset + W1_IMAGE_LEN], &image ); - assert!( - row.value[expected_offset + W1_IMAGE_LEN..] - .iter() - .all(|b| *b == 0) - ); + assert!(row.value[expected_offset + W1_IMAGE_LEN..] + .iter() + .all(|b| *b == 0)); assert!(VALUE_SLAB_LEN - expected_offset >= W1_IMAGE_LEN); } @@ -284,11 +280,13 @@ mod tests { fn canonical_serialization_is_exactly_512_bytes_and_key_agrees() { let manifest = fixture_manifest(); let floors = fixture_floors(); - let cell = pack_cell(WEATHER_W1_CLASSID, 123, 456, &manifest, &floors, |_| Some(42.0)) - .expect("cell packs"); + let cell = pack_cell(WEATHER_W1_CLASSID, 123, 456, &manifest, &floors, |_| { + Some(42.0) + }) + .expect("cell packs"); let mut row = assemble_row(&cell).expect("row"); - row.edges.in_family[0] = 0xA5; - row.edges.out_family[3] = 0x5A; + row.edges.as_bytes_mut()[0] = 0xA5; + row.edges.as_bytes_mut()[15] = 0x5A; let bytes = row_bytes(&row); let expected_offset = ValueSchema::Full.tenant_bytes(); From 43d460cbfcc9fc039037041af3fabb128edbd808 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 05:58:19 +0000 Subject: [PATCH 5/9] facet: fail loud on big-endian; pin the reinterpret LE byte image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex P2 on #1246. Aliasing `EdgeBlock` to `FacetCascade` moved a STORED row field (`NodeRow::edges`) from a byte-backed type to a typed one, and that introduced an endianness dependency where none existed. `FacetCascade::as_bytes` / `ref_from_bytes` are pure pointer reinterprets, so the struct's memory IS the canonical LE row image — and `facet_classid` is a native-endian `u32`. On a big-endian target `from_bytes` (explicitly `u32::from_le_bytes`) and `as_bytes` disagree on bytes [0..4), silently byte-swapping a non-zero class id through `NodeRowPacket::as_le_bytes` / `row_bytes`. The predecessor `EdgeBlock { in_family: [u8; 12], out_family: [u8; 4] }` was byte-backed and endian-independent, so this is a real regression in kind, not a pre-existing property: `NodeGuid` is still `[u8; 16]`. Latent today on two counts — no target_endian handling exists anywhere in the tree and no BE target is built, and every production writer uses `EdgeBlock::default()` so the class id is always zero (which byte-swaps to itself). Both mitigations evaporate the moment something mints a real `facet_classid`, which is exactly the trigger the ISS row already names. Minimal fix, chosen over the two wider ones: - NOT changing `facet_classid` to `[u8; 4]` with accessors — that breaks every consumer that reads the field, medcare-gotham included. - NOT encoding at the packet boundary — that defeats the zero-copy law. - A `const _: () = assert!(cfg!(target_endian = "little"), ...)` beside the existing size asserts: a build error instead of a corrupted row image, in the idiom the file already uses. No consumer breaks; every target the workspace builds (x86-64, aarch64, wasm32, thumbv6m) is little-endian. Also pins the property that had no test. The suite asserted `to_bytes` (the explicit LE codec) and never `as_bytes` (the reinterpret that serialization actually uses); the new test asserts they are equal, on the fixture's non-zero 0xDEAD_BEEF class id, since a zero id byte-swaps to itself and would hide the divergence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HScwwezRdMxFfTs3WLG19d --- crates/lance-graph-contract/src/facet.rs | 56 ++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/lance-graph-contract/src/facet.rs b/crates/lance-graph-contract/src/facet.rs index 5d79abe6c..6a0af6179 100644 --- a/crates/lance-graph-contract/src/facet.rs +++ b/crates/lance-graph-contract/src/facet.rs @@ -99,6 +99,24 @@ pub struct FacetCascade { pub tiers: [FacetTier; 6], } +// The facet is a STORED row field (`NodeRow::edges`), and `as_bytes` / +// `ref_from_bytes` are pure pointer reinterprets — so the struct's in-memory +// image IS the canonical LE row image. `facet_classid` is a native-endian +// `u32`, which makes that identity hold on little-endian targets ONLY: on a +// big-endian target `from_bytes` (explicitly `u32::from_le_bytes`) and +// `as_bytes` (a reinterpret) would disagree on bytes `[0..4)`, silently +// byte-swapping a non-zero class id through serialization. The predecessor +// type at this row offset (`EdgeBlock { in_family: [u8; 12], out_family: +// [u8; 4] }`) was byte-backed and so endian-independent; aliasing it to this +// typed facet is what introduced the dependency. Fail LOUD at compile time +// rather than corrupt a row image at runtime — the round trip is pinned by +// `le_byte_image_round_trips_with_a_non_zero_classid` below. +// (codex P2 on PR #1246; `ISS-EDGE-BLOCK-WAS-A-SECOND-TYPE-FOR-THE-SAME-FACET`.) +const _: () = assert!( + cfg!(target_endian = "little"), + "FacetCascade's reinterpret-based LE byte image assumes a little-endian target" +); + const _: () = assert!(core::mem::size_of::() == 2, "one 8:8 tile"); const _: () = assert!( core::mem::size_of::() == 16, @@ -674,6 +692,44 @@ mod tests { ); } + /// The **reinterpret** view and the **explicit LE codec** must agree byte for + /// byte on a non-zero `facet_classid`. + /// + /// These are two different mechanisms, and only one of them was pinned before: + /// `to_bytes` encodes with `u32::to_le_bytes`, while `as_bytes` (and + /// `ref_from_bytes`) reinterpret the struct's own memory — which is what + /// `NodeRowPacket::as_le_bytes` and `row_bytes` serialize, now that a facet is + /// a STORED row field (`NodeRow::edges`). A zero class id byte-swaps to itself + /// and would hide the divergence, so the fixture's `0xDEAD_BEEF` is doing the + /// work here. The compile-time `target_endian` guard above is what keeps this + /// property from being merely asserted on the one target that satisfies it. + /// (codex P2 on PR #1246.) + #[test] + fn le_byte_image_round_trips_with_a_non_zero_classid() { + let b = sample(); + let f = FacetCascade::from_bytes(&b); + assert_ne!(f.facet_classid, 0, "a zero class id cannot detect a swap"); + assert_ne!( + f.facet_classid.swap_bytes(), + f.facet_classid, + "the fixture class id must be byte-order sensitive" + ); + + assert_eq!( + f.as_bytes(), + &f.to_bytes(), + "the reinterpret view must equal the explicit LE encoding" + ); + assert_eq!(f.as_bytes(), &b, "and both must equal the source bytes"); + + // The borrowed no-op decode reads the same image. + let aligned = FacetCascade::from_bytes(&b); + assert_eq!( + FacetCascade::ref_from_bytes(aligned.as_bytes()), + Some(&aligned) + ); + } + #[test] fn redout_is_granularity_free_and_orthogonal() { let f = FacetCascade::from_bytes(&sample()); From 11fe89c228a7966c2b1e80ab170ade5cc16c50e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 06:22:30 +0000 Subject: [PATCH 6/9] mask-risc: count Pred::Range apart; correct two stale NodeRow comments Three review findings from #1246, each verified against the code first. 1. `OpHistogram` counted `Pred::Range` as a value-lane predicate (CodeRabbit). It reads no lane, and `mask_passes()` excluded its destination write, so a range-only program reported one predicate and ZERO mask passes -- the one shape a cost model reads as free. Adds a `ranges` field, counts Range there, and includes it in `mask_passes()`. NOT the "three mask-word passes" the finding asserted: reading `mask_set_range` in ndarray, every word of `out_words` is written exactly once across disjoint segments (two zero-fills, then head/body/tail of the range). That is ONE linear pass, and one is what is charged. New test asserts a range lands in `ranges` not `predicates` and costs one pass, and that a bare lane predicate still costs zero. 2. Two `NodeRow` SAFETY comments went stale with the `EdgeBlock` alias, and they are the second, independent reinterpret path for the same endian exposure codex flagged. Both still said EdgeBlock is a byte array: `as_le_bytes` claimed "EdgeBlock is plain [u8;_]" and therefore that the cast yields the LE packet with no translation; `node_rows_from_le_bytes` claimed "EdgeBlock is [u8;16]" as its no-niche argument. The soundness conclusions both survive -- `FacetCascade` is 16 B `repr(C, align(16))` with all-integer fields, so no niche -- but the stated reasons were false, and the `as_le_bytes` one is exactly the LE claim. Both now name `FacetCascade` and point at the crate-level little-endian assert in `facet` that this path relies on. Nothing else changes; the assert already covered these call sites. 3. `lance-graph-quack` still asserted `mask_set_range` "is absent from `ndarray::simd`" four lines above a note saying it is present. The stale clause is struck in place rather than deleted, so why the lowering was once blocked stays legible. DECLINED, with reason: the finding that `D-MRX-7` should be prepended to STATUS_BOARD. That board's tables are ordered ascending by D-id -- the same table runs D-MRX-0 through -7, and the three-carrier table runs D-TCF-1 through -5. Prepending would break the ordering it is meant to preserve. The prepend rule in CLAUDE.md names the append-only ledgers (EPIPHANIES, PR_ARC_INVENTORY, INTEGRATION_PLANS); STATUS_BOARD is described there as a deliverable dashboard with no ordering rule. 1355 contract tests, 41 mask-risc tests green; clippy and fmt clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HScwwezRdMxFfTs3WLG19d --- .../src/canonical_node.rs | 21 +++++-- crates/lance-graph-mask-risc/src/ir.rs | 58 ++++++++++++++++++- crates/lance-graph-quack/src/lib.rs | 8 ++- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/crates/lance-graph-contract/src/canonical_node.rs b/crates/lance-graph-contract/src/canonical_node.rs index 1221fee42..5039b5be7 100644 --- a/crates/lance-graph-contract/src/canonical_node.rs +++ b/crates/lance-graph-contract/src/canonical_node.rs @@ -1679,9 +1679,15 @@ impl<'a> SoaEnvelope for NodeRowPacket<'a> { // alignment of NodeRow (64) ⊇ alignment of u8 (1)). // // The NodeGuid and EdgeBlock fields hold their bytes in canon-LE - // order (NodeGuid::new uses to_le_bytes; EdgeBlock is plain [u8;_]), - // so the resulting byte slice IS the envelope's LE packet — no - // translation needed at the boundary. + // order, so the resulting byte slice IS the envelope's LE packet — no + // translation needed at the boundary. NodeGuid earns that by being + // `[u8; 16]` outright. EdgeBlock does NOT: since the 2026-09-17 alias + // it is a `FacetCascade`, whose `facet_classid` is a native-endian + // `u32`, so this cast reproduces the LE packet only on a little-endian + // target. That is enforced, not assumed — `facet::` carries a + // crate-level `const _: () = assert!(cfg!(target_endian = "little"))` + // for exactly this path (codex P2 on #1246). Do not restate EdgeBlock + // as a byte array here; it is the one field that is not. unsafe { core::slice::from_raw_parts( self.rows.as_ptr().cast::(), @@ -1733,9 +1739,12 @@ pub fn node_rows_from_le_bytes(bytes: &[u8]) -> Option<&[NodeRow]> { // (const-asserted above). We checked (1) bytes.len() is an exact multiple of // the stride, so n rows span the whole slice with no trailing bytes, and (2) // the pointer is aligned to align_of::() (64). Every bit pattern in - // the 512 bytes is a valid NodeRow (NodeGuid is bytes, EdgeBlock is [u8;16], - // value is [u8;480] — no niche/enum to invalidate), so the reinterpretation - // is sound. The returned slice borrows `bytes` for its lifetime (no copy). + // the 512 bytes is a valid NodeRow (NodeGuid is `[u8; 16]`; EdgeBlock is a + // `FacetCascade`, 16 B `repr(C, align(16))` whose fields are all plain + // integers, so it too has no niche; value is `[u8; 480]`) — nothing to + // invalidate, so the reinterpretation is sound. Soundness does not imply + // byte-identity across targets: see `as_le_bytes` above and the + // little-endian assert in `facet::` that this path also relies on. The returned slice borrows `bytes` for its lifetime (no copy). Some(unsafe { core::slice::from_raw_parts(bytes.as_ptr().cast::(), n) }) } diff --git a/crates/lance-graph-mask-risc/src/ir.rs b/crates/lance-graph-mask-risc/src/ir.rs index 0917dfc3d..6ee31afbb 100644 --- a/crates/lance-graph-mask-risc/src/ir.rs +++ b/crates/lance-graph-mask-risc/src/ir.rs @@ -300,6 +300,13 @@ impl Program { let mut h = OpHistogram::default(); for op in &self.ops { match op { + // `Pred::Range` reads NO value lane (it is a row-index + // predicate), so it is not a value-lane predicate pass; it + // spends one linear write over the destination mask instead. + MaskOp::Pred { + pred: Pred::Range { .. }, + .. + } => h.ranges += 1, MaskOp::Pred { .. } => h.predicates += 1, MaskOp::And { .. } | MaskOp::Or { .. } @@ -324,12 +331,19 @@ pub struct OpHistogram { pub not: usize, /// Three-input passes. pub ternlog: usize, + /// Row-index range writes (`Pred::Range`) — counted apart from + /// [`predicates`](Self::predicates) because they read no value lane. + /// Each is ONE pass over the destination mask: `mask_set_range` writes + /// every word of `out_words` exactly once across disjoint segments (the + /// two zero-fills below/above the range, then the head/body/tail of the + /// range itself), never re-walking the whole mask per segment. + pub ranges: usize, } impl OpHistogram { /// Total mask-word passes the program spends after its predicates. pub fn mask_passes(&self) -> usize { - self.two_input + self.not + self.ternlog + self.two_input + self.not + self.ternlog + self.ranges } } @@ -426,6 +440,45 @@ mod tests { /// predicate as a mask pass (predicates sweep VALUE lanes and are /// reported separately). Fixture: one of each kind, so every counter is /// exactly 1 and the pass total is 4. + #[test] + /// `Pred::Range` is a row-index predicate: it reads no value lane, so it + /// must NOT land in `predicates`, and its destination write must be + /// counted in `mask_passes()`. Before this split a range-only program + /// reported one predicate and ZERO mask passes, which is the one shape a + /// cost model would read as free. (CodeRabbit, PR #1246.) + #[test] + fn range_is_counted_apart_from_value_lane_predicates() { + let range_only = Program::new( + vec![MaskOp::Pred { + pred: Pred::Range { lo: 3, hi: 9 }, + under: None, + dst: 0, + }], + Terminal::Count { + mask: Operand::Scratch(0), + }, + ); + let h = range_only.op_histogram(); + assert_eq!(h.ranges, 1, "the range must be counted"); + assert_eq!(h.predicates, 0, "a range reads no value lane"); + assert_eq!(h.mask_passes(), 1, "and its write is not free"); + + // A lane-reading predicate still counts as one, and not as a range. + let lane_only = Program::new( + vec![MaskOp::Pred { + pred: Pred::GtI32 { lane: 0, t: 3 }, + under: None, + dst: 0, + }], + Terminal::Count { + mask: Operand::Scratch(0), + }, + ); + let h2 = lane_only.op_histogram(); + assert_eq!((h2.predicates, h2.ranges), (1, 0)); + assert_eq!(h2.mask_passes(), 0, "a bare lane predicate spends none"); + } + #[test] fn op_histogram_counts_each_physical_kind_once() { let p = Program::new( @@ -463,7 +516,8 @@ mod tests { predicates: 1, two_input: 1, not: 1, - ternlog: 1 + ternlog: 1, + ranges: 0, } ); assert_eq!(h.mask_passes(), 3); diff --git a/crates/lance-graph-quack/src/lib.rs b/crates/lance-graph-quack/src/lib.rs index 228385a32..78c4d6cbf 100644 --- a/crates/lance-graph-quack/src/lib.rs +++ b/crates/lance-graph-quack/src/lib.rs @@ -462,9 +462,13 @@ impl Filter { /// /// A ternary match — a full sweep of the lane, one pass, no allocation. It /// is NOT yet a range WRITE. The range write is `mask_set_range`, the - /// matrix's T1 gap G6, and it is absent from `ndarray::simd`; per the + /// matrix's T1 gap G6. ~~and it is absent from `ndarray::simd`; per the /// missing-capability STOP rule a consumer does not hand-roll it one layer - /// up, so this crate spells the PREDICATE and waits for the primitive. + /// up, so this crate spells the PREDICATE and waits for the primitive.~~ + /// ⊘ STRUCK — that clause is false as of the note below, which says the + /// opposite; it is kept struck rather than deleted so the reason the + /// lowering was once blocked stays legible. The STOP rule still applies; + /// what changed is that the capability is no longer missing. /// /// ⊘ 2026-09-17 (`ISS-MASK-RISC-HAD-NO-RANGE-OP`): both halves of that /// paragraph have moved. `mask_set_range` IS on `ndarray::simd` From 3800dc38b7de8af2b1455a3492c6149297134897 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 06:25:26 +0000 Subject: [PATCH 7/9] mask-risc: drop the doubled #[test] my previous commit left CI clippy went red on 11fe89c2 with `duplicated attribute` at ir.rs:449. Mine, and my own fault twice over. The insertion anchored on the existing `fn op_histogram_counts_...` line, which put the new doc comment and `#[test]` BETWEEN that function's existing `#[test]` and its signature. The old attribute then landed on the new test, which carried its own, so the new test had two and the arrangement compiled fine -- `duplicated_attributes` is warn-by-default. Why it reached CI: the local clippy run omitted `-D warnings`, which every gating step in style.yml passes. A warn-level lint is invisible without it, so "clippy clean" locally meant nothing about the gate. Reproduced all three mandatory invocations verbatim this time (contract `--lib --tests`, mask-risc `--all-targets`, quack `--all-targets --all-features`, each with `-D warnings`); all three are clean, both tests still carry exactly one attribute and both still run, fmt clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HScwwezRdMxFfTs3WLG19d --- crates/lance-graph-mask-risc/src/ir.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/lance-graph-mask-risc/src/ir.rs b/crates/lance-graph-mask-risc/src/ir.rs index 6ee31afbb..5816c7fc7 100644 --- a/crates/lance-graph-mask-risc/src/ir.rs +++ b/crates/lance-graph-mask-risc/src/ir.rs @@ -440,7 +440,6 @@ mod tests { /// predicate as a mask pass (predicates sweep VALUE lanes and are /// reported separately). Fixture: one of each kind, so every counter is /// exactly 1 and the pass total is 4. - #[test] /// `Pred::Range` is a row-index predicate: it reads no value lane, so it /// must NOT land in `predicates`, and its destination write must be /// counted in `mask_passes()`. Before this split a range-only program From db079c4ff2ec290ae97bafcdb84fb9f915c6d30b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 09:26:31 +0000 Subject: [PATCH 8/9] deps: float the lance family patch; never pin to .0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling 2026-09-18: "never pin to x.00, always float x.*" / "so no decimal .0.0". Eight exact-equals pins carried a .0.0 and are retired: Cargo.toml lance =11.0.0 -> 11.*, lance-linalg, lance-index, lancedb =0.38.0 -> 0.38.* holograph lance lance-graph-catalog lance-namespace lance-graph lance-namespace, lance-arrow Floating the patch is strictly SAFER here, not looser, and this manifest already logs why. Lines 143-146 record a graph cargo could NOT satisfy, because our `lance = "=6.0.1"` was narrower than lancedb's transitive `lance = "=6.0.0"`. An exact pin on our side can only ever be equal to or narrower than what the family asks for, so it buys nothing and breaks the moment lancedb's transitive requirement moves by a patch. The lockstep fact the old rationale rested on still holds; what changes is that the deliberate bump is now the MAJOR (11 -> 12), which floating the patch does not touch. The struck rationale is kept in the comment rather than deleted, so the reason the pins were once exact stays legible. arrow and datafusion were already caret and are untouched. VERIFIED as a resolution no-op. A throwaway `cargo generate-lockfile` before and after resolves the family byte-identically: arrow 58.4.0 · datafusion 54.1.0 · lancedb 0.38.0 lance / lance-arrow / lance-index / lance-linalg / lance-namespace 11.0.0 `11.*` resolves to whatever lancedb demands -- today the identical 11.0.0 -- and now survives a lancedb patch bump instead of deadlocking against it. The throwaway lock was deleted after reading, per the repo's no-tracked-lock rule. All three mandatory clippy invocations clean under `-D warnings`; fmt clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HScwwezRdMxFfTs3WLG19d --- Cargo.toml | 31 ++++++++++++++++++--------- crates/holograph/Cargo.toml | 2 +- crates/lance-graph-catalog/Cargo.toml | 2 +- crates/lance-graph/Cargo.toml | 4 ++-- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6152562cf..bbb12ede0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -221,9 +221,9 @@ resolver = "2" # Crate-level pins (canonical source of truth — workspace.dependencies is not # used; each consumer crate declares its own pin with exact-equals so the # whole graph resolves to the same semver): -# lance = "=11.0.0" (lance-arrow / lance-index / lance-linalg / +# lance = "11.*" (lance-arrow / lance-index / lance-linalg / # lance-namespace bump in lock-step) -# lancedb = "=0.38.0" +# lancedb = "0.38.*" # datafusion = "54" (+ datafusion-common / -expr / -sql / # -functions-aggregate) # arrow-array = "58" (+ arrow-schema / -buffer / -ipc / arrow) @@ -294,7 +294,7 @@ resolver = "2" # fork of `aws-smithy-json` fixed against `aws-smithy-types 1.7` -- the two # call sites need `.into()` and one wildcard arm; upstream's own compiler # hints spell both out. -lance = { version = "=11.0.0", default-features = false, features = [ +lance = { version = "11.*", default-features = false, features = [ "azure", "gcp", "oss", @@ -304,13 +304,24 @@ lance = { version = "=11.0.0", default-features = false, features = [ "goosefs", "geo", ] } -lance-linalg = "=11.0.0" -lance-index = "=11.0.0" -lancedb = { version = "=0.38.0", default-features = false } -# Exact, not caret: the lance family moves in EXACT lockstep -# (E-PIN-LANCE9-LANCEDB033-DF541-ARROW58-NO-DF53-1), and with no tracked -# lock the caret ranges these replaced would have taken the newest 58.x / -# 54.x silently. A bump is one deliberate PR — this is where it happens now. +lance-linalg = "11.*" +lance-index = "11.*" +lancedb = { version = "0.38.*", default-features = false } +# ⊘ OPERATOR RULING 2026-09-18 — NEVER PIN TO `.0.0`; FLOAT THE PATCH. +# *"never pin to x.00, always float x.*"* / *"so no decimal .0.0"*. The +# `=X.Y.0` exact-equals these replaced are RETIRED. Floating the patch is +# strictly SAFER here, not looser, and the manifest already records why: +# lines 143-146 above log a graph cargo could NOT satisfy, because our +# `lance = "=6.0.1"` was narrower than lancedb's transitive `lance = +# "=6.0.0"`. An exact pin on OUR side can only ever be equal to or narrower +# than what the family asks for, so it buys nothing and breaks the moment +# lancedb's transitive requirement moves by a patch. `11.*` resolves to +# whatever lancedb demands — today the identical 11.0.0 — and survives that +# move. ⊘ The struck rationale ("Exact, not caret: the lance family moves in +# EXACT lockstep ... A bump is one deliberate PR") is kept here as the reason +# the pins were once exact; the lockstep FACT still holds, and the deliberate +# bump is now the MAJOR (11 → 12), which floating the patch does not touch. +# arrow/datafusion were already caret and are unaffected. arrow = "58" arrow-array = "58" arrow-schema = "58" diff --git a/crates/holograph/Cargo.toml b/crates/holograph/Cargo.toml index 3ec9ca1c9..5c88e2cac 100644 --- a/crates/holograph/Cargo.toml +++ b/crates/holograph/Cargo.toml @@ -36,7 +36,7 @@ arrow-buffer = "58" datafusion = { version = "54", optional = true } # Lance columnar format — 100x faster random access than Parquet -lance = { version = "=11.0.0", optional = true, default-features = false } +lance = { version = "11.*", optional = true, default-features = false } # Async runtime tokio = { version = "1.49", features = ["rt-multi-thread", "macros"], optional = true } diff --git a/crates/lance-graph-catalog/Cargo.toml b/crates/lance-graph-catalog/Cargo.toml index f910bb523..9c3c2123f 100644 --- a/crates/lance-graph-catalog/Cargo.toml +++ b/crates/lance-graph-catalog/Cargo.toml @@ -14,7 +14,7 @@ categories = ["database", "data-structures", "science"] arrow-schema = { workspace = true } async-trait = "0.1" datafusion = { workspace = true } -lance-namespace = "=11.0.0" +lance-namespace = "11.*" reqwest = { version = "0.12", features = ["json"], optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/lance-graph/Cargo.toml b/crates/lance-graph/Cargo.toml index 3b6e1571d..79f4b997d 100644 --- a/crates/lance-graph/Cargo.toml +++ b/crates/lance-graph/Cargo.toml @@ -50,7 +50,7 @@ lance-graph-catalog = { path = "../lance-graph-catalog", version = "0.5.4" } lance-graph-contract = { path = "../lance-graph-contract" } lance = { workspace = true } lance-linalg = { workspace = true } -lance-namespace = "=11.0.0" +lance-namespace = "11.*" lancedb = { workspace = true, optional = true } nom = "7.1" serde = { version = "1", features = ["derive"] } @@ -142,7 +142,7 @@ lite-unified = [] deepnsm = { path = "../deepnsm" } lance-graph-contract = { path = "../lance-graph-contract" } futures = "0.3" -lance-arrow = "=11.0.0" +lance-arrow = "11.*" lance-index = { workspace = true } tempfile = "3" # D-MW-P2 probe only: `ShardWriterConfig::shard_id` is a `Uuid`, so the probe From abbd14777143df879110653d6085aff295829159 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 09:34:54 +0000 Subject: [PATCH 9/9] mask-risc: a gated Pred::Range spends two passes, not one CodeRabbit on #1246, verified and correct. `Pred::Range` with `under: Some(_)` reaches `exec.rs:541`, which runs `mask_set_range` and THEN `mask_and_assign`. My `ranges` counter charged one pass for both, so `mask_passes()` under-reported a gated range by exactly the intersection. Checked the asymmetry before fixing it, because charging every gated predicate would have been wrong: `run_pred` gives every LANE predicate a fused `*_to_mask_under` kernel (gt/lt/ge/le/eq/ne over i32 and u32, match), so gating one of those costs NOTHING extra and `predicates += 1` stays right. Range is the sole exception -- there is no `mask_set_range_under`. Charged to `two_input` rather than a new field, because the second pass literally IS one: `mask_and_assign` is an `and`, which is what `two_input` documents itself as counting. That also leaves the asymmetry visible in the histogram instead of hidden behind a range-specific name. Closing it for real needs the fused primitive upstream; named in the doc comment as `mask_set_range_under`. The new test is two-sided on purpose. Asserting only that a gated range costs two would not show the cost is specific to it, so it also asserts a gated lane predicate still costs zero. Without the second half the test would pass against a change that charged every gate. 41 mask-risc tests green; all three mandatory clippy invocations clean under `-D warnings`; fmt clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HScwwezRdMxFfTs3WLG19d --- crates/lance-graph-mask-risc/src/ir.rs | 64 +++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/lance-graph-mask-risc/src/ir.rs b/crates/lance-graph-mask-risc/src/ir.rs index 5816c7fc7..9fd4b0a14 100644 --- a/crates/lance-graph-mask-risc/src/ir.rs +++ b/crates/lance-graph-mask-risc/src/ir.rs @@ -303,10 +303,27 @@ impl Program { // `Pred::Range` reads NO value lane (it is a row-index // predicate), so it is not a value-lane predicate pass; it // spends one linear write over the destination mask instead. + // + // A GATED range spends a SECOND pass, and that is not true of + // any other predicate: every lane predicate has a fused + // `*_to_mask_under` kernel, so gating it costs nothing extra. + // There is no `mask_set_range_under`, so `exec` runs the gated + // range as `mask_set_range` followed by `mask_and_assign` — + // literally an `and`, which is what `two_input` already counts. + // Charged there rather than to a new field, so the asymmetry is + // visible in the histogram instead of hidden behind a name. + // (CodeRabbit, PR #1246. Closing this would need the fused + // primitive upstream, tracked as `mask_set_range_under`.) MaskOp::Pred { pred: Pred::Range { .. }, + under, .. - } => h.ranges += 1, + } => { + h.ranges += 1; + if under.is_some() { + h.two_input += 1; + } + } MaskOp::Pred { .. } => h.predicates += 1, MaskOp::And { .. } | MaskOp::Or { .. } @@ -337,6 +354,11 @@ pub struct OpHistogram { /// every word of `out_words` exactly once across disjoint segments (the /// two zero-fills below/above the range, then the head/body/tail of the /// range itself), never re-walking the whole mask per segment. + /// + /// A range under a gate spends a second pass, counted in + /// [`two_input`](Self::two_input) because it IS one: `exec` follows the + /// write with `mask_and_assign`. Unlike every lane predicate, which has a + /// fused `*_to_mask_under` kernel, no `mask_set_range_under` exists. pub ranges: usize, } @@ -478,6 +500,46 @@ mod tests { assert_eq!(h2.mask_passes(), 0, "a bare lane predicate spends none"); } + /// A GATED range costs two passes, and a gated LANE predicate costs none: + /// every lane predicate has a fused `*_to_mask_under` kernel, while the + /// range has no `mask_set_range_under`, so `exec` runs write-then-`and`. + /// Both halves matter — asserting only the range would not show that the + /// extra pass is specific to it. (CodeRabbit, PR #1246.) + #[test] + fn a_gated_range_costs_the_intersection_but_a_gated_lane_predicate_does_not() { + let gated_range = Program::new( + vec![MaskOp::Pred { + pred: Pred::Range { lo: 3, hi: 9 }, + under: Some(Operand::Plane(0)), + dst: 0, + }], + Terminal::Count { + mask: Operand::Scratch(0), + }, + ); + let h = gated_range.op_histogram(); + assert_eq!((h.ranges, h.two_input), (1, 1), "write + intersection"); + assert_eq!(h.mask_passes(), 2, "a gated range spends BOTH"); + + let gated_lane = Program::new( + vec![MaskOp::Pred { + pred: Pred::GtI32 { lane: 0, t: 3 }, + under: Some(Operand::Plane(0)), + dst: 0, + }], + Terminal::Count { + mask: Operand::Scratch(0), + }, + ); + let h2 = gated_lane.op_histogram(); + assert_eq!( + (h2.predicates, h2.two_input), + (1, 0), + "the fused `*_to_mask_under` kernel adds no pass" + ); + assert_eq!(h2.mask_passes(), 0, "so gating a lane predicate is free"); + } + #[test] fn op_histogram_counts_each_physical_kind_once() { let p = Program::new(