diff --git a/.claude/board/TECH_DEBT.md b/.claude/board/TECH_DEBT.md index 81bbe2c6f..3e96d2df3 100644 --- a/.claude/board/TECH_DEBT.md +++ b/.claude/board/TECH_DEBT.md @@ -1,4 +1,50 @@ -## TD-SYM-SUM-MERGE-IS-NOT-ADDITION-1 (2026-09-23) — OPEN, dormant +## TD-KEYED-SINK-MERGE-IDENTITY-1 (2026-10-05) — OPEN + +**Equal length is not semantic compatibility.** `Terminal::merge_group_sink` +merges two bare `&[i64]` sinks. It checks exactly two things: that the +terminal is a supported keyed `i64` terminal, and that the two sinks have +equal length. A bare slice proves none of the following: + +- raw vs finalized state (a finalized slot is outside the merge law, and + merging it gives a wrong answer, not an error — pinned by + `finalize_once_after_merge_never_merge_finalized`); +- fold/state identity (which terminal and fold produced it); +- coordinate space / version; +- source / filter identity; +- same destination universe (same K, but K of what) — **a destination ordinal + is not a semantic identity by itself; an ordinal has meaning only inside its + destination-space / codebook identity** (WORKING-MODEL, operator 2026-10-05); +- binding identity (which binding produced the ordinals); +- contribution-population identity, and disjoint / legal contribution sets + (Count and the sums re-count overlapped rows); +- combined row-bound legality (`MASKED_SUM_I32_MAX_ROWS` / + `GROUP_SUM_SYM_MAX_ROWS` bound the TOTAL over all merged partials). + +Today these are documented caller preconditions on `merge_group_sink`. + +**Close it with** the retained FoldState identity contract (fold-contract +correction 2, `entries/2026-10-05-fold-contract-and-keyed-sink-merge.md`). +Do **not** close it by bolting metadata onto the Step E merge. A wrapper type +added for this alone would be a second, partial identity carrier. + +## TD-SYM-SUM-MERGE-IS-NOT-ADDITION-1 (2026-09-23) — RESOLVED 2026-10-05: `GroupFold::merge` / `Terminal::merge_group_sink` ship the law below; partial-extent `GroupReduce` admitted; test `tests/keyed_merge.rs` (one-side-empty groups included); see `entries/2026-10-05-fold-contract-and-keyed-sink-merge.md` + +> **RESOLUTION NOTE (2026-10-05, Step E).** The merge path is LIVE. It went +> live through: +> - `GroupFold::merge`, the per-slot law, including the `_sym` ⊥ law exactly +> as pinned below; +> - `Terminal::merge_group_sink`; +> - partial-extent keyed execution: `GroupSumI32` / `GroupSumViaI32` / +> `GroupReduce`, each on a fresh, re-seeded sink per extent; +> - `tests/keyed_merge.rs`, which covers groups empty on one side only, the +> empty-marker cases, and disable runs where plain `+` and "empty read as +> 0" both go red. +> +> Everything below — "nothing merges partial sinks today", "when it goes +> live", "no merge function and no test" — is now **historical**. It is the +> rationale for pinning the law BEFORE implementation, kept as written. +> What remains open is not the algebra but the identity/preconditions of a +> bare-slice merge: `TD-KEYED-SINK-MERGE-IDENTITY-1` above. **`GroupFold::SumSymI32`'s seed is not an additive identity, so two partial sinks must never be combined with `+`.** For MIN/MAX the seed IS the lattice diff --git a/.claude/board/entries/2026-10-05-fold-contract-and-keyed-sink-merge.md b/.claude/board/entries/2026-10-05-fold-contract-and-keyed-sink-merge.md new file mode 100644 index 000000000..b9af4f800 --- /dev/null +++ b/.claude/board/entries/2026-10-05-fold-contract-and-keyed-sink-merge.md @@ -0,0 +1,75 @@ +# 2026-10-05 — Fold contract (WHERE × WHEN) and the first rendezvous primitive: keyed partial-sink merge + +**Status:** WORKING-MODEL (the contract) · TEST-PINNED (Step E, `crates/lance-graph-mask-risc/tests/keyed_merge.rs`) · OPEN (items at the end) +**Supersedes:** the wording of the fold-contract reconnaissance reported in-session (2026-10-05) on four points, listed under "Corrections". The reconnaissance itself is VERIFIED-IN-CODE and is not repeated here; its first finding is the gap Step E closes. + +## The contract + +A fold has two degrees of freedom: **WHERE** it lands (destination) and **WHEN** it merges or finalizes (rendezvous). Planning has a third: **WHETHER** it deserves to exist. + +| verb | meaning | layer (WORKING-MODEL, operator 2026-10-05) | +|---|---|---| +| PREPARE / PLAN | may run speculatively and asynchronously, overlapping ingest, write or earlier execution; bound to schema + coordinate identity + expected version; validated before use; amortizable or bypassable | planner (JIT / loco) | +| BIND | makes a prepared route valid for one world/version | planner / ABI | +| RESOLVE | a row resolves its destination through functional references; no population state | R2IL | +| ACCUMULATE | fold the row's contribution into the destination's state | R2IL | +| RETAIN | keep RAW, unfinalized fold state | loco lifetime policy | +| RENDEZVOUS | the earliest point where independent fold states are needed together — a dependency fence, not an instruction | loco scheduling | +| MERGE | same fold kind: `S × S → S` | R2IL data instruction | +| COMPOSE | different retained fold states become inputs of a later computation (FoldSet) | loco program | +| FINALIZE | one-way: identity is lost (NULL from count, `sum/count`, normalize) | R2IL | +| MATERIALIZE | only on demand | boundary / adapter | + +R2IL says WHAT operation; ogar-loco says WHEN / with whom / in which recipe; Quack says WHICH data computation is demanded; the ABI says IN WHICH world/version. Hard Rust kernels sit below R2IL and are added only when a measured fusion of R2IL steps beats the sequence — never because one application recipe is hot. + +### Corrections to the reconnaissance wording + +1. **ACCUMULATE / multi-terminal.** A shared traversal is defined by the compatible PHYSICAL source: source/coordinate world, pinned version, row extent, tile traversal. Each fold consumer independently owns its filter, its route / group-key resolution, its fold algebra and its sink. (Was: "share filter, route and traversal" — too narrow; the tile loop already has every slot live at terminal time, so per-consumer filters and keys are mechanically sound.) +2. **RETAIN.** Fold-state identity is separate from placement. A retained state must prove what it means — fold algebra, source coordinate/version identity, grouping/local coordinate identity, filter/presence semantics, exactness/overflow contract — but its eventual consumer/destination may stay symbolic until a later rendezvous. Compute now, retain raw, bind placement later, where semantics allow. +3. **MERGE.** The general contract is `merge : S × S → S`, associative wherever execution relies on arbitrary partitioning. Commutativity is a property of a particular fold, not of the contract. Count, Sum, Min, Max and `_sym` Sum are commutative (under their stated overflow/bound contracts), so Step E tests arbitrary order. `KeyRunCarry` is NOT thereby non-mergeable: it may be an ordered-segment fold (associative, not commutative, with boundary metadata). OPEN. +4. **MERGE ≠ COMPOSE.** `COUNT Berlin ⊕ COUNT Hamburg → COUNT Germany` is MERGE. License + Revenue + Risk + Headcount → annual report is COMPOSE. Heterogeneous folds are never forced into one merge algebra. + +### Planning rules (operator, 2026-10-05) + +- **Never wait longer to discover how to avoid work than it would take to do the work.** Use a plan only when `plan_cost + optimized_execution < simple_execution` over the expected lifecycle; `effective_plan_cost ≈ max(0, planning − overlapped_write) / expected_reuse`. +- **Plan may run ahead of authority; a prepared plan is not a valid execution world.** At seal: compatible → instant handoff; stale → rebind or discard. +- **Fire-and-forget planning, not fire-and-forget semantics.** Execution does not owe the planner a wait; the simple path runs now and the plan serves later batches. +- **JIT composes, it does not generate code.** It chooses existing instructions, order, fusion, where to retain, where a rendezvous is needed. A recurring sequence becomes a named loco program; it is not recompiled into Rust. + +### The DO arm (operator, 2026-10-05) — the write half of the same algebra + +The IR needs a `DO` arm symmetric to READ, whose natural form is a fold: source contribution → resolve target → fold into mutation state per target → retained action state → Rubicon/guards → seal → ActionHandler → Revision. **The DO arm does not execute one effect per source row. It folds source contributions by resolved effect destination into the minimal deterministic mutation state, and only that state may cross the action boundary.** Each DO class carries identity / accumulate / merge / conflict / finalize-to-effect (ADD+ADD idempotent; ADD+REMOVE and SET E5 + SET F3 are conflicts, policy-defined). `DO` means "construct the mutation state", never "write now" — that keeps replay, dry-run, before/after, dedup and batch amortization. NOT BUILT. + +## Step E — keyed partial-sink merge (mask-risc) + +**What changed:** `GroupSumI32`, `GroupSumViaI32` and `GroupReduce { Count, MinI32, MaxI32, SumSymI32 }` (Lane / Via / Pair keys) are admitted on a partial extent. Each extent gets a FRESH, re-seeded `Out::I64`; an edge word is clipped in a register (the arms previously read the unclipped mask — dormant while partial extents were refused). Partials combine with `Terminal::merge_group_sink`, which applies `GroupFold::merge` — the merge law lives on the IR type that already knows the fold kind. No new grouping engine, sink, address, terminal or type. + +**Overflow algebra — each fold's existing semantics preserved, no conflict found (so no STOP):** + +| fold | kernel accumulation | merge | exact when | +|---|---|---|---| +| Count | `wrapping_add(1)` | `wrapping_add` | always (a count ≤ its rows) | +| GroupSumI32 / Via | `wrapping_add` | `wrapping_add` | total rows ≤ `MASKED_SUM_I32_MAX_ROWS` (2^32); validate bounds the plane, disjoint extents of it cannot exceed it | +| MinI32 / MaxI32 | `min` / `max` | `min` / `max` (seed = lattice identity) | always | +| SumSymI32 | first row replaces ⊥, then `wrapping_add` | `⊥⊕x = x`, `x⊕⊥ = x`, `⊥⊕⊥ = ⊥`, else `wrapping_add` (`TD-SYM-SUM-MERGE-IS-NOT-ADDITION-1`) | TOTAL rows across partials ≤ `GROUP_SUM_SYM_MAX_ROWS` | + +Report's `FoldState::merge` (`wrapping_add` / `min` / `max`) agrees with every row. ndarray's `checked_merge` (PowerSums) is a different state type and is untouched. + +**Errors:** all refusals of these terminals are preflight — `precheck` (slot ceiling, extent range, extent support) and `validate` (lanes, out shape, row bounds) run before the sink is seeded. None of them fails during traversal (the only traversal-time error, `LaneNotOrdered`, belongs to `CountKeyRunsU32`, still refused on a partial extent). `execute_into`'s contract is unchanged; a partial sink is the caller's per-extent workspace, K-sized, never population-sized. + +**Gates (all green; mask-risc suite, clippy `-D warnings`, fmt):** split composition equals the whole and the whole equals the row oracle, for 14 terminal × key combinations at two tile widths, over one partition, two-way cuts in a word / on word edges / on the tile edge, empty extents and ≥ 12 random uneven partitions, in four orders plus a balanced-tree grouping. Anti-vacuity asserted (a group live in two partials, a group live in exactly one, an empty group, an empty extent, a cut splitting a live word). `_sym` can-fire cases (⊥⊕x, x⊕⊥, ⊥⊕⊥, real zero ≠ ⊥) by value and through execution. `finalize(merge(raw))` correct; merging coalesced finalized MIN slots shown WRONG (0 for a true 5). Monoid laws on extremes; the `_sym` counterexample outside the row bound pinned. + +**Disable runs, each red then restored:** `_sym` merged with plain `+`; MIN merged as MAX; empty `_sym` read as 0; GroupReduce edge unclipped; GroupSumI32 edge unclipped. + +**What this makes true:** `population A → raw grouped state A ─┐ merge raw → finalize once` / `population B → raw grouped state B ─┘` — without source-row materialization and without A and B executing as one pass. First substrate implementation of RENDEZVOUS (same-type). + +## OPEN + +- **`TD-KEYED-SINK-MERGE-IDENTITY-1`.** Equal length is not semantic compatibility. A bare `&[i64]` cannot prove that it is raw, which fold produced it, its coordinate space or version, its filter, its destination universe, that the extents are disjoint, or that the combined row bound holds. Merging finalized slots is wrong but not REFUSED. Today these are caller preconditions documented on `merge_group_sink`. They close with the retained FoldState identity contract (correction 2), not with metadata bolted onto Step E. +- `KeyRunCarry` as an ordered-segment fold. +- Multi-terminal traversal sharing (one physical pass → N independent consumers). +- **A. Destination binding / coordinate resolution** — a frontend / binder concern (merged #1331: Report's `CoordSpec` cannot bind a functional reference, a composed route, or several coordinates to one destination ordinal). Its normal path lowers to the EXISTING `Local` / `Via` keyed fold; it is NOT a missing ndarray / fold primitive. A query-local interner is only a fallback for a genuinely unbindable destination universe. A destination ordinal is not a semantic identity by itself: **an ordinal has meaning only inside its destination-space / codebook identity** (WORKING-MODEL; the identity carrier is not built — see `TD-KEYED-SINK-MERGE-IDENTITY-1`). +- **B. Functional route composition beyond depth 2** — a substrate / R2IL question. Address chain (`lanes[k2][lanes[k1][fk[i]]]`) vs a precomposed resident lane over the intermediate table: unmeasured. +- The DO arm and its per-class conflict algebra. +- **rs-graph-llm as a semantic dependency graph over retained folds** (operator WORKING-MODEL, 2026-10-05; NOT BUILT). Quack is the deterministic IR for every stateful READ/DO computation; rs-graph-llm owns only dependencies, retained fold identity (FoldRef: algebra, coordinate/version, filter/provenance, raw state, optional destination) and the human/LLM boundaries. Consequences named: `Context` carries scalars, decisions, handles, FoldRefs and capabilities, never populations; an edge is a dependency / rendezvous, not row transport; a task may complete with an unfinalized FoldRef; LLM output is structured evidence folded (support / contradiction / provenance / frequency / confidence) before it becomes state; `NextAction::GoTo` gives way to declared readiness that loco schedules; agents contribute DO intents, and the DO fold — not the agent — produces ActionIntent. Loco needs only fold metadata (mergeable, ordered, finalized, dependencies, world/version, destination bound). Consistent with decision 3 of `2026-10-05-report-pair-key-and-stack-recon.md` (graph-flow emits facts; canonical Kanban + Revision owns lifecycle). +- Stale doc: `population-law-crosscheck-v1.md:39` says mask-risc has no moments fold; `GroupPowerSumsI32` / `GroupCrossPowerSumsI32` exist. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 34c7272f8..19a6c9d4f 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ 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. -217 entries, 2026-08-06 .. 2026-10-05. +218 entries, 2026-08-06 .. 2026-10-05. | date | entry id | finding | file | |---|---|---|---| @@ -33,6 +33,7 @@ exactly one of them, never both. | 2026-10-05 | `report-pair-key-and-stack-recon` | | [2026-10-05-report-pair-key-and-stack-recon.md](2026-10-05-report-pair-key-and-stack-recon.md) | | 2026-10-05 | `quack-two-world-frontend` | | [2026-10-05-quack-two-world-frontend.md](2026-10-05-quack-two-world-frontend.md) | | 2026-10-05 | `quack-storage-portability` | | [2026-10-05-quack-storage-portability.md](2026-10-05-quack-storage-portability.md) | +| 2026-10-05 | `fold-contract-and-keyed-sink-merge` | | [2026-10-05-fold-contract-and-keyed-sink-merge.md](2026-10-05-fold-contract-and-keyed-sink-merge.md) | | 2026-10-04 | `D-LXC-22` | | [2026-10-04-wordnet-clam-chaoda-scope-and-grammar-read-params.md](2026-10-04-wordnet-clam-chaoda-scope-and-grammar-read-params.md) | | 2026-10-04 | `D-HPS-1` | | [2026-10-04-spog-slab-hotplug-resolution.md](2026-10-04-spog-slab-hotplug-resolution.md) | | 2026-10-04 | `D-HPS-2` | | [2026-10-04-resolve-once-population-execution.md](2026-10-04-resolve-once-population-execution.md) | diff --git a/crates/lance-graph-mask-risc/src/exec.rs b/crates/lance-graph-mask-risc/src/exec.rs index 27a67c540..6e305f05d 100644 --- a/crates/lance-graph-mask-risc/src/exec.rs +++ b/crates/lance-graph-mask-risc/src/exec.rs @@ -1368,7 +1368,11 @@ pub fn execute_into( /// `MaskedSumI32` (sum), `MaskedMinI32` / `MaskedMaxI32` (min / max), /// `GroupPowerSumsI32` / `GroupCrossPowerSumsI32` (each extent gets its own /// sink, seeded fresh; partial sinks combine group-by-group with -/// `PowerSums::checked_merge` / `CrossPowerSums::checked_merge`) — plus +/// `PowerSums::checked_merge` / `CrossPowerSums::checked_merge`), +/// `GroupSumI32` / `GroupSumViaI32` / `GroupReduce` (same: a fresh, +/// re-seeded `Out::I64` per extent; partial sinks combine with +/// [`Terminal::merge_group_sink`], which applies each fold's own law — +/// never plain `+` for the `_sym` SUM) — plus /// `Keep`, which writes only the in-extent bits of its population-addressed /// [`Out::Mask`] and leaves every other bit as the caller holds it (so /// disjoint extents compose into one buffer in any SEQUENTIAL order). Anything else is @@ -1442,14 +1446,14 @@ fn precheck( | Terminal::MaskedStridedGroupSum { .. } | Terminal::GroupPowerSumsI32 { .. } | Terminal::GroupCrossPowerSumsI32 { .. } + | Terminal::GroupSumI32 { .. } + | Terminal::GroupSumViaI32 { .. } + | Terminal::GroupReduce { .. } | Terminal::Keep { .. } => None, Terminal::BlendI32 { .. } => Some("BlendI32"), Terminal::ScatterOrU32 { .. } => Some("ScatterOrU32"), Terminal::ScatterCountU32 { .. } => Some("ScatterCountU32"), Terminal::CountKeyRunsU32 { .. } => Some("CountKeyRunsU32"), - Terminal::GroupSumI32 { .. } => Some("GroupSumI32"), - Terminal::GroupSumViaI32 { .. } => Some("GroupSumViaI32"), - Terminal::GroupReduce { .. } => Some("GroupReduce"), }; if let Some(what) = refused { return Err(ExecError::ExtentUnsupported { what }); @@ -1892,7 +1896,7 @@ pub fn execute_compiled( // the kernel adds into it, tile after tile. if let Out::I64(o) = &mut out { masked_group_sum_i32( - read(planes, &slots, mask, t), + clip(read(planes, &slots, mask, t), edge, &mut eb, false), lane_u32(planes, key, t), lane_i32(planes, val, t), o, @@ -1905,7 +1909,7 @@ pub fn execute_compiled( // foreign `key` — one delegation per tile (law L3). if let Out::I64(o) = &mut out { masked_group_sum_i32_via( - read(planes, &slots, mask, t), + clip(read(planes, &slots, mask, t), edge, &mut eb, false), lane_u32(planes, fk, t), foreign_lane_u32(foreign, key), lane_i32(planes, val, t), @@ -1916,9 +1920,10 @@ pub fn execute_compiled( Terminal::GroupReduce { mask, key, fold } => { // `validate` already refused a missing/too-small `out` and // every wrong-width lane; one delegation per tile (law L3), - // the sink seeded above with the fold's identity. + // the sink seeded above with the fold's identity. An edge + // tile's mask is restricted to the extent in a register. if let Out::I64(o) = &mut out { - let m = read(planes, &slots, mask, t); + let m = clip(read(planes, &slots, mask, t), edge, &mut eb, false); match (key, fold) { (GroupKey::Lane(k), GroupFold::Count) => { masked_group_count_u32(m, lane_u32(planes, k, t), o) diff --git a/crates/lance-graph-mask-risc/src/ir.rs b/crates/lance-graph-mask-risc/src/ir.rs index 48953973c..242d65db8 100644 --- a/crates/lance-graph-mask-risc/src/ir.rs +++ b/crates/lance-graph-mask-risc/src/ir.rs @@ -537,6 +537,142 @@ impl GroupFold { _ => v == self.seed(), } } + + /// Combine two RAW partial slots of this fold — the per-slot merge law + /// a partial-extent run needs; [`Terminal::merge_group_sink`] applies it + /// over whole sinks. + /// + /// [`GroupFold::seed`] is the identity of every law below, and every law + /// is associative and commutative: + /// + /// - `Count`: `a.wrapping_add(b)` — the kernel's own accumulation. Exact: + /// a count never exceeds the rows it counted. + /// - `MinI32` / `MaxI32`: `min` / `max`. The seed (`i64::MAX` / `i64::MIN`) + /// is the lattice identity, so an empty side is absorbed by the law + /// itself. + /// - `SumSymI32`: NOT `+` (`TD-SYM-SUM-MERGE-IS-NOT-ADDITION-1`). With + /// `⊥ = SYM_EMPTY_I64`: `⊥ ⊕ x = x`, `x ⊕ ⊥ = x`, `⊥ ⊕ ⊥ = ⊥`, otherwise + /// `x.wrapping_add(y)` — the kernel's own rule (the first row replaces + /// the marker, later rows add), lifted from rows to partials. Wrapping + /// keeps the row-first law: merging partials equals folding all their + /// rows in one walk. `⊥` stays distinct from every present sum only + /// while the TOTAL rows across all merged partials stay within + /// [`GROUP_SUM_SYM_MAX_ROWS`] — a bound on the whole, never on one + /// partial. Disjoint extents of one validated plane satisfy it by + /// construction. + /// + /// The operands are RAW slots, exactly as a run leaves them. A slot a + /// consumer has already finalized (a marker mapped to `0`, an empty + /// MIN/MAX turned into `NULL` or `0`) is outside this law's domain: `0` + /// is not the identity of MIN, MAX or the `_sym` SUM, so merging + /// finalized slots gives a wrong answer, not an error. Merge first, + /// finalize once. + pub const fn merge(self, a: i64, b: i64) -> i64 { + match self { + GroupFold::Count => a.wrapping_add(b), + GroupFold::MinI32(_) => { + if a < b { + a + } else { + b + } + } + GroupFold::MaxI32(_) => { + if a > b { + a + } else { + b + } + } + GroupFold::SumSymI32(_) => { + if a == ndarray::simd::SYM_EMPTY_I64 { + b + } else if b == ndarray::simd::SYM_EMPTY_I64 { + a + } else { + a.wrapping_add(b) + } + } + } + } +} + +impl Terminal { + /// Merge one RAW partial `Out::I64` sink of this terminal into `acc`, + /// slot by slot: `acc[g] = law(acc[g], part[g])`. + /// + /// This is how the per-extent results of the keyed `i64` folds combine + /// — [`Terminal::GroupSumI32`] and [`Terminal::GroupSumViaI32`] by + /// `wrapping_add` (their kernels' accumulation; exact within + /// [`MASKED_SUM_I32_MAX_ROWS`] total rows), and [`Terminal::GroupReduce`] + /// by [`GroupFold::merge`]. Seed `acc` with the terminal's identity + /// (`0`, or [`GroupFold::seed`]) or start from any one partial; the + /// laws are associative and commutative, so partials merge in any order + /// and grouping. + /// + /// The merge is O(K) over the two sinks and never touches the + /// population. + /// + /// # Preconditions — the caller's, NOT checked + /// + /// A bare `&[i64]` cannot prove any of these, and equal length is not + /// semantic compatibility (`TD-KEYED-SINK-MERGE-IDENTITY-1`; to be closed + /// by the retained fold-state identity contract, not here): + /// + /// 1. both sinks are RAW, exactly as a run leaves them — never finalized + /// (see [`GroupFold::merge`] for why a finalized slot gives a wrong + /// answer, not an error); + /// 2. both come from THIS terminal (same fold, same key address); + /// 3. over the same coordinate space and version: the same planes and + /// foreign tables; + /// 4. with the same filter; + /// 5. over the same destination universe under the same binding — the + /// same K, meaning the same groups, not merely the same length (a + /// destination ordinal means something only inside its destination + /// space); + /// 6. from DISJOINT extents — `Count` and the sums count a row once per + /// extent that holds it, and only MIN/MAX are idempotent; + /// 7. with the TOTAL rows of all merged partials within the fold's row + /// bound ([`MASKED_SUM_I32_MAX_ROWS`] for the sums, + /// [`GROUP_SUM_SYM_MAX_ROWS`] for the `_sym` SUM). Disjoint extents of + /// one validated plane satisfy this by construction. + /// + /// Refused, with nothing written: a terminal with no keyed `i64` sink + /// ([`ExecError::ExtentUnsupported`] — its partials have no merge law + /// here), and sinks of different LENGTHS ([`ExecError::LenMismatch`]). + /// The length check is the only universe check there is: same length is + /// not the same destination universe (precondition 5 above), and two + /// sinks of equal length over different groups merge without complaint. + /// Semantic-universe identity stays with `TD-KEYED-SINK-MERGE-IDENTITY-1`. + /// + /// [`ExecError::ExtentUnsupported`]: crate::ExecError::ExtentUnsupported + /// [`ExecError::LenMismatch`]: crate::ExecError::LenMismatch + pub fn merge_group_sink(&self, acc: &mut [i64], part: &[i64]) -> Result<(), crate::ExecError> { + // `None` = the coalescing full-range sum; `Some(fold)` = a seeded fold. + let fold = match *self { + Terminal::GroupSumI32 { .. } | Terminal::GroupSumViaI32 { .. } => None, + Terminal::GroupReduce { fold, .. } => Some(fold), + _ => { + return Err(crate::ExecError::ExtentUnsupported { + what: "merge_group_sink", + }) + } + }; + if acc.len() != part.len() { + return Err(crate::ExecError::LenMismatch { + what: "merge_group_sink", + expected: acc.len(), + found: part.len(), + }); + } + for (a, &p) in acc.iter_mut().zip(part) { + *a = match fold { + None => a.wrapping_add(p), + Some(f) => f.merge(*a, p), + }; + } + Ok(()) + } } /// The widest plane a NULL-preserving [`GroupFold::SumSymI32`] is defined on: diff --git a/crates/lance-graph-mask-risc/src/value.rs b/crates/lance-graph-mask-risc/src/value.rs index 30dff5bf6..fd3833147 100644 --- a/crates/lance-graph-mask-risc/src/value.rs +++ b/crates/lance-graph-mask-risc/src/value.rs @@ -218,6 +218,10 @@ pub enum ExecError { /// `MaskedSumI32`, `MaskedMinI32`, `MaskedMaxI32`, `MaskedStridedGroupSum` /// (a sum merges by addition), `GroupPowerSumsI32` / /// `GroupCrossPowerSumsI32` (fresh per-extent sinks, merged group-by-group - /// with `checked_merge`) and `Keep`. `what` names the refused terminal. + /// with `checked_merge`), `GroupSumI32` / `GroupSumViaI32` / + /// `GroupReduce` (fresh per-extent sinks, merged with + /// `Terminal::merge_group_sink`) and `Keep`. `what` names the refused + /// terminal — or `"merge_group_sink"` when that merge is asked of a + /// terminal with no keyed `i64` sink. ExtentUnsupported { what: &'static str }, } diff --git a/crates/lance-graph-mask-risc/tests/extent.rs b/crates/lance-graph-mask-risc/tests/extent.rs index c112f6a36..0ce49a1a3 100644 --- a/crates/lance-graph-mask-risc/tests/extent.rs +++ b/crates/lance-graph-mask-risc/tests/extent.rs @@ -498,41 +498,42 @@ fn bad_extents_and_unmergeable_terminals_are_refused_before_execution() { let r = execute_extent(&count, &planes, &Foreign::NONE, &mut s, Out::None, lo..hi); assert_eq!(r, Err(ExecError::ExtentOutOfRange { lo, hi, n_rows: n })); } - let group = Program::new( + // `BlendI32` writes a population-addressed lane with no merge law, so a + // partial extent is refused. (The keyed i64 folds used to be the example + // here; they are admitted now — see `keyed_i64_partials_merge_to_the_whole`.) + let blend = Program::new( vec![], - Terminal::GroupSumI32 { + Terminal::BlendI32 { mask: Operand::Plane(0), - key: 0, - val: 1, + then: 1, + els: 1, }, ); - let mut sink = vec![0i64; 4]; - let mut s = Scratch::for_program(&group, n).expect("scratch"); + let mut sink = vec![-1i32; n]; + let mut s = Scratch::for_program(&blend, n).expect("scratch"); assert_eq!( execute_extent( - &group, + &blend, &planes, &Foreign::NONE, &mut s, - Out::I64(&mut sink), + Out::I32(&mut sink), 10..20 ), - Err(ExecError::ExtentUnsupported { - what: "GroupSumI32" - }) + Err(ExecError::ExtentUnsupported { what: "BlendI32" }) ); - assert_eq!(sink, vec![0; 4], "a refusal writes nothing"); + assert_eq!(sink, vec![-1; n], "a refusal writes nothing"); // The whole extent accepts every terminal: it IS `execute_into`. assert_eq!( execute_extent( - &group, + &blend, &planes, &Foreign::NONE, &mut s, - Out::I64(&mut sink), + Out::I32(&mut sink), 0..n ), - Ok(Value::GroupSummed) + Ok(Value::Blended) ); // A partial Keep needs its population-addressed sink. let keep = program(Shape::Tiled, 0, n as u32, Term::Keep); diff --git a/crates/lance-graph-mask-risc/tests/keyed_merge.rs b/crates/lance-graph-mask-risc/tests/keyed_merge.rs new file mode 100644 index 000000000..dfa5d01e7 --- /dev/null +++ b/crates/lance-graph-mask-risc/tests/keyed_merge.rs @@ -0,0 +1,530 @@ +//! Keyed partial-sink merge: the keyed `i64` folds over partial extents. +//! +//! `GroupSumI32`, `GroupSumViaI32` and `GroupReduce { Count, MinI32, MaxI32, +//! SumSymI32 }` run over any absolute extent into a FRESH, re-seeded +//! `Out::I64`, and partial sinks combine with `Terminal::merge_group_sink`, +//! which applies each fold's own law (`GroupFold::merge`). +//! +//! The gates: +//! - split composition: `full(rows) == merge(fold(part_1), …, fold(part_n))` +//! for one, two and many uneven partitions, empty partitions, cuts inside +//! a word, on word edges and on tile edges, in several merge orders AND +//! groupings (the laws are associative; these folds are also commutative); +//! - the whole run equals the row-at-a-time oracle, so "full" is not just +//! the executor agreeing with itself; +//! - the `_sym` SUM law's can-fire cases: `⊥ ⊕ x`, `x ⊕ ⊥`, `⊥ ⊕ ⊥`, and a +//! real zero sum that must stay distinct from `⊥`; +//! - finalize once, after the merge: merging FINALIZED slots is shown to +//! give a wrong answer, so the raw-only domain is load-bearing; +//! - refusals: no keyed `i64` sink, or two different sink lengths (a length +//! check only — same length is not the same destination universe). + +use lance_graph_mask_risc::exec::{execute_extent, Scratch}; +use lance_graph_mask_risc::{ + reference_execute_into, scratch_words_for, ExecError, Foreign, GroupFold, GroupKey, LaneRef, + Operand, Out, Planes, Program, Terminal, Value, +}; + +fn lcg(seed: &mut u64) -> u64 { + *seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + *seed >> 11 +} + +fn plane(n: usize, set: impl Fn(usize) -> bool) -> Vec { + let mut w = vec![0u64; n.div_ceil(64)]; + for r in (0..n).filter(|&r| set(r)) { + w[r / 64] |= 1 << (r % 64); + } + w +} + +fn bit(p: &[u64], r: usize) -> bool { + p[r / 64] >> (r % 64) & 1 == 1 +} + +/// The identity a fresh accumulator starts from: what the executor seeds. +fn identity(t: &Terminal) -> i64 { + match *t { + Terminal::GroupReduce { fold, .. } => fold.seed(), + _ => 0, + } +} + +/// `t` over one extent, into a FRESH sink the caller owns. The sink starts +/// dirty on purpose: the terminal must seed it, never add to stale state. +fn part( + t: &Terminal, + planes: &Planes<'_>, + foreign: &Foreign<'_>, + tile_words: usize, + groups: usize, + ext: std::ops::Range, +) -> Vec { + let p = Program::new(vec![], *t); + let slots = p.scratch_slots as usize; + let mut buf = vec![0u64; scratch_words_for(tile_words, slots).expect("sized")]; + let mut s = Scratch::over(&mut buf, tile_words, slots).expect("carves"); + let mut sink = vec![0x5A5A_5A5A_i64; groups]; + let v = execute_extent(&p, planes, foreign, &mut s, Out::I64(&mut sink), ext) + .expect("a partial extent is admitted"); + assert!(matches!(v, Value::GroupSummed | Value::GroupReduced)); + sink +} + +/// Merge `parts` in `order`, left to right, from the identity. +fn merge_linear(t: &Terminal, parts: &[Vec], order: &[usize]) -> Vec { + let mut acc = vec![identity(t); parts[0].len()]; + for &i in order { + t.merge_group_sink(&mut acc, &parts[i]) + .expect("same terminal, same K"); + } + acc +} + +/// Merge `parts` as a balanced tree — a different GROUPING of the same +/// operands, which only an associative law survives. +fn merge_tree(t: &Terminal, parts: &[Vec]) -> Vec { + match parts.len() { + 1 => parts[0].clone(), + k => { + let (l, r) = parts.split_at(k / 2); + let mut acc = merge_tree(t, l); + t.merge_group_sink(&mut acc, &merge_tree(t, r)) + .expect("same terminal, same K"); + acc + } + } +} + +/// FAILS IF: a keyed i64 terminal over a partial extent counts a row outside +/// it (an unclipped edge word), re-counts a row two extents both claim, adds +/// to a stale sink instead of re-seeding it, or its partials do not merge +/// back to the whole under the fold's law — in any order or grouping, for +/// every fold and key address, at two tile widths. +/// +/// Anti-vacuity (asserted at the end): some group must be non-empty in two +/// partials (else the merge never combines), some group must be non-empty in +/// exactly one partial of a multi-part split (else "absent on one side" is +/// never exercised), some group must be empty in the whole (else the seed is +/// never merged), some partition must contain an empty extent, and some cut +/// must split a 64-row word with selected rows on both sides. +#[test] +fn keyed_i64_partials_merge_to_the_whole() { + let mut spans_two = false; + let mut only_one = false; + let mut empty_group = false; + let mut empty_extent = false; + let mut split_live_word = false; + for n in [1317usize, 4096 + 37] { + let mut seed = 0x6E_E0 ^ n as u64; + let sel: Vec = (0..n).map(|_| !lcg(&mut seed).is_multiple_of(3)).collect(); + let pl = plane(n, |r| sel[r]); + // Keys 0..=5 with 6 = past the universe (dropped). Key 4 only occurs + // in the first 90 rows, so most splits see it in one extent only; + // group 6 of a K = 7 universe is never named, so it stays empty. + let key: Vec = (0..n) + .map(|r| { + let k = (lcg(&mut seed) % 7) as u32; + if k == 4 && r >= 90 { + 0 + } else { + k + } + }) + .collect(); + let val: Vec = (0..n) + .map(|i| match i % 17 { + 3 => i32::MIN, + 9 => i32::MAX, + _ => (lcg(&mut seed) % 200_001) as i32 - 100_000, + }) + .collect(); + let fk: Vec = (0..n).map(|_| (lcg(&mut seed) % 8) as u32).collect(); // 7 = past table + let hi: Vec = (0..n).map(|_| (lcg(&mut seed) % 3) as u32).collect(); + let lo: Vec = (0..n).map(|_| (lcg(&mut seed) % 5) as u32).collect(); // 4 = >= stride + let table: Vec = vec![0, 4, 2, 9, 1, 3, 5]; // 9 = second-hop drop + let masks: [&[u64]; 1] = [&pl]; + let lanes = [ + LaneRef::U32(&key), + LaneRef::I32(&val), + LaneRef::U32(&fk), + LaneRef::U32(&hi), + LaneRef::U32(&lo), + ]; + let planes = Planes { + n_rows: n, + masks: &masks, + lanes: &lanes, + }; + let flanes = [LaneRef::U32(&table)]; + let foreign = Foreign { + planes: &[], + lanes: &flanes, + }; + + // One partition; two-way cuts inside a word, on word edges (64) and + // on the 2-word tile edge (128); cuts at 0 / n leave an EMPTY + // extent; many uneven random partitions, duplicates included (also + // empty extents). + let mut partitions: Vec> = vec![vec![0, n]]; + for k in [0, 1, 63, 64, 65, 127, 128, 129, n / 2, n - 1, n] { + partitions.push(vec![0, k, n]); + } + for _ in 0..12 { + let mut cuts: Vec = (0..2 + lcg(&mut seed) % 6) + .map(|_| (lcg(&mut seed) as usize) % (n + 1)) + .collect(); + cuts.push(0); + cuts.push(n); + cuts.sort_unstable(); + partitions.push(cuts); + } + for cuts in &partitions { + empty_extent |= cuts.windows(2).any(|w| w[0] == w[1]); + split_live_word |= cuts[1..cuts.len() - 1].iter().any(|&c| { + c % 64 != 0 + && (c - c % 64..c).any(|r| bit(&pl, r)) + && (c..(c - c % 64 + 64).min(n)).any(|r| bit(&pl, r)) + }); + } + + let mask = Operand::Plane(0); + let addrs = [ + (GroupKey::Lane(0), 7usize), + (GroupKey::Via { fk: 2, key: 0 }, 7), + ( + GroupKey::Pair { + hi: 3, + lo: 4, + stride: 4, + }, + 13, + ), + ]; + let mut terminals: Vec<(Terminal, usize)> = vec![ + ( + Terminal::GroupSumI32 { + mask, + key: 0, + val: 1, + }, + 7, + ), + ( + Terminal::GroupSumViaI32 { + mask, + fk: 2, + key: 0, + val: 1, + }, + 7, + ), + ]; + for (gk, groups) in addrs { + for fold in [ + GroupFold::Count, + GroupFold::MinI32(1), + GroupFold::MaxI32(1), + GroupFold::SumSymI32(1), + ] { + terminals.push(( + Terminal::GroupReduce { + mask, + key: gk, + fold, + }, + groups, + )); + } + } + + let words = n.div_ceil(64); + for (t, groups) in &terminals { + let (t, groups) = (*t, *groups); + // The oracle knows nothing about tiles, extents or merges. + let mut want = vec![0i64; groups]; + reference_execute_into( + &Program::new(vec![], t), + &planes, + &foreign, + Out::I64(&mut want), + ) + .expect("oracle"); + empty_group |= matches!(t, Terminal::GroupReduce { .. }) + && want.iter().any(|&v| v == identity(&t)); + for tile_words in [2usize, words] { + let whole = part(&t, &planes, &foreign, tile_words, groups, 0..n); + assert_eq!(whole, want, "{t:?} tile={tile_words}: whole run vs oracle"); + for cuts in &partitions { + let spans: Vec<_> = cuts.windows(2).map(|w| w[0]..w[1]).collect(); + let parts: Vec> = spans + .iter() + .map(|e| part(&t, &planes, &foreign, tile_words, groups, e.clone())) + .collect(); + let id = identity(&t); + for g in 0..groups { + let live = parts.iter().filter(|p| p[g] != id && p[g] != 0).count(); + spans_two |= live > 1; + only_one |= live == 1 && parts.len() > 1; + } + let k = parts.len(); + let mut shuffled: Vec = (0..k).collect(); + for i in (1..k).rev() { + shuffled.swap(i, (lcg(&mut seed) as usize) % (i + 1)); + } + for order in [ + (0..k).collect::>(), + (0..k).rev().collect(), + (0..k).map(|i| (i + 1) % k).collect(), + shuffled, + ] { + let at = + format!("n={n} {t:?} tile={tile_words} cuts={cuts:?} order={order:?}"); + assert_eq!(merge_linear(&t, &parts, &order), whole, "linear {at}"); + } + assert_eq!( + merge_tree(&t, &parts), + whole, + "tree n={n} {t:?} tile={tile_words} cuts={cuts:?}" + ); + } + } + } + } + assert!(spans_two, "some group must be live in two partials"); + assert!(only_one, "some group must be live in exactly one partial"); + assert!(empty_group, "some group must be empty in the whole"); + assert!(empty_extent, "some partition must contain an empty extent"); + assert!(split_live_word, "some cut must split a live 64-row word"); +} + +/// FAILS IF: the `_sym` SUM merges with plain `+`, reads its empty marker as +/// `0`, or lets a real zero sum collapse into "empty". Each case is built so +/// it CAN fire: a group present on one side only, a group empty on both, and +/// a group whose two partials cancel to a present `0`. +#[test] +fn sym_sum_merge_law_can_fire() { + let f = GroupFold::SumSymI32(0); + let empty = f.seed(); + assert_eq!(f.merge(empty, 5), 5, "⊥ ⊕ x = x"); + assert_eq!(f.merge(5, empty), 5, "x ⊕ ⊥ = x"); + assert_eq!(f.merge(empty, empty), empty, "⊥ ⊕ ⊥ = ⊥"); + assert_eq!(f.merge(7, -7), 0, "a real zero sum"); + assert!(!f.is_empty_slot(f.merge(7, -7)), "a real zero is NOT empty"); + // What the forbidden `+` would have produced, so the law is not vacuous: + assert_ne!(empty.wrapping_add(5), 5); + assert_ne!(empty.wrapping_add(empty), empty); + + // The same cases through execution: rows 0..64 and 64..128 are two + // extents. Group 0: +7 in A, −7 in B → present 0. Group 1: only in A. + // Group 2: only in B. Group 3: never named → empty. + let n = 128; + let key: Vec = (0..n as u32) + .map(|r| match r { + 0 | 64 => 0, + 1..=5 => 1, + 70..=72 => 2, + _ => 9, // past the universe + }) + .collect(); + let val: Vec = (0..n as i32) + .map(|r| match r { + 0 => 7, + 64 => -7, + _ => r, + }) + .collect(); + let pl = plane(n, |_| true); + let masks: [&[u64]; 1] = [&pl]; + let lanes = [LaneRef::U32(&key), LaneRef::I32(&val)]; + let planes = Planes { + n_rows: n, + masks: &masks, + lanes: &lanes, + }; + let t = Terminal::GroupReduce { + mask: Operand::Plane(0), + key: GroupKey::Lane(0), + fold: GroupFold::SumSymI32(1), + }; + let a = part(&t, &planes, &Foreign::NONE, 1, 4, 0..64); + let b = part(&t, &planes, &Foreign::NONE, 1, 4, 64..n); + assert_eq!(a, vec![7, 15, empty, empty]); + assert_eq!(b, vec![-7, empty, 213, empty]); + let mut acc = a.clone(); + t.merge_group_sink(&mut acc, &b).expect("merge"); + let whole = part(&t, &planes, &Foreign::NONE, 1, 4, 0..n); + assert_eq!(acc, whole); + assert_eq!(acc, vec![0, 15, 213, empty]); + assert!(!GroupFold::SumSymI32(1).is_empty_slot(acc[0])); +} + +/// Finalize the way a consumer does (cf. quack's `normalize_group_sink`): +/// an empty slot becomes `None` (SQL `NULL`), everything else its value. +fn finalize(fold: GroupFold, slot: i64) -> Option { + (!fold.is_empty_slot(slot)).then_some(slot) +} + +/// FAILS IF: finalize-after-merge is wrong, OR merging finalized slots +/// happens to be right — the second half pins that the raw-only domain of +/// `merge` is load-bearing. A consumer that coalesces `NULL` to `0` before +/// merging gets `MIN(0, 5) = 0` for a group whose true minimum is `5`. +#[test] +fn finalize_once_after_merge_never_merge_finalized() { + let n = 128; + let key: Vec = (0..n as u32) + .map(|r| if r == 100 { 0 } else { 9 }) + .collect(); + let val: Vec = vec![5; n]; + let pl = plane(n, |_| true); + let masks: [&[u64]; 1] = [&pl]; + let lanes = [LaneRef::U32(&key), LaneRef::I32(&val)]; + let planes = Planes { + n_rows: n, + masks: &masks, + lanes: &lanes, + }; + for fold in [GroupFold::MinI32(1), GroupFold::SumSymI32(1)] { + let t = Terminal::GroupReduce { + mask: Operand::Plane(0), + key: GroupKey::Lane(0), + fold, + }; + let a = part(&t, &planes, &Foreign::NONE, 1, 1, 0..64); // group 0 empty here + let b = part(&t, &planes, &Foreign::NONE, 1, 1, 64..n); // value 5 here + let mut raw = a.clone(); + t.merge_group_sink(&mut raw, &b).expect("merge"); + assert_eq!( + finalize(fold, raw[0]), + Some(5), + "{fold:?}: finalize(merge(raw))" + ); + + // The wrong order: finalize each partial, coalesce NULL to 0, merge. + let coalesce = + |s: &[i64]| -> Vec { s.iter().map(|&v| finalize(fold, v).unwrap_or(0)).collect() }; + let mut fin = coalesce(&a); + t.merge_group_sink(&mut fin, &coalesce(&b)).expect("merge"); + if matches!(fold, GroupFold::MinI32(_)) { + assert_eq!( + fin[0], 0, + "merge(finalized) is WRONG for MIN, not merely different" + ); + assert_ne!(finalize(fold, fin[0]), Some(5)); + } + } +} + +/// FAILS IF: the merge accepts a terminal with no keyed i64 sink, or two +/// sinks of different LENGTHS — or writes before refusing. This is a length +/// check only: same length != same destination universe, and nothing here +/// can tell (TD-KEYED-SINK-MERGE-IDENTITY-1). +#[test] +fn merge_refuses_unmergeable_terminals_and_mismatched_sink_lengths() { + let mut acc = vec![1i64, 2, 3]; + let count = Terminal::Count { + mask: Operand::Plane(0), + }; + assert_eq!( + count.merge_group_sink(&mut acc, &[1, 1, 1]), + Err(ExecError::ExtentUnsupported { + what: "merge_group_sink" + }) + ); + let sum = Terminal::GroupSumI32 { + mask: Operand::Plane(0), + key: 0, + val: 1, + }; + assert_eq!( + sum.merge_group_sink(&mut acc, &[1, 1]), + Err(ExecError::LenMismatch { + what: "merge_group_sink", + expected: 3, + found: 2 + }) + ); + assert_eq!(acc, vec![1, 2, 3], "a refusal writes nothing"); + assert_eq!(sum.merge_group_sink(&mut acc, &[1, 1, 1]), Ok(())); + assert_eq!(acc, vec![2, 3, 4]); +} + +/// FAILS IF: the `_sym` law were associative even where a present total +/// wraps onto ⊥ — i.e. if the row bound were NOT what makes the merge +/// lawful. Two present partials of `i64::MIN + 1` plus `-1`: grouped one way +/// the inner sum is exactly `⊥` and absorbs, grouped the other it is not. +/// Real partials cannot produce these totals: their rows stay within +/// `GROUP_SUM_SYM_MAX_ROWS`, which is why that bound is stated over the +/// TOTAL rows of all merged partials, never per partial. +#[test] +fn sym_sum_bound_is_load_bearing() { + let f = GroupFold::SumSymI32(0); + let (a, b, c) = (i64::MIN + 1, i64::MIN + 1, -1); + assert_eq!(f.merge(b, c), f.seed(), "a present total landed on ⊥"); + assert_ne!(f.merge(f.merge(a, b), c), f.merge(a, f.merge(b, c))); +} + +/// FAILS IF: a fold's merge is not a monoid with its seed as identity — +/// checked directly on extreme values, not only through execution. +/// Commutativity is checked too; it holds for THESE folds, and is a property +/// of each fold, not of the merge contract (an ordered-segment fold such as +/// a key-run carry may be associative without it). +#[test] +fn fold_merge_laws_hold_on_extremes() { + let xs = [ + i64::MIN + 1, + -1_000_000_007, + -1, + 0, + 1, + i64::from(i32::MAX), + i64::from(i32::MIN), + i64::MAX - 1, + ]; + for fold in [ + GroupFold::Count, + GroupFold::MinI32(0), + GroupFold::MaxI32(0), + GroupFold::SumSymI32(0), + ] { + // The `_sym` SUM is a monoid only while no PRESENT total reaches ⊥. + // `GROUP_SUM_SYM_MAX_ROWS` guarantees that for real partials: every + // total of at most 2^32 − 1 rows of i32 lies in ±(2^63 − 1). Here the + // domain is |x| ≤ 2^61, so no sum of three operands can reach ⊥. + // Outside it the law breaks; `sym_sum_bound_is_load_bearing` pins how. + let in_domain = |x: i64| match fold { + GroupFold::SumSymI32(_) => x.unsigned_abs() <= 1 << 61, + _ => true, + }; + let mut domain: Vec = xs.iter().copied().filter(|&x| in_domain(x)).collect(); + domain.push(fold.seed()); + for &a in &domain { + assert_eq!( + fold.merge(fold.seed(), a), + a, + "{fold:?}: left identity at {a}" + ); + assert_eq!( + fold.merge(a, fold.seed()), + a, + "{fold:?}: right identity at {a}" + ); + for &b in &domain { + assert_eq!( + fold.merge(a, b), + fold.merge(b, a), + "{fold:?}: commutes {a} {b}" + ); + for &c in &domain { + assert_eq!( + fold.merge(fold.merge(a, b), c), + fold.merge(a, fold.merge(b, c)), + "{fold:?}: associates {a} {b} {c}" + ); + } + } + } + } +}