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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/board/STATUS_BOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ primitive" to "add a fusion rule".
| D-WFL-EXPR | **Mask algebra is globally NON-MATERIALIZING by default.** A mask EXPRESSION denotes membership; it does not imply a bitmap exists. Materialization happens only at an explicit TERMINAL, when the membership set is requested as a carrier. Stronger than "folds are zero-copy" because folding, masking, ternlog, gating, projection and reduction all join ONE algebra — the expression stays unevaluated as population state all the way to a low-entropy terminal | Queued | three concepts kept distinct in every plan: MASKING (operation) · MASK EXPRESSION (composition) · MATERIALIZED MASK (bitmap). Falsified if a plan cannot express a multi-operand masking chain that emits no membership bits |
| D-WFL-MASKOP | ⊘ **`MaskOp` must not semantically mean "produce a Scratch mask" — it must mean CONTRIBUTE TO A MASK EXPRESSION.** Scratch is one physical LOWERING, never the semantics. Read-verified: `Terminal::Keep{mask}` (`ir.rs:184`, *"the final mask itself stays in `mask`… nothing is reduced"*) IS the materialization election, but `MaskOp::And{a,b,dst}` is `dst = a & b` — every op is an assignment, so the ops destroy at level N−1 the choice the terminals encode at level N, and `exec.rs:566` then forces every slot to `words_for(n_rows)` | Queued | **This is the deepest correction in the arc and it precedes W0/W1.** Falsified if changing `MaskOp` semantics does not remove the need for per-op fixes |
| D-WFL-SEAMB″ | ⊘ `Pred::Range → Scratch` is a **SYMPTOM, not the disease** — every earlier framing (performance complaint · T1 conformance failure · fold-law violation · absent decision) was chasing one op. Fixing `Range` alone leaves `And`/`Or`/`Xor`/`AndNot`/`Ternlog` all writing full planes | Queued | the fix is judged at the execution MODEL, not at one variant |
| D-WFL-FUSE | **Half the "missing primitives" dissolve into lowering rules.** The fused `popcount(a & b)` of `D-WFL-T1-FUSED` is not a bespoke instruction — it is what a fuser emits for `MaskExpr → Terminal::Count`. `fuse.rs` already collapses a Boolean tree into one ternlog; what it does NOT do is fuse across the **op → terminal** boundary, which is exactly the boundary D-WFL-MASKOP moves | Queued | re-audit every "missing op" against this before minting any. A primitive that a fusion rule could emit is not a primitive |
| D-WFL-FUSE | **Half the "missing primitives" dissolve into lowering rules.** The fused `popcount(a & b)` of `D-WFL-T1-FUSED` is not a bespoke instruction — it is what a fuser emits for `MaskExpr → Terminal::Count`. `fuse.rs` already collapses a Boolean tree into one ternlog; what it does NOT do is fuse across the **op → terminal** boundary, which is exactly the boundary D-WFL-MASKOP moves | In PR for the single-op case — `Program::fused_ternlog` folds one resident 2/3-input op → Count/Any; multi-op trees (fuse.rs → one ternlog → this fold) still OPEN | re-audit every "missing op" against this before minting any. A primitive that a fusion rule could emit is not a primitive |

**Why it took a day, so it is not repeated:** the design already encoded the
distinction (`Keep` vs `Count`; `WideFieldMask` as a field-PARTICIPATION
Expand Down Expand Up @@ -70,7 +70,7 @@ defines what a fold IS — never what the machine may do.
| D-WFL-SIBLING | **The rule governs the TRANSITION, not the bytes:** crossing from fold-native to mask-native execution must be deliberate and visible at the T2 planning membrane. Once MASK is elected, behaving like a mask engine (AND → TERNLOG → shift → cache) is legitimate. Forbidden only: the planner believes it is folding, a helper silently allocates `words_for(N)`, and nobody made the decision | Queued | the BBB question must be answerable for every plan: *who elected the mask, on what basis?* Falsified if a plan can become mask-native without an election appearing in it |
| D-WFL-SEAMB′ | ⊘ **restates Seam B more precisely than every earlier framing** (performance complaint · T1 conformance failure · fold-law violation — all circling this). The defect in `Pred::Range` is NOT that it writes a mask. It is that the planner can neither elect nor decline: there is exactly ONE path, so **the choice does not exist**. Seam B is an ABSENT DECISION, not a present mask | Queued | fixed when both paths exist and the plan records which was taken — not when the mask disappears |
| D-WFL-W2b″ | ⊘ **supersedes D-WFL-W2b′'s "must not write".** W2b demonstrates BOTH legal paths over identical semantics: FOLD-NATIVE (`Range ∩ resident → Count/Any`, no second mask) and MASK-NATIVE (`→ a bounded/cached mask` because a consumer reuses it). Pipeline vs materialize | Queued | the two arms differentially checked against each other AND the oracle — identical row sets, identical Count/Any. The earlier "zero derived buffers, asserted by counter" gate now scopes to the FOLD arm only |
| D-WFL-T1-FUSED′ | ⊘ upgrade from optimization to **enabler**: without a fused `popcount(a & b)` over a span there is no intermediate-buffer-free path, so **the fold-native arm does not exist at all**. The primitive CREATES the choice — which is exactly why Seam B had no decision in it | Refuted for `Range ∩ plane` (existing `popcount_batch_u64`/`mask_any` over the borrowed span suffice); plane∩plane `and_popcount` still OPEN | unchanged differential gate vs `mask_and` + `popcount_batch_u64`; the framing change raises its priority from nice-to-have to W2b-blocking |
| D-WFL-T1-FUSED′ | ⊘ upgrade from optimization to **enabler**: without a fused `popcount(a & b)` over a span there is no intermediate-buffer-free path, so **the fold-native arm does not exist at all**. The primitive CREATES the choice — which is exactly why Seam B had no decision in it | In PR — plane∩plane closed as the `AND2` table of the generalized `mask_ternlog_popcount`/`_any` (ndarray #322; no new ISA primitive — `U64x8` composition suffices); `entries/2026-09-23-ternlog-count-any-fold.md` | unchanged differential gate vs `mask_and` + `popcount_batch_u64`; the framing change raises its priority from nice-to-have to W2b-blocking |
| D-WFL-ELECT | the election rule, static first: `terminal Count → FOLD`; `one AND then Count → probably FOLD`; `reuse_count > 1 → consider MASK`; `shared cached result → MASK`; `Wabe frontier reused → maybe MASK`; `~11 ns cached mask → almost certainly MASK`. DuckDB-style dynamic costing later | Queued | static rules must be inspectable in the plan. Falsified if the rule set fires the same way on every program (it would carry no information — cf. the can-it-stay-silent twin) |

## D-WFL — the cache scoping (2026-09-19): frozen is fine, marching is the disaster
Expand Down Expand Up @@ -106,7 +106,7 @@ newly written derived buffer.
| D-id | scope | status | gate |
|---|---|---|---|
| D-WFL-W2b′ | **respec: bounded composition must not WRITE the intersection.** `WRONG: Range × resident mask → write a bounded mask → Count/Any`. `RIGHT: peek only the intersecting resident words → AND in registers → Count/Any`. The moment the bounded mask is written the program has crossed into RECONSTRUCT — legitimately perhaps, but it is no longer a fold and must be named | Queued | zero derived buffers allocated or written between the bound and the terminal, asserted by counter. A bounded-mask write fails the wave even at 12 words |
| D-WFL-T1-FUSED | the clean case for the anti-zoo rule licensing a NEW T1 primitive: a fused `popcount(a[i] & b[i])` accumulated over a word span. It cannot be expressed by the existing algebra without an intermediate buffer, so it exposes a genuinely new zero-copy operation rather than a convenience | Queued | differential vs `mask_and` + `popcount_batch_u64` over the same span; identical answer, zero intermediate bytes. Falsified if composition already achieves it without a buffer |
| D-WFL-T1-FUSED | the clean case for the anti-zoo rule licensing a NEW T1 primitive: a fused `popcount(a[i] & b[i])` accumulated over a word span. It cannot be expressed by the existing algebra without an intermediate buffer, so it exposes a genuinely new zero-copy operation rather than a convenience | In PR — composition falsifier HELD at the ISA level (`U64x8` ternlog/popcnt everywhere); the slice loop landed generalized over all 256 tables (ndarray #322); differential gate met | differential vs `mask_and` + `popcount_batch_u64` over the same span; identical answer, zero intermediate bytes. Falsified if composition already achieves it without a buffer |

## D-WFL-L0 — the foundational ruling, to land BEFORE any W0/W1 code (2026-09-19)

Expand Down
58 changes: 58 additions & 0 deletions .claude/board/entries/2026-09-23-ternlog-count-any-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 2026-09-23 — Boolean membership → Count/Any without a mask (ternlog fold)

**Status:** MEASURED (fold shipped in PR, ndarray #322 + this lance-graph PR) · OPEN (multi-op trees, compare-count)
**D-ids:** D-WFL-T1-FUSED, D-WFL-T1-FUSED′, D-WFL-FUSE (single-op case). OQ-5 is untouched and still open.

## The question, and its answer
The question was the smallest T1 operation that lets a 2/3-input Boolean membership end in Count/Any without writing a mask, and whether the existing `U64x8` composition already does it without writing to memory.

**Answer (MEASURED):**
- **No new ISA primitive is needed.** `U64x8::{ternlog::<IMM>, popcnt, +, |, reduce_sum}` exists on every realization: avx512, avx2-polyfill, scalar, neon and wasm.
- **Only the slice loop was missing.** It belongs in T1, because mask-risc must not host a SIMD loop.
- **What landed in ndarray #322** is one family, not one function per op: `mask_ternlog_popcount::<IMM>` and `mask_ternlog_any::<IMM>`. A 2-input op is a table that ignores `c`.
- **Effect on the D-WFL-T1-FUSED rows:**
- D-WFL-T1-FUSED's own falsifier held ("falsified if composition already achieves it without a buffer") at the ISA level.
- Its differential gate (`mask_and` + `popcount_batch_u64`, identical answer, zero intermediate bytes) is met by the generalized function for all 256 tables.
- D-WFL-T1-FUSED′'s open "plane∩plane `and_popcount`" is closed as the `AND2` table of this family.

Probe: ndarray `examples/ternlog_fold_probe.rs` compares materialize-then-reduce against the register fold.

| backend | words | Count gain | Any gain (worst case, no hit anywhere) |
|---|---|---|---|
| avx2 | 16 384 | 1.27× | 3.24× |
| avx2 | 262 144 | 1.50× | 6.18× |
| avx512 | 16 384 | 1.90× | 2.59× |
| avx512 | 262 144 | 1.94× | 4.39× |

- The first version of Any tested the accumulator after every chunk and lost to materializing on avx2 at 16 384 words (0.91×). The shipped form tests once per block of 8 chunks.
- A plain scalar fused loop about ties the register fold on avx2. **The win comes from not writing the mask, not from SIMD.**

## The mask-risc fold (CURRENT-CONTRACT, TEST-PINNED)
- **Shape recognised:** `Program::fused_ternlog()` matches a single `And`, `Or`, `Xor`, `AndNot` or `Ternlog` whose operands are all `Operand::Plane`, folded by `Count` or `Any` of its own `dst`. Like `fused_terminal`, this is a derived predicate. `requires_scratch()` now accounts for both.
- **Execution:**
- Whole words go through `ternlog_{popcount,any}_dispatch`, a generated 256-arm table checked by `--check` and by the arm-count test.
- Each word the extent cuts (at most two) is combined in a one-word register and ANDed with `edge_mask`.
- **Odd tables:**
- The population's last word counts as a cut word, so an odd table (true on all-zero inputs) never counts dead tail bits.
- The ndarray function counts at word level; the tail is the caller's to mask, and it is masked here.
- **Not fused** (these keep the tiled path): `Keep`, `All`, `Not`, scratch operands, and multi-op programs.

## Evidence
- **`tests/fused_ternlog.rs`:** the fold equals the Keep arm and a bit-serial oracle:
- the four 2-input ops across 7 populations (1 … 4133) × 4 plane shapes × absolute extents cut at 63/64/65/127/128/129;
- all 256 tables at 65 and 133 rows;
- the odd-table tail;
- zero allocation and an untouched poisoned arena, plus the paired half proving the probe can see a `Keep` carve;
- the recogniser's admit and refuse halves.
- **Disable runs,** made after committing and each failing as intended:
- recogniser off: 5 of 5 red;
- tail cut ignored: 3 red;
- head cut ignored: 2 red;
- wrong OR table: 1 red;
- edge word not masked: 3 red.
- **Probe:** `examples/ternlog_fused_probe.rs` at N = 1M rows, fold vs `Keep` + `popcount_batch_u64`. Count: 1.25–1.44× at 1 row, 3.4–7.4× at 1 %, 5.3–20× whole. The whole-population Any ratios (600–960×) are almost all early exit on dense planes, so they are **not** a like-for-like cost comparison; the worst-case figure for Any is the ndarray row above.

## What remains
- **Multi-op Boolean trees** (e.g. `(a & b) | !c` spread across ops) still materialize scratch. The next step is `fuse.rs` collapsing such a tree into ONE ternlog and handing that to this fold. Not started.
- **Lane predicate → Count/Any** (compare-count / compare-fold) is the next PR. Not started.
- **OQ-5** (thread/rayon vendor) is untouched. No scheduler work has started.
3 changes: 2 additions & 1 deletion .claude/board/entries/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately
opposite directions; the stranding this convention prevents shows up in
exactly one of them, never both.

151 entries, 2026-08-06 .. 2026-09-23.
152 entries, 2026-08-06 .. 2026-09-23.

| date | entry id | finding | file |
|---|---|---|---|
| 2026-09-23 | `ternlog-count-any-fold` | | [2026-09-23-ternlog-count-any-fold.md](2026-09-23-ternlog-count-any-fold.md) |
| 2026-09-23 | `terminal-elects-materialization-range-plane` | | [2026-09-23-terminal-elects-materialization-range-plane.md](2026-09-23-terminal-elects-materialization-range-plane.md) |
| 2026-09-23 | `quack-having-sym-sum-presence-mask` | | [2026-09-23-quack-having-sym-sum-presence-mask.md](2026-09-23-quack-having-sym-sum-presence-mask.md) |
| 2026-09-23 | `cubecl-llvm-boundary-and-audit-regrade` | | [2026-09-23-cubecl-llvm-boundary-and-audit-regrade.md](2026-09-23-cubecl-llvm-boundary-and-audit-regrade.md) |
Expand Down
171 changes: 171 additions & 0 deletions crates/lance-graph-mask-risc/examples/ternlog_fused_probe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
//! Boolean membership → Count/Any at N = 1M rows: the fold vs the kept mask.
//!
//! One op over three resident planes, two physical endings:
//!
//! - FOLD: `Count` / `Any` of the op's own slot — `Program::fused_ternlog`
//! lowers it onto `ndarray::simd::mask_ternlog_{popcount,any}`; no slot is
//! carved and no membership word is written.
//! - MATERIALIZE: the same op with a `Keep` terminal into an `Out::Mask`, then
//! `popcount_batch_u64` / `mask_any` over the kept words — the
//! bitmap-then-reduce shape the fold replaces.
//!
//! Reported per shape × extent: median ns for each arm and the membership
//! words the materializing arm writes (the fold writes none). Every result is
//! checked against a bit-serial scalar oracle over the same absolute rows.
//!
//! `cargo run --release -p lance-graph-mask-risc --example ternlog_fused_probe`

use std::time::Instant;

use lance_graph_mask_risc::exec::{execute_extent, Scratch};
use lance_graph_mask_risc::{
touched_words, Foreign, MaskOp, Operand, Out, Planes, Program, Terminal, Value,
};
use ndarray::simd::{mask_any, popcount_batch_u64};

fn lcg(seed: &mut u64) -> u64 {
*seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*seed >> 11
}

fn median<T>(reps: usize, mut f: impl FnMut() -> T) -> (f64, T) {
let mut ts = Vec::with_capacity(reps);
let mut last = None;
for _ in 0..reps {
let t = Instant::now();
last = Some(std::hint::black_box(f()));
ts.push(t.elapsed().as_nanos() as f64);
}
ts.sort_by(|a, b| a.total_cmp(b));
(ts[reps / 2], last.expect("reps > 0"))
}

fn main() {
let n = 1usize << 20;
let words = n.div_ceil(64);
let mut seed = 0x7E4F_u64;
let mut mk = |dense: bool| {
let mut w = vec![0u64; words];
for r in 0..n {
let x = lcg(&mut seed);
if (dense && !x.is_multiple_of(3)) || (!dense && x.is_multiple_of(29)) {
w[r / 64] |= 1 << (r % 64);
}
}
w
};
let (pa, pb, pc) = (mk(true), mk(true), mk(false));
let masks: [&[u64]; 3] = [&pa, &pb, &pc];
let planes = Planes {
n_rows: n,
masks: &masks,
lanes: &[],
};
let (a, b, c) = (Operand::Plane(0), Operand::Plane(1), Operand::Plane(2));
let shapes: [(&str, MaskOp, u8); 4] = [
("And", MaskOp::And { a, b, dst: 0 }, 0xC0),
("Xor", MaskOp::Xor { a, b, dst: 0 }, 0x3C),
(
"MAJ3",
MaskOp::Ternlog {
imm: 0xE8,
a,
b,
c,
dst: 0,
},
0xE8,
),
(
"NOR3",
MaskOp::Ternlog {
imm: 0x01,
a,
b,
c,
dst: 0,
},
0x01,
),
];
let mid = n / 2 + 17;
let extents = [
("1 row", mid, mid + 1),
("1%", mid, mid + n / 100),
("whole", 0, n),
];
let bit = |p: &[u64], r: usize| (p[r / 64] >> (r % 64) & 1) as u8;
println!(
"{:>5} {:>6} {:>5} {:>11} {:>11} {:>7} {:>10}",
"op", "extent", "term", "fold_ns", "keep_ns", "k/f", "keep_wr"
);
for (name, op, table) in shapes {
let count = Program::new(
vec![op],
Terminal::Count {
mask: Operand::Scratch(0),
},
);
let any = Program::new(
vec![op],
Terminal::Any {
mask: Operand::Scratch(0),
},
);
let keep = Program::new(
vec![op],
Terminal::Keep {
mask: Operand::Scratch(0),
},
);
assert!(count.fused_ternlog().is_some() && any.fused_ternlog().is_some());
let mut ks = Scratch::for_program(&keep, n).expect("scratch");
let mut out = vec![0u64; words];
for (ename, lo, hi) in extents {
let want = (lo..hi)
.filter(|&r| table >> (bit(&pa, r) << 2 | bit(&pb, r) << 1 | bit(&pc, r)) & 1 == 1)
.count();
let reps = if hi - lo > 100_000 { 41 } else { 2001 };
let span = touched_words(lo as u32, hi as u32);
for (term, p) in [("Count", &count), ("Any", &any)] {
let (fns, fv) = median(reps, || {
let mut s = Scratch::new(0, 0);
execute_extent(p, &planes, &Foreign::NONE, &mut s, Out::None, lo..hi)
.expect("fold")
});
let (kns, kv) = median(reps, || {
out[span.clone()].fill(0);
execute_extent(
&keep,
&planes,
&Foreign::NONE,
&mut ks,
Out::Mask(&mut out),
lo..hi,
)
.expect("keep");
if term == "Count" {
Value::Count(popcount_batch_u64(&out[span.clone()]) as usize)
} else {
Value::Bool(mask_any(&out[span.clone()]))
}
});
let expect = if term == "Count" {
Value::Count(want)
} else {
Value::Bool(want > 0)
};
assert_eq!(fv, expect, "fold {name} {ename} {term}");
assert_eq!(kv, expect, "keep {name} {ename} {term}");
println!(
"{name:>5} {ename:>6} {term:>5} {fns:>11.0} {kns:>11.0} {:>7.2} {:>10}",
kns / fns,
span.len()
);
}
}
}
println!("(n = {n}, {words} population words; the fold writes 0 membership words)");
}
Loading
Loading