diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index c439b4509..73749de4a 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -10,7 +10,7 @@ Plan: `.claude/plans/d-relalg-unified-v1.md`. Decisions recorded there: ADMISSIO | **D-RU-3** | cross-rule CSE in crepe-plan-probe (256-rule benchmark, separate from the hop benchmark) | Queued (after D-RU-2) | answers == crepe and ascent; traversals and materializations measured | | **D-RU-4** | co-addressed join operator over `JoinDomain` | Queued (after D-RU-2) | parity with the star path; no position taken from another table's values | | **D-RU-5** | Quack physical pins (dense vs sorted, materialize vs recompute) | Queued (after D-RU-3/4) | can-fire and can-stay-silent per pin | -| **D-RU-6** | recursion driver over BIND tiles (256-hop benchmark) | Queued (slot assignment must be argued as an attested frozen-codebook lookup under ADMISSION = B) | least fixpoint == datafrog; ≥ 200 iterations asserted; cycles terminate | +| **D-RU-6** | recursion driver over BIND tiles (256-hop benchmark) | Queued/Lab (slot assignment must be argued as an attested frozen-codebook lookup under ADMISSION = B). Lab probe MEASURED (`crepe-plan-probe/examples/walk_tile.rs`, `entries/2026-10-21-d-ru-6-tile-walk-probe.md`): tuple sets equal ascent, crepe and walk_mask; execution gather-free; binding witness probe-local, so ADMISSION B is NOT demonstrated; count-only 1.0–9.3× slower than walk_mask, end to end 1.1–2.0× slower | least fixpoint == datafrog; ≥ 200 iterations asserted; cycles terminate. Benchmark requirement (operator, 2026-10-21; refs D-RMAP-2 §5b, #1642): tuple materialization, count-only evaluation, memory growth, address binding and value-derived reads by origin reported separately; tuple-set equality with crepe and ascent wherever they run | | **D-RU-7** | prefetch and staging of Arm B ranges | Queued (after D-RU-6 or a non-recursive multi-stage plan) | critical-path overlap measured; bytes read unchanged | | **D-RU-8** | Wikidata- and SAP-shaped fixtures with completeness refusals | Queued (after D-RU-6/7) | `!R` refused under an incomplete scope | diff --git a/.claude/board/entries/2026-10-21-d-ru-6-tile-walk-probe.md b/.claude/board/entries/2026-10-21-d-ru-6-tile-walk-probe.md new file mode 100644 index 000000000..49972f558 --- /dev/null +++ b/.claude/board/entries/2026-10-21-d-ru-6-tile-walk-probe.md @@ -0,0 +1,103 @@ +# 2026-10-21 — D-RU-6-LAB-1: graph_walking over co-addressed RMAP tiles, measured against walk_mask + +**Status:** MEASURED, lab only. D-RU-6 stays **Queued/Lab**: ADMISSION B is not demonstrated for the binding path, and passing tests is not an admission. Plan: `.claude/plans/d-relalg-unified-v1.md` §8, §10, §14 (U6). References: D-RMAP-2 §5b (`d-rmap-2-context-tile-algebra-probe-v1.md`, tile kernels consume prepared relationships), #1642 (the `walk_mask` tuple oracle and fixtures). + +## Benchmark requirement (operator, 2026-10-21) + +Every D-RU-6 comparison reports these separately, never folded into one number: +1. tuple materialization (decoding to the canonical `Walk` / `NoWalk` sets); +2. count-only evaluation (no tuple built); +3. memory growth (resident, per engine, in a fresh process); +4. address binding: attestation, witness validation, identity resolution and focus binding, projection; +5. value-derived reads, by origin. + +Correctness is the full tuple set against crepe and ascent wherever they run; Boolean reachability alone does not count. Above the oracle size the result is marked UNCHECKED and compared engine against engine only. + +## What landed + +`crates/crepe-plan-probe/examples/walk_tile.rs`, a separate example; the baseline `walk_mask.rs` is compiled in as a module, and it only gains `pub(crate)` on the reused items. +- **Binding.** The node ids form a frozen sorted codebook, with one `rmap::Focus` per 256 ids. The witness is `rmap::Witness`: facts digest, codebook seal, focus generation, scope. +- **Projection.** One block matrix of 256 × 256 tiles per edge length, `rmap::tile_addr` layout. +- **Recursion.** Semi-naive over length planes, `new &= !walk; walk |= new`. The hop is a Boolean tile product in predicated form: per coordinate `y`, `C |= broadcast(row y of A) & spread(bit y of each row of B)`, as one mask-risc `Ternlog` (0xF8) program. Every address is a loop coordinate. +- **Paging.** More than 256 nodes are paged over several foci; products run over all `(I, K, J)` tile triples, and cross-page edges are counted. A focus limit that the nodes exceed is refused (`IncompleteFocus`), never truncated. A scope that misses reached ids is refused. +- **Guards.** `admit` refuses any program containing `MaskOp::Gather` before execution. A stale witness (focus generation, facts, seal) is refused. + +**Tests (23, of which 8 are the baseline's own):** +- tuple-set equality with ascent, crepe and walk_mask on mixed lengths {1,2,3,5,7}, cycles, alternative path lengths, zero-length and over-length edges, ids that are not nodes, 255/256/257/300 nodes across foci, the benchmark generator over two foci, and a sparse graph over three foci; +- a chain that needs ≥ 200 iterations (230 measured); +- a zero-length 200-ring that terminates; +- the tile product against `tile_gemm::BitMat::compose`; +- refusals. + +**Disable runs** (each reverted afterwards; all red): + +| Disable | Red | +|---|---| +| `admit` lets `Gather` through | the gather test | +| witness check removed | the stale test | +| escape refusal removed | the scope test | +| page cap removed | the cap test | +| length bound made exclusive | 6 tests | +| cross-page products dropped | 2 tests | +| spread reads `y ^ 1` | 8+ tests | +| decode shifts a length | 8+ tests | + +CI runs `cargo test --example walk_tile`; the benchmark is not run in CI. + +## Measured (release, 4 vCPU, load < 1.3, medians of 7) + +| fixture | nodes | foci | mask count-only ms | tile count-only ms | tile/mask | mask + decode ms | tile + decode ms | tile/mask | oracle | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---| +| edges(128) | 128 | 1 | 0.43 | 0.72 | 1.7× | 4.14 | 4.54 | 1.1× | equal (ascent, crepe) | +| edges(256) | 256 | 1 | 1.28 | 1.32 | 1.0× | 15.8 | 18.2 | 1.2× | equal | +| edges(512) | 512 | 2 | 3.07 | 8.95 | 2.9× | 68.3 | 74.7 | 1.1× | equal | +| edges(1024) | 1024 | 4 | 12.7 | 72.0 | 5.7× | 277 | 384 | 1.4× | UNCHECKED (tile = mask) | +| chain(230) | 230 | 1 | 7.43 | 27.6 | 3.7× | 16.8 | 33.6 | 2.0× | equal | +| mixed(256) | 256 | 1 | 8.65 | 80.0 | 9.3× | 298 | 568 | 1.9× | equal | + +"Tile count-only" includes attestation, validation, binding, projection, recursion and counting. Its phases at edges(1024), in ms: attest 0.84, validate < 0.001, bind 0.80, project 1.74, recurse 67.6, count 0.16, decode 318. + +**Memory.** Resident growth (VmHWM in a fresh process, MiB): + +| | mask count-only | tile count-only | mask + decode | tile + decode | +|---|---:|---:|---:|---:| +| edges(512) | 3.61 | 0.66 | 13.9 | 10.9 | +| edges(1024) | 14.2 | 2.23 | 50.7 | 44.9 | +| mixed(256) | 0.65 | 1.30 | 57.1 | 57.5 | + +At edges(1024), the mask planes take 19.1 MiB (three dense sets of 51 planes) and the tile walk takes 1.28 MiB, because only non-empty tiles exist. + +**Value-derived reads at edges(1024):** + +| Engine | Phase | Reads | +|---|---|---| +| walk_mask | execution (delta row at an adjacency bit) | 419,230 | +| tile | preparation (codebook lookups, 2 per edge) | 83,846 | +| tile | execution | 0 | +| tile | decoding (codebook decodes, 2 per output tuple) | 4,036,624 | + +## Reading + +- **Value-derived reads in execution: removed.** The tile recursion reads no position taken from a value, and `Gather` is refused before execution. +- **Speed: lost, everywhere but one cell.** Count-only, the tile path is 1.0× (edges(256), one focus, single edge length) to 9.3× slower (mixed lengths). End to end it is 1.1–2.0× slower; the gap narrows only because decoding (BTreeSet) dominates both engines. +- **Where the time goes.** The predicated product touches 3 × 1,024 words per contributing `y`, whatever the sparsity. walk_mask touches `w` words per set adjacency bit. Recursion is 94% of the tile path's count-only time at edges(1024). +- **Binding costs are small.** Attest, bind and project together are about 3.4 ms at edges(1024), about 4.7% of the count-only time. They are reported, not hidden. +- **Memory is the one gain.** Only non-empty tiles exist, so the count-only resident growth is 6.4× smaller at edges(1024). Decoding erases that. +- **Not measured:** a sparsity-aware co-addressed product (skipping whole 64-row blocks of `B` without addressing by value); AMX or BF16 tile products for this program (D-RMAP-2 §5a: the bit arm beats them on Boolean composition); identity-scale paging (65,536 nodes). + +## ADMISSION B classification + +| Step | Classification | +|---|---| +| attestation, codebook build | same-table sequential scans, counted | +| slot resolution | one frozen-codebook lookup per edge endpoint, keyed by the edge's own value. This is the shape the ruling admits as representation translation, but the witness is a probe-local digest (`rmap::Witness`), not the attested sorted key the ruling names. **Not demonstrated.** | +| edge tiles | projection of the same table at resolved slots | +| recursion | co-addressed. **Demonstrated** (refusal guard, disable-verified). | +| decoding | frozen-codebook decoding, counted | + +**Verdict:** execution is gather-free; the complete preparation and binding path is not shown to satisfy ADMISSION B. D-RU-6 stays Queued/Lab. No production promotion. + +## Open + +- A contract-level attestation for the codebook (an `OrderedLaneWitness`-shaped seal), before any ADMISSION B claim. +- Whether a co-addressed product can use block sparsity of `B` to close the 1.0–9.3× count-only gap without a value-addressed read. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 9273c10f0..192eb50ee 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -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. -397 entries, 2026-08-06 .. 2026-10-20. +398 entries, 2026-08-06 .. 2026-10-21. | date | entry id | finding | file | |---|---|---|---| +| 2026-10-21 | `D-RU-6-LAB-1` | | [2026-10-21-d-ru-6-tile-walk-probe.md](2026-10-21-d-ru-6-tile-walk-probe.md) | | 2026-10-20 | `D-RU-1` | | [2026-10-20-u1-jc-batch.md](2026-10-20-u1-jc-batch.md) | | 2026-10-20 | `thought-git-replay-api-write-rubicon` | | [2026-10-20-thought-git-replay-api-write-rubicon.md](2026-10-20-thought-git-replay-api-write-rubicon.md) | | 2026-10-20 | `ADR-2026-10-20-SIX` | | [2026-10-20-six-decisions.md](2026-10-20-six-decisions.md) | diff --git a/.claude/plans/d-relalg-unified-v1.md b/.claude/plans/d-relalg-unified-v1.md index f4490fe6a..4fea50142 100644 --- a/.claude/plans/d-relalg-unified-v1.md +++ b/.claude/plans/d-relalg-unified-v1.md @@ -202,6 +202,8 @@ This is the `walk_mask` loop. To be lawful it needs the step "rule fragment appl **Benchmark requirement.** At least one input must need deep propagation. A chain or a ladder of length ≥ 200 forces the loop to run that many iterations (asserted, not assumed), plus a cyclic graph to check termination. +**Benchmark requirement, D-RU-6 (operator, 2026-10-21).** Every comparison against `walk_mask` (#1642) reports separately: tuple materialization, count-only evaluation, memory growth, address binding (attestation, witness validation, identity resolution and focus binding, projection), and value-derived reads by origin. Correctness is the full `Walk` / `NoWalk` tuple sets against crepe and ascent wherever they run, never Boolean reachability alone. D-RMAP-2 §5b's split of preparation, execution and maintenance applies. First lab measurement: `entries/2026-10-21-d-ru-6-tile-walk-probe.md` (execution gather-free; binding not shown to satisfy ADMISSION B; the tile path loses on time and wins on count-only memory). + ## 9. Physical planning in Quack Quack has no cost model, and its own docs say a selectivity-only model cannot separate the measured regimes (`lib.rs:674-688`). The first step is therefore what #1639 just did for Arm B: per-decision **policy pins measured on fixtures**, each with a can-fire and a can-stay-silent test. Not a general cost model. Candidate decisions: diff --git a/.github/workflows/crepe-plan-probe.yml b/.github/workflows/crepe-plan-probe.yml index ba40ba091..b52baa283 100644 --- a/.github/workflows/crepe-plan-probe.yml +++ b/.github/workflows/crepe-plan-probe.yml @@ -56,6 +56,9 @@ jobs: # when named. The benchmark itself (`cargo run --release --example # walk_mask`) is not run in CI. - run: cargo test --example walk_mask + # D-RU-6 lab probe: the same program over RMAP tiles, its oracle + # comparisons and refusals. Its benchmark is not run in CI either. + - run: cargo test --example walk_tile # relalg is its own workspace and patches ndarray to a sibling checkout # (`../../../ndarray` from its directory); no other job runs its tests. - name: relalg tests and clippy diff --git a/crates/crepe-plan-probe/examples/walk_mask.rs b/crates/crepe-plan-probe/examples/walk_mask.rs index 9eadf4d57..231608b56 100644 --- a/crates/crepe-plan-probe/examples/walk_mask.rs +++ b/crates/crepe-plan-probe/examples/walk_mask.rs @@ -60,7 +60,7 @@ use std::time::{Duration, Instant}; use crepe::crepe; use ndarray::simd::{mask_andnot_assign, mask_any, mask_or_assign, popcount_batch_u64}; -const MAX_PATH_LEN: u32 = 50; +pub(crate) const MAX_PATH_LEN: u32 = 50; const REPS: usize = 9; /// Largest `n` the benchmark runs the ascent and crepe oracles for. const ORACLE_MAX_N: usize = 512; @@ -104,7 +104,7 @@ ascent::ascent! { } /// The benchmark's edges (every edge has length 5). -fn edges(n: i32) -> Vec<(i32, i32, u32)> { +pub(crate) fn edges(n: i32) -> Vec<(i32, i32, u32)> { let mut e = Vec::new(); for i in 0..n { for j in 0..n { @@ -118,13 +118,13 @@ fn edges(n: i32) -> Vec<(i32, i32, u32)> { /// The two output relations as canonical sets. #[derive(Debug, PartialEq, Eq)] -struct Tuples { - walk: BTreeSet<(i32, i32, u32)>, - no_walk: BTreeSet<(i32, i32)>, +pub(crate) struct Tuples { + pub(crate) walk: BTreeSet<(i32, i32, u32)>, + pub(crate) no_walk: BTreeSet<(i32, i32)>, } impl Tuples { - fn counts(&self) -> (usize, usize) { + pub(crate) fn counts(&self) -> (usize, usize) { (self.walk.len(), self.no_walk.len()) } } @@ -138,12 +138,12 @@ fn run_ascent(e: &[(i32, i32, u32)]) -> WalkAscent { p } -fn ascent_walk(e: &[(i32, i32, u32)]) -> (usize, usize) { +pub(crate) fn ascent_walk(e: &[(i32, i32, u32)]) -> (usize, usize) { let p = run_ascent(e); (p.walk.len(), p.no_walk.len()) } -fn ascent_tuples(e: &[(i32, i32, u32)]) -> Tuples { +pub(crate) fn ascent_tuples(e: &[(i32, i32, u32)]) -> Tuples { let p = run_ascent(e); Tuples { walk: p.walk.into_iter().collect(), @@ -158,12 +158,12 @@ fn run_crepe(e: &[(i32, i32, u32)]) -> (Vec, Vec) { (walk.into_iter().collect(), nowalk.into_iter().collect()) } -fn crepe_walk(e: &[(i32, i32, u32)]) -> (usize, usize) { +pub(crate) fn crepe_walk(e: &[(i32, i32, u32)]) -> (usize, usize) { let (walk, nowalk) = run_crepe(e); (walk.len(), nowalk.len()) } -fn crepe_tuples(e: &[(i32, i32, u32)]) -> Tuples { +pub(crate) fn crepe_tuples(e: &[(i32, i32, u32)]) -> Tuples { let (walk, nowalk) = run_crepe(e); Tuples { walk: walk.into_iter().map(|Walk(x, z, l)| (x, z, l)).collect(), @@ -185,7 +185,7 @@ fn bit(row: &[u64], i: usize) -> bool { } /// The fixpoint of the program over bit planes. -struct Planes { +pub(crate) struct Planes { n: usize, w: usize, lens: usize, @@ -193,7 +193,7 @@ struct Planes { /// `walk[l][x]`: the nodes `z` with `Walk(x, z, l)`. walk: Vec, /// Value-addressed row reads (`delta[row(l2, y)]` for a set bit `y`). - row_reads: u64, + pub(crate) row_reads: u64, } impl Planes { @@ -211,7 +211,7 @@ impl Planes { } /// `(|Walk|, |NoWalk|)` from popcounts; no tuple is built. - fn counts(&self) -> (usize, usize) { + pub(crate) fn counts(&self) -> (usize, usize) { let walk = popcount_batch_u64(&self.walk) as usize; let mut reach = vec![0u64; self.w]; let mut miss = vec![0u64; self.w]; @@ -229,7 +229,7 @@ impl Planes { } /// Decode the planes into the canonical tuple sets. - fn tuples(&self) -> Tuples { + pub(crate) fn tuples(&self) -> Tuples { let mut walk = BTreeSet::new(); let mut no_walk = BTreeSet::new(); for x in (0..self.n).filter(|&x| self.is_node(x)) { @@ -249,13 +249,13 @@ impl Planes { } /// Bytes of one length-indexed plane set; the fixpoint holds three. - fn plane_bytes(&self) -> usize { + pub(crate) fn plane_bytes(&self) -> usize { self.walk.len() * 8 } } /// The program over bit planes. Node ids must lie in `0..n`. -fn mask_walk(n: usize, e: &[(i32, i32, u32)]) -> Planes { +pub(crate) fn mask_walk(n: usize, e: &[(i32, i32, u32)]) -> Planes { let w = words(n); let lens = MAX_PATH_LEN as usize + 1; let row = |plane: usize, x: usize| (plane * n + x) * w; @@ -358,7 +358,7 @@ fn difference(a: &BTreeSet, b: &BTreeSet) -> Str } /// Compare the mask tuples with one oracle's; `Err` names the difference. -fn agree(mask: &Tuples, oracle: &Tuples) -> Result<(), String> { +pub(crate) fn agree(mask: &Tuples, oracle: &Tuples) -> Result<(), String> { if mask.walk != oracle.walk { return Err(format!("Walk: {}", difference(&mask.walk, &oracle.walk))); } @@ -462,7 +462,7 @@ fn main() { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; /// The mask tuples equal both oracles' tuples, and the count-only path @@ -491,11 +491,11 @@ mod tests { } } - const LENGTHS: [u32; 5] = [1, 2, 3, 5, 7]; + pub(crate) const LENGTHS: [u32; 5] = [1, 2, 3, 5, 7]; /// `n` nodes, `per_node` random out-edges each with a length from /// `LENGTHS`, plus the ring `0 -> 1 -> 2 -> 0`. - fn random_graph(n: usize, per_node: usize, seed: u64) -> Vec<(i32, i32, u32)> { + pub(crate) fn random_graph(n: usize, per_node: usize, seed: u64) -> Vec<(i32, i32, u32)> { let mut r = Rng(seed); let mut e = vec![(0, 1, 1), (1, 2, 2), (2, 0, 3)]; for x in 0..n { diff --git a/crates/crepe-plan-probe/examples/walk_tile.rs b/crates/crepe-plan-probe/examples/walk_tile.rs new file mode 100644 index 000000000..f211deb85 --- /dev/null +++ b/crates/crepe-plan-probe/examples/walk_tile.rs @@ -0,0 +1,1178 @@ +//! D-RU-6 lab probe: crepe's `graph_walking` program over co-addressed RMAP +//! Boolean tiles, measured against `walk_mask` (the baseline, #1637, #1642). +//! +//! STATUS: LAB ONLY. Nothing here is a production path, and passing these +//! tests is not an admission. D-RU-6 stays Queued/Lab +//! (`.claude/plans/d-relalg-unified-v1.md` §8, §10, §14 U6). +//! +//! ```text +//! cargo run --release --example walk_tile # the comparison +//! cargo test --example walk_tile # correctness and refusals +//! ``` +//! +//! # The program +//! +//! Same as `walk_mask` and crepe's benchmark: `Walk(x, z, len)` for +//! `len in 0..=MAX_PATH_LEN`, `NoWalk(x, y)` over nodes, set semantics, least +//! fixpoint. Several lengths per pair, cycles, zero-length edges, ids that are +//! not nodes and edges longer than the bound are all kept. +//! +//! # The two executors +//! +//! * **walk_mask** (the baseline, unchanged). One `n x n` bit plane per +//! length. The hop iterates the set bits `y` of an adjacency row and reads +//! `delta[row(l2, y)]`: a row address taken from a value (a gather). +//! * **walk_tile** (this file). Nodes are bound to 256-slot RMAP foci +//! ([`Focus`]); every relation is a block matrix of 256 x 256 tiles over the +//! `u8:u8` rail ([`tile_addr`]). The hop is a Boolean tile product +//! `C = A ∘ B` evaluated in predicated form: for every coordinate `y`, +//! `C |= broadcast(row y of A) & spread(bit y of every row of B)`, one +//! mask-risc `Ternlog` program per `y`. Every address is a loop coordinate +//! (`y`, a row `z`, a tile `(I, J)`); a value only decides what is written, +//! never where something is read. +//! +//! # Phases, measured separately +//! +//! 1. attestation (a digest of the canonical edge list) and witness +//! validation; +//! 2. identity resolution and focus binding (the frozen codebook and one +//! [`Focus`] per 256 nodes); +//! 3. projection (edge tiles per length); +//! 4. recursion (the semi-naive fixpoint over tiles); +//! 5. tuple decoding, and the count-only alternative; +//! 6. end to end, peak heap, and value-derived reads by origin. +//! +//! # ADMISSION B: what this probe shows and what it does not +//! +//! The rule (operator, 2026-10-20, `entries/2026-10-20-six-decisions.md` §1): +//! preparation may do counted, version-witnessed same-table seeks over an +//! attested sorted key and build a projection for co-addressed evaluation; +//! frozen codebook decoding is representation translation if its mapping is +//! immutable within the attested version, its cost is accounted for, and it +//! does not become a relational lookup. Execution stays co-addressed. +//! +//! | Step | Reads | Classification here | +//! |---|---|---| +//! | attestation | sequential scan of the edge table | same-table, counted | +//! | codebook build | sequential scan of both endpoint columns, sort | same-table projection | +//! | slot resolution | one codebook lookup per edge endpoint, keyed by the edge's own value | frozen-codebook translation; probe witness, NOT the contract's attestation | +//! | edge tiles | writes at the resolved slots | projection of the same table | +//! | recursion | loop coordinates only; mask-risc programs admitted without `Gather` | co-addressed (demonstrated) | +//! | decoding | one codebook decode per output identity | frozen-codebook decoding | +//! +//! The execution side is gather-free, and a refusal guard enforces it. The +//! preparation side has the SHAPE ADMISSION B admits, but its witness is a +//! probe-local digest ([`Witness`] from `rmap`), not the attested sorted key +//! the ruling names (`OrderedLaneWitness`). So the binding path is NOT +//! demonstrated to satisfy ADMISSION B, and D-RU-6 does not move. +//! +//! Measured results and the disable runs: `.claude/board/entries/2026-10-21-d-ru-6-tile-walk-probe.md`. + +use std::collections::BTreeSet; +use std::hint::black_box; +use std::time::{Duration, Instant}; + +use lance_graph_mask_risc::{ + Foreign, MaskOp, Operand, Out, Planes, Program, Scratch, Terminal, execute_into, words_for, +}; +use mork_pathmap_probe::rmap::{Focus, Refusal, SLOTS, TILE_ROWS, TILE_WORDS, Witness, tile_addr}; + +// The baseline, compiled from its own file so the comparison runs the merged +// code and its oracles, not a copy. Its `main` and the helpers this example +// does not call are dead code here. +#[allow(dead_code)] +#[path = "walk_mask.rs"] +mod baseline; + +use baseline::{MAX_PATH_LEN, Tuples, agree, ascent_tuples, crepe_tuples, edges, mask_walk}; + +const LENS: usize = MAX_PATH_LEN as usize + 1; +const REPS: usize = 7; +const ORACLE_MAX_NODES: usize = 512; +/// Words per tile row (256 bits). +const ROW_WORDS: usize = SLOTS / 64; + +type Edge = (i32, i32, u32); + +// ---- refusals ---------------------------------------------------------------- + +/// Why the tile path refuses to answer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Refuse { + /// A witness check failed, or the answer would depend on identities + /// outside the bound foci. + Rmap(Refusal), + /// A program reads at a position taken from a value. + UnauthorizedGather, +} + +impl From for Refuse { + fn from(r: Refusal) -> Self { + Refuse::Rmap(r) + } +} + +/// Admit a program to the execution phase: no op may read at a position +/// taken from a lane's value. `MaskOp::Gather` is the only such op in +/// mask-risc's IR, and `Pred` reads lanes at the row's own position. +fn admit(p: &Program) -> Result<(), Refuse> { + if p.ops.iter().any(|op| matches!(op, MaskOp::Gather { .. })) { + return Err(Refuse::UnauthorizedGather); + } + Ok(()) +} + +// ---- phase 1: attestation ---------------------------------------------------- + +/// FNV-1a over a byte stream. +fn fnv(bytes: impl IntoIterator) -> u64 { + let mut h = 0xcbf2_9ce4_8422_2325u64; + for b in bytes { + h ^= u64::from(b); + h = h.wrapping_mul(0x0100_0000_01b3); + } + h +} + +/// The facts digest: the edge set in canonical order. A probe-local stand-in +/// for an attested sorted key (see the module docs). +fn attest(e: &[Edge]) -> u64 { + let mut s = e.to_vec(); + s.sort_unstable(); + s.dedup(); + fnv(s.iter().flat_map(|&(a, b, l)| { + a.to_le_bytes() + .into_iter() + .chain(b.to_le_bytes()) + .chain(l.to_le_bytes()) + })) +} + +// ---- phase 2: identity resolution and focus binding --------------------------- + +/// The frozen codebook (sorted node ids) and one [`Focus`] per 256 of them. +struct Bound { + witness: Witness, + codebook: Vec, + foci: Vec, +} + +impl Bound { + fn pages(&self) -> usize { + self.foci.len() + } + + fn nodes(&self) -> usize { + self.codebook.len() + } + + fn page_len(&self, p: usize) -> usize { + self.foci[p].len() + } + + /// Global id of slot `s` of page `p`: a codebook decode. + fn id(&self, p: usize, s: usize) -> i32 { + self.foci[p].id(s as u8) as i32 + } + + /// `(page, slot)` of a node id: a codebook lookup keyed by a value. `None` + /// when the id is not bound. + fn resolve(&self, id: i32) -> Option<(usize, u8)> { + let p = self + .foci + .partition_point(|f| f.id(0) as i32 <= id) + .checked_sub(1)?; + self.foci[p].slot_of(id as u32).map(|s| (p, s)) + } +} + +/// Bind every endpoint of `e` (all nodes) or, with `scope`, only the given +/// ids. Refuses more than `max_pages` foci: a global answer is never +/// truncated to the foci that fit. +fn bind( + e: &[Edge], + facts: u64, + generation: u64, + scope: Option<&[i32]>, + max_pages: usize, +) -> Result { + // The nodes are the edge endpoints (`Node(x) <- Edge(x, _, _)`, and the + // other end). A scope can only narrow them: an id that is in the scope but + // in no edge is not a node and gets no slot. + let mut codebook: Vec = e.iter().flat_map(|&(a, b, _)| [a, b]).collect(); + codebook.sort_unstable(); + codebook.dedup(); + if let Some(s) = scope { + let mut s = s.to_vec(); + s.sort_unstable(); + codebook.retain(|id| s.binary_search(id).is_ok()); + } + assert!( + codebook.first().is_none_or(|&f| f >= 0), + "ids must be non-negative" + ); + if codebook.len().div_ceil(SLOTS) > max_pages { + return Err(Refusal::IncompleteFocus.into()); + } + let foci: Vec = codebook + .chunks(SLOTS) + .map(|c| Focus::bind(&c.iter().map(|&i| i as u32).collect::>(), generation)) + .collect(); + let seal = fnv(codebook.iter().flat_map(|i| i.to_le_bytes())); + Ok(Bound { + witness: Witness { + facts, + seal, + focus: generation, + scope: u64::from(scope.is_some()), + }, + codebook, + foci, + }) +} + +// ---- phase 3: projection ----------------------------------------------------- + +/// A relation over the bound nodes: `pages x pages` tiles, row-major by page, +/// `None` for an all-zero tile. Tile `(I, J)` holds `R(x, z)` for `x` in page +/// `I`, `z` in page `J`, at `tile_addr(x, z)`: row `z`, bit `x`. +type Blocks = Vec>>; + +fn tile() -> Box<[u64]> { + vec![0u64; TILE_WORDS].into_boxed_slice() +} + +fn set(t: &mut [u64], i: usize) { + t[i >> 6] |= 1 << (i & 63); +} + +fn get(t: &[u64], i: usize) -> bool { + t[i >> 6] >> (i & 63) & 1 == 1 +} + +/// The edge relation, one block matrix per edge length. +struct Projection { + witness: Witness, + by_len: Vec<(usize, Blocks)>, + /// Ids an edge reaches that no focus binds (only under a `scope`). + escaped: BTreeSet, + /// Edges whose endpoints sit in different foci (crossed by paging). + cross_page_edges: u64, + /// Codebook lookups keyed by an edge value. + lookups: u64, +} + +fn project(b: &Bound, e: &[Edge]) -> Projection { + let pages = b.pages(); + let mut by_len: Vec> = vec![None; LENS]; + let mut escaped = BTreeSet::new(); + let mut cross_page_edges = 0u64; + let mut lookups = 0u64; + for &(a, z, l) in e { + lookups += 2; + let (Some((i, x)), Some((j, y))) = (b.resolve(a), b.resolve(z)) else { + for id in [a, z] { + if b.resolve(id).is_none() { + escaped.insert(id); + } + } + continue; + }; + let l = l as usize; + if l >= LENS { + continue; + } + if i != j { + cross_page_edges += 1; + } + let blocks = by_len[l].get_or_insert_with(|| vec![None; pages * pages]); + set( + blocks[i * pages + j].get_or_insert_with(tile), + tile_addr(x, y), + ); + } + Projection { + witness: b.witness, + by_len: by_len + .into_iter() + .enumerate() + .filter_map(|(l, b)| b.map(|b| (l, b))) + .collect(), + escaped, + cross_page_edges, + lookups, + } +} + +// ---- phase 4: recursion over tiles --------------------------------------------- + +/// The tile product kernel: broadcast and spread buffers, the mask-risc +/// program and its scratch, reused across calls. +struct Kernel { + bcast: Box<[u64]>, + spread: Box<[u64]>, + out: Box<[u64]>, + prog: Program, + scratch: Scratch<'static>, +} + +/// `dst = c | (p & q)`: `Ternlog` with `(a, b, c) = (C, P, Q)`, table `0xF8`. +fn product_program() -> Program { + Program::new( + vec![MaskOp::Ternlog { + imm: 0xF8, + a: Operand::Plane(0), + b: Operand::Plane(1), + c: Operand::Plane(2), + dst: 0, + }], + Terminal::Keep { + mask: Operand::Scratch(0), + }, + ) +} + +#[derive(Clone, Copy, Debug, Default)] +struct Counters { + tile_products: u64, + /// Coordinates `y` that contributed (both operands non-empty at `y`). + steps: u64, + /// mask-risc calls. + calls: u64, +} + +impl Kernel { + fn with_program(prog: Program) -> Result { + admit(&prog)?; + Ok(Kernel { + bcast: tile(), + spread: tile(), + out: tile(), + prog, + scratch: Scratch::new(words_for(TILE_ROWS), 1), + }) + } + + fn new() -> Kernel { + Kernel::with_program(product_program()).expect("the product program has no gather") + } + + /// `c |= a ∘ b`: `C(x, z) = ∃y A(x, y) ∧ B(y, z)`. Row `z` of `C` is the + /// OR over `y` of row `y` of `A`, taken when bit `y` of row `z` of `B` is + /// set. Every read is at a loop coordinate. + fn product_into(&mut self, a: &[u64], b: &[u64], c: &mut Box<[u64]>, n: &mut Counters) { + n.tile_products += 1; + for y in 0..SLOTS { + let ay = &a[y * ROW_WORDS..(y + 1) * ROW_WORDS]; + if ay.iter().all(|&w| w == 0) { + continue; + } + let mut any = false; + for z in 0..SLOTS { + let on = get(&b[z * ROW_WORDS..(z + 1) * ROW_WORDS], y); + any |= on; + let w = if on { u64::MAX } else { 0 }; + self.spread[z * ROW_WORDS..(z + 1) * ROW_WORDS].fill(w); + } + if !any { + continue; + } + for z in 0..SLOTS { + self.bcast[z * ROW_WORDS..(z + 1) * ROW_WORDS].copy_from_slice(ay); + } + n.steps += 1; + n.calls += 1; + let masks: [&[u64]; 3] = [c, &self.bcast, &self.spread]; + let pl = Planes { + n_rows: TILE_ROWS, + masks: &masks, + lanes: &[], + }; + execute_into( + &self.prog, + &pl, + &Foreign::NONE, + &mut self.scratch, + Out::Mask(&mut self.out), + ) + .expect("the product program is valid"); + std::mem::swap(c, &mut self.out); + } + } +} + +fn zero(pages: usize) -> Vec { + vec![vec![None; pages * pages]; LENS] +} + +/// The least fixpoint over tiles. +struct Run { + walk: Vec, + iterations: usize, + counters: Counters, +} + +fn recurse(b: &Bound, p: &Projection, current: &Witness, k: &mut Kernel) -> Result { + b.witness.check(current)?; + p.witness.check(current)?; + if !p.escaped.is_empty() { + return Err(Refusal::IncompleteFocus.into()); + } + let pages = b.pages(); + let mut walk = zero(pages); + let mut delta = zero(pages); + // Walk(x, x, 0) for every node. + for i in 0..pages { + let mut t = tile(); + for x in 0..b.page_len(i) { + set(&mut t, tile_addr(x as u8, x as u8)); + } + walk[0][i * pages + i] = Some(t.clone()); + delta[0][i * pages + i] = Some(t); + } + let mut live = vec![false; LENS]; + live[0] = true; + let mut counters = Counters::default(); + let mut iterations = 0usize; + // Every iteration that continues adds at least one bit. + let bound = LENS * b.nodes() * b.nodes() + 1; + loop { + iterations += 1; + assert!(iterations <= bound, "fixpoint did not terminate"); + let mut new = zero(pages); + for (l1, edge) in &p.by_len { + for l2 in (0..LENS).filter(|&l2| live[l2]) { + let l = l1 + l2; + if l >= LENS { + continue; + } + for i in 0..pages { + for j in 0..pages { + for m in 0..pages { + let (Some(a), Some(d)) = + (&edge[i * pages + m], &delta[l2][m * pages + j]) + else { + continue; + }; + let c = new[l][i * pages + j].get_or_insert_with(tile); + k.product_into(a, d, c, &mut counters); + } + } + } + } + } + let mut next_live = vec![false; LENS]; + for l in 0..LENS { + for t in 0..pages * pages { + let Some(n) = new[l][t].as_mut() else { + continue; + }; + let w = walk[l][t].get_or_insert_with(tile); + let mut any = false; + for (nw, &ww) in n.iter_mut().zip(w.iter()) { + *nw &= !ww; + any |= *nw != 0; + } + if any { + for (ww, &nw) in w.iter_mut().zip(n.iter()) { + *ww |= nw; + } + next_live[l] = true; + } else { + new[l][t] = None; + } + } + } + if !next_live.iter().any(|&x| x) { + break; + } + delta = new; + live = next_live; + } + Ok(Run { + walk, + iterations, + counters, + }) +} + +// ---- phase 5: decoding --------------------------------------------------------- + +/// Per-pair reach over all lengths, tile by tile. +fn reach(r: &Run, t: usize) -> Box<[u64]> { + let mut out = tile(); + for l in 0..LENS { + if let Some(w) = &r.walk[l][t] { + for (o, &x) in out.iter_mut().zip(w.iter()) { + *o |= x; + } + } + } + out +} + +fn popcount(t: &[u64]) -> usize { + t.iter().map(|w| w.count_ones() as usize).sum() +} + +/// `(|Walk|, |NoWalk|)` without building a tuple. +fn counts(b: &Bound, r: &Run) -> (usize, usize) { + let pages = b.pages(); + let walk = r.walk.iter().flatten().flatten().map(|t| popcount(t)).sum(); + let reached: usize = (0..pages * pages).map(|t| popcount(&reach(r, t))).sum(); + (walk, b.nodes() * b.nodes() - reached) +} + +/// The tuple sets, with every slot decoded through the codebook. +fn tuples(b: &Bound, r: &Run) -> (Tuples, u64) { + let pages = b.pages(); + let mut walk = BTreeSet::new(); + let mut no_walk = BTreeSet::new(); + let mut decodes = 0u64; + for i in 0..pages { + for j in 0..pages { + let t = i * pages + j; + for (l, plane) in r.walk.iter().enumerate() { + let Some(w) = &plane[t] else { continue }; + for z in 0..b.page_len(j) { + for x in 0..b.page_len(i) { + if get(w, tile_addr(x as u8, z as u8)) { + decodes += 2; + walk.insert((b.id(i, x), b.id(j, z), l as u32)); + } + } + } + } + let reached = reach(r, t); + for z in 0..b.page_len(j) { + for x in 0..b.page_len(i) { + if !get(&reached, tile_addr(x as u8, z as u8)) { + decodes += 2; + no_walk.insert((b.id(i, x), b.id(j, z))); + } + } + } + } + } + (Tuples { walk, no_walk }, decodes) +} + +/// Bytes held by the tiles of a run (walk planes only). +fn run_bytes(r: &Run) -> usize { + r.walk.iter().flatten().flatten().count() * TILE_WORDS * 8 +} + +// ---- the whole tile path --------------------------------------------------------- + +/// Every phase of one tile evaluation, timed. +struct TileReport { + attest: Duration, + validate: Duration, + bind: Duration, + project: Duration, + recurse: Duration, + count: Duration, + decode: Duration, + lookups: u64, + decodes: u64, + cross_page_edges: u64, + pages: usize, + iterations: usize, + counters: Counters, + tile_bytes: usize, + counts: (usize, usize), + tuples: Tuples, +} + +impl TileReport { + /// Every phase up to the counts: no tuple is built. + fn e2e_count(&self) -> Duration { + self.attest + self.validate + self.bind + self.project + self.recurse + self.count + } + + /// Every phase up to the decoded tuples. + fn e2e(&self) -> Duration { + self.attest + self.validate + self.bind + self.project + self.recurse + self.decode + } +} + +fn tile_eval(e: &[Edge], max_pages: usize) -> Result { + { + let s = Instant::now(); + let facts = attest(e); + let attest_t = s.elapsed(); + let s = Instant::now(); + let b = bind(e, facts, 1, None, max_pages)?; + let bind_t = s.elapsed(); + let s = Instant::now(); + let p = project(&b, e); + let project_t = s.elapsed(); + let mut k = Kernel::new(); + let s = Instant::now(); + black_box(b.witness.check(&p.witness))?; + let validate_t = s.elapsed(); + let s = Instant::now(); + let r = recurse(&b, &p, &b.witness, &mut k)?; + let recurse_t = s.elapsed(); + let s = Instant::now(); + let c = counts(&b, &r); + let count_t = s.elapsed(); + let s = Instant::now(); + let (t, decodes) = tuples(&b, &r); + let decode_t = s.elapsed(); + Ok(TileReport { + attest: attest_t, + validate: validate_t, + bind: bind_t, + project: project_t, + recurse: recurse_t, + count: count_t, + decode: decode_t, + lookups: p.lookups, + decodes, + cross_page_edges: p.cross_page_edges, + pages: b.pages(), + iterations: r.iterations, + counters: r.counters, + tile_bytes: run_bytes(&r), + counts: c, + tuples: t, + }) + } +} + +// ---- benchmark ------------------------------------------------------------------- + +fn median(mut v: Vec) -> Duration { + v.sort(); + v[v.len() / 2] +} + +fn ms(t: Duration) -> String { + format!("{:.3}", t.as_secs_f64() * 1e3) +} + +fn mib(b: usize) -> String { + format!("{:.2}", b as f64 / (1 << 20) as f64) +} + +/// A chain `0 -> 1 -> ... -> n-1`: length 0, except every 10th edge has +/// length 1, so a walk is at most `n / 10` long and the fixpoint needs about +/// `n` iterations. +fn chain(n: i32) -> Vec { + (0..n - 1) + .map(|i| (i, i + 1, u32::from(i % 10 == 9))) + .collect() +} + +/// Every id in `0..n` with two out-edges of length 1, 2, 3, 5 or 7 +/// (splitmix64, fixed seed), plus a ring. +fn mixed(n: i32, seed: u64) -> Vec { + let mut s = seed; + let mut next = || { + s = s.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = s; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let lens = [1, 2, 3, 5, 7]; + let mut e: Vec = (0..n).map(|i| (i, (i + 1) % n, 3)).collect(); + for x in 0..n { + for _ in 0..2 { + let y = (next() % n as u64) as i32; + e.push((x, y, lens[(next() % 5) as usize])); + } + } + e +} + +fn fixtures() -> Vec<(&'static str, Vec)> { + vec![ + ("edges(128)", edges(128)), + ("edges(256)", edges(256)), + ("edges(512)", edges(512)), + ("edges(1024)", edges(1024)), + ("chain(230)", chain(230)), + ("mixed(256)", mixed(256, 7)), + ] +} + +/// walk_mask's three plane sets (`walk`, `delta`, `new`). +fn baseline_plane_bytes(e: &[Edge]) -> usize { + 3 * mask_walk(width(e), e).plane_bytes() +} + +/// The largest node id plus one: walk_mask's plane width. +fn width(e: &[Edge]) -> usize { + e.iter() + .flat_map(|&(a, b, _)| [a, b]) + .max() + .map_or(0, |m| m as usize + 1) +} + +/// A `kB` field of `/proc/self/status`. +fn status_kib(field: &str) -> usize { + std::fs::read_to_string("/proc/self/status") + .expect("Linux /proc") + .lines() + .find_map(|l| l.strip_prefix(field)) + .and_then(|v| v.trim().trim_end_matches("kB").trim().parse().ok()) + .expect("field present") +} + +/// Child mode: build the fixture, reset the resident high-water mark, run one +/// engine end to end (tuples decoded), print the growth of the high-water mark +/// over the resident set before the run, in KiB. +fn child(engine: &str, fixture: &str) { + let (_, e) = fixtures() + .into_iter() + .find(|(n, _)| *n == fixture) + .expect("known fixture"); + // Writing 5 to clear_refs resets VmHWM to the current RSS (Linux >= 4.0). + std::fs::write("/proc/self/clear_refs", "5").expect("clear_refs"); + let before = status_kib("VmRSS:"); + match engine { + "mask" => { + let p = mask_walk(width(&e), &e); + black_box((p.counts(), p.tuples())); + } + "mask-count" => { + black_box(mask_walk(width(&e), &e).counts()); + } + "tile" => { + black_box(tile_eval(&e, usize::MAX).expect("tile path answers")); + } + "tile-count" => { + let b = bind(&e, attest(&e), 1, None, usize::MAX).expect("binds"); + let p = project(&b, &e); + let r = recurse(&b, &p, &b.witness, &mut Kernel::new()).expect("answers"); + black_box(counts(&b, &r)); + } + _ => panic!("unknown engine {engine}"), + } + println!("{}", status_kib("VmHWM:").saturating_sub(before)); +} + +/// Resident high-water growth of one engine on one fixture, in a fresh +/// process (jemalloc keeps freed pages, so in-process numbers would depend on +/// what ran before). +fn resident_kib(engine: &str, fixture: &str) -> usize { + let out = std::process::Command::new(std::env::current_exe().expect("exe")) + .args(["--resident", engine, fixture]) + .output() + .expect("child runs"); + assert!(out.status.success(), "child failed: {out:?}"); + String::from_utf8(out.stdout) + .expect("utf8") + .trim() + .parse() + .expect("a number") +} + +fn main() { + let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("--resident") { + child(&args[2], &args[3]); + return; + } + println!( + "# correctness and end to end (ms, median of {REPS}; resident = VmHWM growth in a fresh process)\n" + ); + println!( + "| fixture | nodes | pages | mask count-only | tile count-only | tile / mask | mask + decode | tile + decode | tile / mask | Walk | NoWalk | oracle |" + ); + println!("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|"); + let mut memory = Vec::new(); + let mut detail = Vec::new(); + for (name, e) in fixtures() { + let n = width(&e); + // Baseline: count-only, then decoding of the same planes, timed apart. + let mut tc = Vec::new(); + let mut td = Vec::new(); + let mut mask = None; + let mut row_reads = 0; + for _ in 0..REPS { + let s = Instant::now(); + let p = mask_walk(n, &e); + black_box(p.counts()); + tc.push(s.elapsed()); + row_reads = p.row_reads; + let s = Instant::now(); + let t = black_box(p.tuples()); + td.push(s.elapsed()); + mask = Some(t); + } + let mask = mask.expect("REPS > 0"); + // Tile path, every phase. + let reps: Vec = (0..REPS) + .map(|_| tile_eval(&e, usize::MAX).expect("tile path answers")) + .collect(); + let med = |f: fn(&TileReport) -> Duration| median(reps.iter().map(f).collect()); + let phases = [ + med(|r| r.attest), + med(|r| r.validate), + med(|r| r.bind), + med(|r| r.project), + med(|r| r.recurse), + med(|r| r.count), + med(|r| r.decode), + ]; + let tile_e2e = med(|r| r.e2e()); + let tile_count = med(|r| r.e2e_count()); + let last = reps.into_iter().last().expect("REPS > 0"); + let nodes = last + .tuples + .walk + .iter() + .filter(|w| w.0 == w.1 && w.2 == 0) + .count(); + let oracle = if nodes <= ORACLE_MAX_NODES { + let a = ascent_tuples(&e); + let c = crepe_tuples(&e); + agree(&last.tuples, &a).unwrap_or_else(|d| panic!("{name}: tile vs ascent {d}")); + agree(&last.tuples, &c).unwrap_or_else(|d| panic!("{name}: tile vs crepe {d}")); + agree(&mask, &a).unwrap_or_else(|d| panic!("{name}: mask vs ascent {d}")); + "tile = mask = ascent = crepe" + } else { + agree(&last.tuples, &mask).unwrap_or_else(|d| panic!("{name}: tile vs mask {d}")); + "tile = mask; no Datalog oracle (UNCHECKED)" + }; + assert_eq!(last.counts, last.tuples.counts(), "{name}: tile counts"); + let mask_count = median(tc); + let mask_e2e = mask_count + median(td); + println!( + "| {name} | {nodes} | {} | {} | {} | {:.1}× | {} | {} | {:.1}× | {} | {} | {oracle} |", + last.pages, + ms(mask_count), + ms(tile_count), + tile_count.as_secs_f64() / mask_count.as_secs_f64(), + ms(mask_e2e), + ms(tile_e2e), + tile_e2e.as_secs_f64() / mask_e2e.as_secs_f64(), + last.counts.0, + last.counts.1, + ); + memory.push(( + name, + ["mask-count", "tile-count", "mask", "tile"].map(|eng| resident_kib(eng, name)), + baseline_plane_bytes(&e), + last.tile_bytes, + )); + detail.push((name, phases, last, row_reads)); + } + println!("\n# resident memory (MiB): VmHWM growth in a fresh process per cell\n"); + println!( + "| fixture | mask count-only | tile count-only | mask + decode | tile + decode | mask planes (3 x walk) | tile walk tiles |" + ); + println!("|---|---:|---:|---:|---:|---:|---:|"); + for (name, kib, planes, tiles) in &memory { + println!( + "| {name} | {} | {} | {} | {} | {} | {} |", + mib(kib[0] * 1024), + mib(kib[1] * 1024), + mib(kib[2] * 1024), + mib(kib[3] * 1024), + mib(*planes), + mib(*tiles), + ); + } + println!("\n# tile path by phase (ms, median of {REPS})\n"); + println!( + "| fixture | attest | validate | bind | project | recurse | count-only | decode | iterations | tile products | contributing y | cross-page edges | walk tiles MiB |" + ); + println!("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"); + for (name, t, r, _) in &detail { + println!( + "| {name} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", + ms(t[0]), + ms(t[1]), + ms(t[2]), + ms(t[3]), + ms(t[4]), + ms(t[5]), + ms(t[6]), + r.iterations, + r.counters.tile_products, + r.counters.steps, + r.cross_page_edges, + mib(r.tile_bytes), + ); + } + println!("\n# value-derived reads by origin\n"); + println!( + "| fixture | walk_mask exec: delta row read at an adjacency bit | tile prep: codebook lookup per edge endpoint | tile exec | tile decode: codebook decode per output id |" + ); + println!("|---|---:|---:|---:|---:|"); + for (name, _, r, row_reads) in &detail { + println!( + "| {name} | {row_reads} | {} | 0 | {} |", + r.lookups, r.decodes + ); + } +} + +// ---- tests ----------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use baseline::tests::{LENGTHS, random_graph}; + + /// The tile path's tuples equal ascent's, crepe's and walk_mask's. + fn check(e: &[Edge]) -> TileReport { + let r = tile_eval(e, usize::MAX).expect("tile path answers"); + assert_eq!(r.counts, r.tuples.counts(), "count-only vs tuples"); + agree(&r.tuples, &ascent_tuples(e)).unwrap_or_else(|d| panic!("ascent: {d}")); + agree(&r.tuples, &crepe_tuples(e)).unwrap_or_else(|d| panic!("crepe: {d}")); + let n = e + .iter() + .flat_map(|&(a, b, _)| [a, b]) + .max() + .map_or(0, |m| m as usize + 1); + agree(&r.tuples, &mask_walk(n, e).tuples()).unwrap_or_else(|d| panic!("walk_mask: {d}")); + r + } + + #[test] + fn the_tile_product_equals_bitmat_compose() { + use mork_pathmap_probe::tile_gemm::BitMat; + let mut s = 0x7u64; + let mut bit = || { + s = s + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (s >> 33) % 17 == 0 + }; + let (mut a, mut b) = (BitMat::empty(), BitMat::empty()); + let (mut ta, mut tb) = (tile(), tile()); + for i in 0..SLOTS { + for j in 0..SLOTS { + if bit() { + a.set(i, j); + set(&mut ta, tile_addr(i as u8, j as u8)); + } + if bit() { + b.set(i, j); + set(&mut tb, tile_addr(i as u8, j as u8)); + } + } + } + let want = a.compose(&a.transpose(), &b); + let mut c = tile(); + Kernel::new().product_into(&ta, &tb, &mut c, &mut Counters::default()); + for x in 0..SLOTS { + for z in 0..SLOTS { + assert_eq!( + get(&c, tile_addr(x as u8, z as u8)), + want.get(x, z), + "({x}, {z})" + ); + } + } + assert!(want.edges() > 1000, "the product is not trivial"); + } + + #[test] + fn mixed_lengths_with_cycles() { + let e = random_graph(65, 2, 0x5EED_0001); + let r = check(&e); + let lens: BTreeSet = r.tuples.walk.iter().map(|w| w.2).collect(); + assert_eq!(lens.len(), LENS, "every length is reached"); + assert!(!r.tuples.no_walk.is_empty()); + } + + #[test] + fn alternative_path_lengths() { + let r = check(&[(0, 1, 1), (0, 2, 2), (2, 1, 3), (1, 3, 7), (0, 3, 5)]); + for w in [(0, 1, 1), (0, 1, 5), (0, 3, 5), (0, 3, 8), (0, 3, 12)] { + assert!(r.tuples.walk.contains(&w), "{w:?}"); + } + } + + #[test] + fn zero_length_edges_and_long_edges() { + let r = check(&[(0, 1, 0), (1, 0, 0), (1, 2, 3), (2, 3, 50), (3, 4, 51)]); + assert!(r.tuples.walk.contains(&(0, 2, 3))); + assert!(r.tuples.walk.contains(&(2, 3, 50))); + assert!( + r.tuples.no_walk.contains(&(3, 4)), + "a length-51 edge starts no walk" + ); + } + + #[test] + fn ids_that_are_not_nodes_take_no_part() { + let e: Vec = (0..40) + .map(|i| (2 * i, 2 * ((i * 7 + 3) % 40), LENGTHS[i as usize % 5])) + .collect(); + let r = check(&e); + assert_eq!(r.pages, 1, "40 nodes bind into one focus"); + } + + #[test] + fn a_chain_needs_at_least_200_iterations() { + let r = check(&chain(230)); + assert!(r.iterations >= 200, "only {} iterations", r.iterations); + assert_eq!(r.pages, 1); + } + + #[test] + fn a_zero_length_cycle_terminates() { + // A ring of 200 zero-length edges and a chord of length 7: every pair + // is reached at length 0, the chord adds lengths 7, 14, ..., 49. + let mut e: Vec = (0..200).map(|i| (i, (i + 1) % 200, 0)).collect(); + e.push((0, 100, 7)); + let r = check(&e); + assert!(r.iterations >= 150, "only {} iterations", r.iterations); + assert!(r.tuples.walk.contains(&(5, 4, 0))); + assert!(r.tuples.walk.contains(&(150, 30, 49))); + assert!(!r.tuples.walk.contains(&(150, 30, 56))); + } + + #[test] + fn paging_across_foci_keeps_the_global_answer() { + for n in [255usize, 256, 257, 300] { + let mut e = random_graph(n, 1, 0xB0B0 + n as u64); + // Edges across every page boundary, both directions. + e.push((255.min(n as i32 - 1), 0, 2)); + e.push((0, n as i32 - 1, 1)); + let r = check(&e); + assert_eq!(r.pages, n.div_ceil(SLOTS), "n={n}"); + if n > SLOTS { + assert!(r.cross_page_edges > 0, "n={n}: no edge crosses a page"); + } + } + } + + #[test] + fn the_benchmark_generator_across_two_foci() { + let r = check(&edges(260)); + assert_eq!(r.pages, 2); + assert!(r.cross_page_edges > 0); + } + + #[test] + fn a_sparse_graph_across_three_foci() { + // Sparse, so the debug-build oracles stay fast in CI. + let r = check(&random_graph(600, 1, 0x3F0C1)); + assert_eq!(r.pages, 3); + assert!(r.cross_page_edges > 0); + } + + // ---- refusals ---- + + #[test] + fn a_stale_witness_is_refused() { + let e = edges(64); + let facts = attest(&e); + let b = bind(&e, facts, 1, None, usize::MAX).expect("binds"); + let p = project(&b, &e); + let mut k = Kernel::new(); + assert!(recurse(&b, &p, &b.witness, &mut k).is_ok()); + let mut w = b.witness; + w.focus = 2; + assert_eq!( + recurse(&b, &p, &w, &mut k).err(), + Some(Refuse::Rmap(Refusal::FocusChanged)) + ); + let mut changed = e.clone(); + changed.push((0, 1, 9)); + let w = Witness { + facts: attest(&changed), + ..b.witness + }; + assert_eq!( + recurse(&b, &p, &w, &mut k).err(), + Some(Refuse::Rmap(Refusal::FactsChanged)) + ); + let w = Witness { + seal: b.witness.seal ^ 1, + ..b.witness + }; + assert_eq!( + recurse(&b, &p, &w, &mut k).err(), + Some(Refuse::Rmap(Refusal::IndexResealed)) + ); + } + + #[test] + fn too_many_nodes_for_the_foci_is_refused_not_truncated() { + let e = random_graph(300, 1, 3); + assert_eq!( + tile_eval(&e, 1).err(), + Some(Refuse::Rmap(Refusal::IncompleteFocus)) + ); + assert!(tile_eval(&e, 2).is_ok()); + } + + #[test] + fn a_focus_that_misses_reached_nodes_is_refused() { + let e = random_graph(120, 2, 9); + let scope: Vec = (0..100).collect(); + let facts = attest(&e); + let b = bind(&e, facts, 1, Some(&scope), usize::MAX).expect("binds"); + let p = project(&b, &e); + assert!(!p.escaped.is_empty(), "the fixture leaves the scope"); + assert!(p.escaped.iter().all(|&i| i >= 100)); + assert_eq!( + recurse(&b, &p, &b.witness, &mut Kernel::new()).err(), + Some(Refuse::Rmap(Refusal::IncompleteFocus)) + ); + } + + #[test] + fn a_scope_id_in_no_edge_is_not_a_node() { + // The scope covers every endpoint and adds 900, which no edge names. + let e: Vec = vec![(0, 1, 1), (1, 2, 2), (2, 0, 3)]; + let scope = [0, 1, 2, 900]; + let b = bind(&e, attest(&e), 1, Some(&scope), usize::MAX).expect("binds"); + assert_eq!(b.nodes(), 3); + let p = project(&b, &e); + let r = recurse(&b, &p, &b.witness, &mut Kernel::new()).expect("answers"); + let (t, _) = tuples(&b, &r); + agree(&t, &ascent_tuples(&e)).unwrap_or_else(|d| panic!("{d}")); + assert!(!t.walk.iter().any(|w| w.0 == 900 || w.1 == 900)); + } + + #[test] + fn a_wrong_path_length_is_detected() { + let e = random_graph(40, 2, 11); + let r = check(&e); + let mut bad = Tuples { + walk: r.tuples.walk.clone(), + no_walk: r.tuples.no_walk.clone(), + }; + let w = *bad + .walk + .iter() + .find(|w| w.2 > 0 && w.2 < MAX_PATH_LEN && !bad.walk.contains(&(w.0, w.1, w.2 + 1))) + .expect("a tuple whose next length is absent"); + bad.walk.remove(&w); + bad.walk.insert((w.0, w.1, w.2 + 1)); + assert_eq!(bad.counts(), r.tuples.counts(), "counts cannot see it"); + let err = agree(&bad, &ascent_tuples(&e)).expect_err("the oracle sees it"); + assert!(err.starts_with("Walk"), "{err}"); + } + + #[test] + fn a_gather_is_refused_before_execution() { + let gather = Program::new( + vec![MaskOp::Gather { + lane: 0, + foreign: 0, + under: None, + dst: 0, + }], + Terminal::Keep { + mask: Operand::Scratch(0), + }, + ); + assert_eq!( + Kernel::with_program(gather).err(), + Some(Refuse::UnauthorizedGather), + "a gathering program must not be admitted" + ); + assert_eq!(admit(&product_program()), Ok(())); + } +}