From ee2fdeb39cfd2dd59b82020b3cd9ca5ddc9db748 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:31:50 +0000 Subject: [PATCH] r2il-mask-abi-probe: V3 facet and V4 call as two readings of one buffer Round 3. One 64-aligned NodeRow buffer, read two ways with no translation: V3 via node_rows_from_le_bytes + FacetCascade::ref_from_bytes, V4 via ogar_loco::call_in_slab and ogar_r2il::{r2il_mask, project}. G6D2 rails and LaneShape::Pairs are the same carving, so tier k of slot l is call 6l+k at the same address. Owner writes are seen by both readings, reading writes nothing and allocates nothing, and per-slot classids never reach a V4 call. Executing a stored body through loco is not a reading: Program owns a gathered FunctionBody, a 360-byte copy. No classid -> LaneShape resolver is shipped; the caller passes the shape. The probe workspace patches the git lance-graph-contract to the local path, so ogar-loco and the probe see one contract identity. Six disable runs go red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MHMvKHLhM6xwRswKY4s6vY --- .../2026-10-06-v3-v4-dual-reading-round3.md | 54 +++ .claude/board/entries/README.md | 3 +- crates/r2il-mask-abi-probe/Cargo.toml | 10 + .../tests/v3_v4_dual_reading.rs | 369 ++++++++++++++++++ 4 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 .claude/board/entries/2026-10-06-v3-v4-dual-reading-round3.md create mode 100644 crates/r2il-mask-abi-probe/tests/v3_v4_dual_reading.rs diff --git a/.claude/board/entries/2026-10-06-v3-v4-dual-reading-round3.md b/.claude/board/entries/2026-10-06-v3-v4-dual-reading-round3.md new file mode 100644 index 000000000..6c748e94f --- /dev/null +++ b/.claude/board/entries/2026-10-06-v3-v4-dual-reading-round3.md @@ -0,0 +1,54 @@ +# 2026-10-06 — V3 data and V4 IR as two readings of one resident buffer (Round 3) + +## MEASURED + +`crates/r2il-mask-abi-probe/tests/v3_v4_dual_reading.rs`, 8 tests, 6 disable +runs red. + +There is one owner: a 64-aligned `[u8; 2 × 512]`. The intake step is +`FunctionBody::write_into_value_slab`. Two shipped readings run over it with +no translation between them: + +- **V3**: `node_rows_from_le_bytes` → `NodeRow::value` → + `FacetCascade::ref_from_bytes` per 16-byte slot. +- **V4**: `ogar_loco::call_in_slab` under a `LaneShape`, plus + `ogar_r2il::{r2il_mask, project}`. + +`G6D2` (`6 × FacetTier{lo,hi}`) and `LaneShape::Pairs` +(`6 × (function:value)`) are the same carving. For all 180 calls, tier `k` +of slot `l` is call `6l+k`, at the same address (pointer identity). +`Triples`/`Quads` also read the slab in place. + +- An owner write is seen at once by the V3 facet, the V4 call and the R2IL + mask. +- Taking every reading leaves the bytes unchanged. +- The readings allocate 0 heap bytes. The meter can fire: it sees + `materialize_indices`. +- Per-slot `facet_classid` bytes are V3 data. Rewriting all 30 changes no V4 + call. + +## FINDING + +- **Reading is shared; loco execution is not.** `Interpreter` runs a + `Program { functions: Vec }`. The only path from resident + bytes is `read_from_value_slab`, which gathers the 360 payload bytes into a + second representation. That is a heap-owned copy at a different address. + Reading a stored V4 body is zero-copy; executing it through loco today is + not. +- **The selector is not in the bytes.** The same slab under `Pairs` and + `Triples` yields different call streams. No shipped function maps a classid + to a `LaneShape`; every caller passes it in. `LocoConcept::FunctionBody` + (`0x1701`) is one concept for all three shapes. +- **Semantic-only difference:** V4 recovers `len` from the last non-zero + call (`read_from_value_slab`). A V3 `0:0` tier is valid data, but as V4 it + is a NOP and padding. An interior all-zero call cannot be represented. + +## OPEN + +- No mutable reinterpret (`mut_from_bytes`) exists on `FacetCascade`. + Mutation goes through the owner's bytes, which matches "bytes are stored, + integers are projected". +- The classid → reading resolver (classid → `LaneShape`, R2IL vs core + dialect) is not shipped; plan W1 / `D-R2IL-5` is still "Not started". +- The documented `ruff_r2il` defect (`VarnodeFacet` classid lo-u16 = space + rank, W0) is not re-tested here; ruff is not in this checkout. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index c71911000..438d5cf45 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. -222 entries, 2026-08-06 .. 2026-10-06. +223 entries, 2026-08-06 .. 2026-10-06. | date | entry id | finding | file | |---|---|---|---| +| 2026-10-06 | `v3-v4-dual-reading-round3` | | [2026-10-06-v3-v4-dual-reading-round3.md](2026-10-06-v3-v4-dual-reading-round3.md) | | 2026-10-06 | `D-MORTON-0` | | [2026-10-06-morton8x8-checked-address.md](2026-10-06-morton8x8-checked-address.md) | | 2026-10-06 | `dav-resident-reading-round2` | | [2026-10-06-dav-resident-reading-round2.md](2026-10-06-dav-resident-reading-round2.md) | | 2026-10-06 | `dav-carrier-invariance-quack` | | [2026-10-06-dav-carrier-invariance-quack.md](2026-10-06-dav-carrier-invariance-quack.md) | diff --git a/crates/r2il-mask-abi-probe/Cargo.toml b/crates/r2il-mask-abi-probe/Cargo.toml index 793518746..8c0d091e0 100644 --- a/crates/r2il-mask-abi-probe/Cargo.toml +++ b/crates/r2il-mask-abi-probe/Cargo.toml @@ -26,3 +26,13 @@ lance-graph-quack = { path = "../lance-graph-quack" } # here matches `crates/symbiont`'s existing `../../../OGAR/crates/...` deps. ogar-r2il = { path = "../../../OGAR/crates/ogar-r2il" } ogar-loco = { path = "../../../OGAR/crates/ogar-loco" } +# Round 3 (`tests/v3_v4_dual_reading.rs`): the V3 reading — `NodeRow`, +# `node_rows_from_le_bytes`, `FacetCascade::ref_from_bytes` — over the same +# bytes the loco/R2IL reading projects. +lance-graph-contract = { path = "../lance-graph-contract" } + +# `ogar-loco` / `ogar-r2il` take `lance-graph-contract` from git `main`. In +# THIS workspace that is redirected to the local checkout, so the probe sees +# one contract identity — the local one — rather than two. +[patch."https://github.com/AdaWorldAPI/lance-graph"] +lance-graph-contract = { path = "../lance-graph-contract" } diff --git a/crates/r2il-mask-abi-probe/tests/v3_v4_dual_reading.rs b/crates/r2il-mask-abi-probe/tests/v3_v4_dual_reading.rs new file mode 100644 index 000000000..9e6845e09 --- /dev/null +++ b/crates/r2il-mask-abi-probe/tests/v3_v4_dual_reading.rs @@ -0,0 +1,369 @@ +//! **Round 3 — V3 data and V4 IR as two readings of the same resident bytes.** +//! +//! The hypothesis (`r2il-machine-semantic-contract-v1.md` §7.8, doctrine 1): +//! *"V4 = V3 + executable content. Nothing about the bytes moves. A V4 row IS +//! a V3 row; a V3 reader just doesn't know it can run."* +//! +//! The resident object is ONE 64-aligned byte buffer of `NodeRow`s. Two +//! shipped readings are taken over it, with no translation step between them: +//! +//! - **V3**: `node_rows_from_le_bytes` → `NodeRow::value` → each 16-byte slot +//! reinterpreted as a `FacetCascade` (`facet_classid` + 6 × `FacetTier +//! {lo, hi}`), via `ref_from_bytes`. +//! - **V4**: the same `value` slab read as loco calls by `ogar_loco:: +//! call_in_slab` under a `LaneShape`, and selected by `ogar_r2il::r2il_mask` +//! / `project`. +//! +//! `G6D2` (`6 × (u8:u8)`) and `LaneShape::Pairs` (`6 × (function : value)`) +//! are the same carving of a slot's 12 payload bytes, so tier `k` of slot `l` +//! IS call `6l + k`: `lo` is the function byte, `hi` the immediate. +//! +//! What this file pins: +//! +//! | claim | test | +//! |---|---| +//! | same bytes, same addresses | `both_readings_address_the_same_bytes` | +//! | other carvings read the same slab in place | `every_lane_shape_reads_the_slab_in_place` | +//! | a byte written by the owner is visible to both readings at once | `an_owner_write_is_seen_by_both_readings` | +//! | taking either reading writes nothing | `reading_never_mutates_the_bytes` | +//! | readings allocate nothing | `readings_allocate_nothing` | +//! | per-slot classids carry no V4 content | `slot_classids_do_not_reach_the_v4_reading` | +//! | the shape is not in the bytes | `the_reading_is_not_recoverable_from_the_bytes` | +//! | **executing** a stored body through loco is NOT a reading — it gathers a copy | `loco_execution_needs_a_gathered_copy` | +//! +//! No storage tier is minted, no classid is registered, and nothing is +//! serialized after the intake write (`write_into_value_slab`, the one +//! intake-arm scatter). + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use lance_graph_contract::canonical_node::{node_rows_from_le_bytes, NodeRow}; +use lance_graph_contract::facet::FacetCascade; +use ogar_loco::{ + call_in_slab, Call, FnIndex, FunctionBody, LaneShape, Program, CLASSID_BYTES, CONTENT_SLOTS, + SLOT_STRIDE, VALUE_SLAB_LEN, +}; +use ogar_r2il::{project, r2il_mask, R2ILFn, R2IL_BASE}; + +// ── heap meter (the `mask-risc/tests/no_alloc.rs` pattern) ─────────────── + +struct Counting; + +static BYTES: AtomicUsize = AtomicUsize::new(0); + +thread_local! { + static MEASURING: Cell = const { Cell::new(false) }; +} + +// SAFETY: a pure pass-through to `System`; the counter is the only addition. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + if MEASURING.try_with(Cell::get).unwrap_or(false) { + BYTES.fetch_add(layout.size(), Ordering::Relaxed); + } + // SAFETY: same layout, same contract as the caller's. + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + // SAFETY: `ptr` came from `alloc` above with this `layout`. + unsafe { System.dealloc(ptr, layout) } + } +} + +#[global_allocator] +static A: Counting = Counting; + +fn measure(f: impl FnOnce() -> R) -> (R, usize) { + let before = BYTES.load(Ordering::Relaxed); + MEASURING.with(|m| m.set(true)); + let r = f(); + MEASURING.with(|m| m.set(false)); + (r, BYTES.load(Ordering::Relaxed) - before) +} + +// ── the resident object ────────────────────────────────────────────────── + +const ROWS: usize = 2; +const ROW: usize = 512; +/// `NodeRow::value` starts after `key(16) | edges(16)`. +const VALUE_AT: usize = 32; + +/// The ONE owner: 64-aligned bytes, exactly what a `FixedSizeBinary(512)` +/// column hands over. +#[repr(C, align(64))] +struct Resident([u8; ROWS * ROW]); + +fn r2il_op(ordinal: u8) -> FnIndex { + FnIndex(R2IL_BASE + ordinal) +} + +/// The intake: a body mixing R2IL ops (`0x90..`) with loco core ops +/// (`< 0x90`), scattered into row 0's value slab by the one intake-arm +/// write. Per-slot classids are set to a recognisable pattern AFTER the +/// scatter, which `write_into_value_slab` leaves untouched by contract. +fn resident() -> Box { + let mut calls = Vec::new(); + for i in 0..LaneShape::Pairs.calls_per_function() { + let call = if i % 3 == 0 { + Call::new(FnIndex::ADD) + } else { + Call::with_value(r2il_op((i % 82) as u8), (i * 7 % 251) as u8 + 1) + }; + calls.push(call); + } + let body = FunctionBody::from_calls(LaneShape::Pairs, &calls).expect("fits"); + let mut r = Box::new(Resident([0; ROWS * ROW])); + let slab: &mut [u8; VALUE_SLAB_LEN] = (&mut r.0[VALUE_AT..VALUE_AT + VALUE_SLAB_LEN]) + .try_into() + .expect("480"); + body.write_into_value_slab(slab); + for l in 0..CONTENT_SLOTS { + let c = 0xC0DE_0000u32 | l as u32; + slab[l * SLOT_STRIDE..l * SLOT_STRIDE + CLASSID_BYTES].copy_from_slice(&c.to_le_bytes()); + } + r +} + +fn rows(r: &Resident) -> &[NodeRow] { + node_rows_from_le_bytes(&r.0).expect("aligned, whole rows") +} + +fn slot(row: &NodeRow, l: usize) -> &[u8; 16] { + (&row.value[l * SLOT_STRIDE..(l + 1) * SLOT_STRIDE]) + .try_into() + .expect("16") +} + +// ── tests ──────────────────────────────────────────────────────────────── + +/// The V3 facet tier and the V4 call are the same two bytes at the same +/// address, for all 180 calls — and the row itself is the buffer, not a copy. +#[test] +fn both_readings_address_the_same_bytes() { + let r = resident(); + let rows = rows(&r); + assert_eq!(rows.len(), ROWS); + assert_eq!( + rows[0].value.as_ptr(), + r.0[VALUE_AT..].as_ptr(), + "row is a reinterpret" + ); + let slab = &rows[0].value; + let mut r2il_calls = 0; + for l in 0..CONTENT_SLOTS { + let facet = FacetCascade::ref_from_bytes(slot(&rows[0], l)).expect("16-aligned slot"); + assert_eq!( + facet as *const FacetCascade as *const u8, + slot(&rows[0], l).as_ptr(), + "V3 facet is a reinterpret of the slot" + ); + assert_eq!(facet.facet_classid, 0xC0DE_0000 | l as u32); + for k in 0..6 { + let i = l * 6 + k; + let call = call_in_slab(slab, LaneShape::Pairs, i); + let tier = &facet.tiers[k]; + assert_eq!(tier.lo, call.function.0, "call {i}: function byte"); + assert_eq!(tier.hi, call.values[0], "call {i}: immediate byte"); + assert_eq!( + tier as *const _ as usize, + slab.as_ptr() as usize + FunctionBody::call_slab_offset(LaneShape::Pairs, i), + "call {i}: V3 tier and V4 call are not at the same address" + ); + r2il_calls += usize::from(R2ILFn::ordinal(call.function).is_some()); + } + } + // Anti-vacuity: both kinds of call are present, so the mask below selects. + let mask = r2il_mask(slab, LaneShape::Pairs); + assert_eq!(mask.count() as usize, r2il_calls); + assert!(r2il_calls > 0 && r2il_calls < 180); +} + +/// `Triples` and `Quads` read the same slab in place too: every call's bytes +/// are the slab's bytes at `call_slab_offset`. A different carving is a +/// different reading, never a rewrite. +#[test] +fn every_lane_shape_reads_the_slab_in_place() { + let r = resident(); + let slab = &rows(&r)[0].value; + for shape in LaneShape::ALL { + for i in 0..shape.calls_per_function() { + let at = FunctionBody::call_slab_offset(shape, i); + let call = call_in_slab(slab, shape, i); + assert_eq!(call.function.0, slab[at]); + for v in 0..shape.values_per_call() { + assert_eq!(call.values[v], slab[at + 1 + v]); + } + } + } +} + +/// The owner writes one byte. Without rebuilding anything, the V3 facet, the +/// V4 call and the R2IL mask all see it. +#[test] +fn an_owner_write_is_seen_by_both_readings() { + let mut r = resident(); + // Call 1 is an R2IL op (1 % 3 != 0). It sits in slot 0, tier 1. + let i = 1; + let at = VALUE_AT + FunctionBody::call_slab_offset(LaneShape::Pairs, i); + let before = r2il_mask(&rows(&r)[0].value, LaneShape::Pairs); + assert!(before.contains(i as u32)); + + r.0[at] = FnIndex::ADD.0; // the owner's write: R2IL op -> core ADD + + let rows = rows(&r); + let facet = FacetCascade::ref_from_bytes(slot(&rows[0], 0)).expect("aligned"); + assert_eq!( + facet.tiers[1].lo, + FnIndex::ADD.0, + "V3 did not see the write" + ); + assert_eq!( + call_in_slab(&rows[0].value, LaneShape::Pairs, i).function, + FnIndex::ADD, + "V4 did not see the write" + ); + let after = r2il_mask(&rows[0].value, LaneShape::Pairs); + assert!( + !after.contains(i as u32), + "R2IL selection did not see the write" + ); + assert_eq!(after.count() + 1, before.count()); +} + +/// Taking every reading leaves the bytes exactly as they were. The snapshot is +/// the test's oracle, not part of either reading. +#[test] +fn reading_never_mutates_the_bytes() { + let r = resident(); + let snapshot = r.0.to_vec(); + let rows = rows(&r); + let slab = &rows[0].value; + let mut sink = 0u64; + for l in 0..CONTENT_SLOTS { + let f = FacetCascade::ref_from_bytes(slot(&rows[0], l)).expect("aligned"); + sink = sink.wrapping_add(u64::from(f.facet_classid)); + } + for shape in LaneShape::ALL { + for i in 0..shape.calls_per_function() { + sink = sink.wrapping_add(u64::from(call_in_slab(slab, shape, i).function.0)); + } + let m = r2il_mask(slab, shape); + sink = sink.wrapping_add(project(slab, shape, &m).count() as u64); + } + assert_ne!(sink, 0, "anti-vacuity: the readings read something"); + assert_eq!( + r.0.as_slice(), + snapshot.as_slice(), + "a reading wrote to the bytes" + ); +} + +/// Both readings allocate nothing: reinterpret, in-place call reads, an +/// inline `CallMask`, and a lazy `project`. +#[test] +fn readings_allocate_nothing() { + let r = resident(); + let ((), heap) = measure(|| { + let rows = rows(&r); + let slab = &rows[0].value; + let mut n = 0usize; + for l in 0..CONTENT_SLOTS { + let f = FacetCascade::ref_from_bytes(slot(&rows[0], l)).expect("aligned"); + n += usize::from(f.tiers[0].lo != 0); + } + for i in 0..LaneShape::Pairs.calls_per_function() { + n += usize::from(call_in_slab(slab, LaneShape::Pairs, i).function.0 != 0); + } + let m = r2il_mask(slab, LaneShape::Pairs); + n += project(slab, LaneShape::Pairs, &m).count(); + assert!(n > 0); + }); + assert_eq!(heap, 0, "a reading allocated"); + // Can-fire: the meter sees a list being built over the same reading. + let (_, listed) = measure(|| { + let m = r2il_mask(&rows(&r)[0].value, LaneShape::Pairs); + m.materialize_indices() + }); + assert!(listed > 0, "the heap meter cannot fire"); +} + +/// The 30 per-slot classids are V3 data the V3 reading sees, and the V4 +/// reading never consults them: rewriting every one changes no call. +#[test] +fn slot_classids_do_not_reach_the_v4_reading() { + let mut r = resident(); + let calls_before: Vec = (0..180) + .map(|i| call_in_slab(&rows(&r)[0].value, LaneShape::Pairs, i)) + .collect(); + for l in 0..CONTENT_SLOTS { + let at = VALUE_AT + l * SLOT_STRIDE; + r.0[at..at + CLASSID_BYTES].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); + } + let rows = rows(&r); + let calls_after: Vec = (0..180) + .map(|i| call_in_slab(&rows[0].value, LaneShape::Pairs, i)) + .collect(); + assert_eq!( + calls_after, calls_before, + "a slot classid leaked into a V4 call" + ); + let f = FacetCascade::ref_from_bytes(slot(&rows[0], 3)).expect("aligned"); + assert_eq!( + f.facet_classid, 0xFFFF_FFFF, + "V3 must see the classid it owns" + ); +} + +/// The bytes do not say which carving they are: the same slab under two +/// shapes yields two different call streams. Which reading applies has to +/// come from OUTSIDE the payload — the classid's job — and no shipped +/// function maps a classid to a `LaneShape`; every caller passes it in. +#[test] +fn the_reading_is_not_recoverable_from_the_bytes() { + let r = resident(); + let slab = &rows(&r)[0].value; + let pairs: Vec = (0..30) + .map(|i| call_in_slab(slab, LaneShape::Pairs, i).function.0) + .collect(); + let triples: Vec = (0..30) + .map(|i| call_in_slab(slab, LaneShape::Triples, i).function.0) + .collect(); + assert_ne!( + pairs, triples, + "two carvings agreed; the selector would be inert" + ); +} + +/// The copy point. loco's `Interpreter` runs a `Program`, and a `Program` +/// OWNS gathered `FunctionBody` values. The only way from resident bytes to +/// one is `read_from_value_slab`, which copies the 360 payload bytes into a +/// second representation at a different address. So the stored-body V4 +/// reading is a reading; executing it through loco today is not. +#[test] +fn loco_execution_needs_a_gathered_copy() { + let r = resident(); + let slab = &rows(&r)[0].value; + let (program, heap) = measure(|| Program { + functions: vec![FunctionBody::read_from_value_slab(LaneShape::Pairs, slab)], + }); + let gathered = program.entry().as_body_bytes(); + let owner = r.0.as_ptr_range(); + assert!( + !owner.contains(&gathered.as_ptr()), + "the gathered body lives inside the resident buffer — no copy was made" + ); + for l in 0..CONTENT_SLOTS { + assert_eq!( + &gathered[l * 12..(l + 1) * 12], + &slab[l * SLOT_STRIDE + CLASSID_BYTES..(l + 1) * SLOT_STRIDE], + "lane {l}: the gather is a copy of the payload bytes" + ); + } + assert!( + heap >= core::mem::size_of::(), + "the Program owns a heap copy" + ); + assert_eq!(program.entry().len(), 180); +}