From fdded85657ab3627a1bdf2e6daa61be955484d75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 15:18:20 +0000 Subject: [PATCH 01/10] lance-graph-r2il: optional R2IL reading of a DT64 MethodBinding (prepare/run) binding::prepare resolves an admitted DT64 MethodBinding once, cold: frame, focus, scope and route guards, class basis, admit, then per effect the lens (OGAR's R2IL fold reading), the word's label resolved to exactly one opcode by OGAR's vocabulary, the translatable subset, and OGAR's arity against the lens's declared arity and the argument count. binding::run executes the bound opcodes through FoldDialect, hot, rechecking only the guards, the route and an explicit execution permit, and returns a receipt bounded by the plan's step count. This reading is optional: no consumer is required to execute through it. Unsupported shapes (WHEN guards, three-valued truth, immediates, sinks, out-of-range lane constants, unbound fields) are refused by name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- crates/lance-graph-r2il/Cargo.toml | 4 + crates/lance-graph-r2il/src/binding.rs | 520 ++++++++++++ crates/lance-graph-r2il/src/lib.rs | 1 + .../tests/integration/binding.rs | 765 ++++++++++++++++++ .../tests/integration/main.rs | 4 + 5 files changed, 1294 insertions(+) create mode 100644 crates/lance-graph-r2il/src/binding.rs create mode 100644 crates/lance-graph-r2il/tests/integration/binding.rs create mode 100644 crates/lance-graph-r2il/tests/integration/main.rs diff --git a/crates/lance-graph-r2il/Cargo.toml b/crates/lance-graph-r2il/Cargo.toml index 5cc17c151..cf690b849 100644 --- a/crates/lance-graph-r2il/Cargo.toml +++ b/crates/lance-graph-r2il/Cargo.toml @@ -18,3 +18,7 @@ lance-graph-mask-risc = { path = "../lance-graph-mask-risc" } # the contract from git `main`; the root `[patch]` maps that to the member. ogar-loco = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" } ogar-r2il = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" } +# The execution-binding seam (`binding`): DT64 resolves and admits a method, +# this crate executes it. Both are zero-dep beyond the contract. +lance-graph-dt64 = { path = "../lance-graph-dt64" } +lance-graph-contract = { path = "../lance-graph-contract" } diff --git a/crates/lance-graph-r2il/src/binding.rs b/crates/lance-graph-r2il/src/binding.rs new file mode 100644 index 000000000..72a978ca0 --- /dev/null +++ b/crates/lance-graph-r2il/src/binding.rs @@ -0,0 +1,520 @@ +//! An optional R2IL reading of a DT64 [`MethodBinding`] (D-DYNBAG §19.3 R1, +//! corrected by §20): when a consumer asks for it, an admitted binding's +//! ordered [`Effect`]s are bound to R2IL opcodes and run through +//! [`FoldDialect`]. +//! +//! **This is not the path every execution takes.** A consumer executes its +//! own prepared native plan (Quack, mask-risc, `ndarray::simd`) without +//! consulting any codegraph. R2IL is the addressable, inspectable reading +//! behind execution; this module shows that the same binding can also be +//! run through that reading, and nothing more. +//! +//! The lifecycle is Tract's: [`prepare`] resolves once, cold, and binds the +//! opcodes; [`run`] executes the bound plan, hot, rechecking only the frame, +//! focus, scope, route and execution permit. `run` never reads the +//! `ClassView`, OGAR's vocabulary or the store's lens again. DT64 stays +//! non-executing; mask-risc runs every fold; no opcode, interpreter or +//! planner is added. +//! +//! A [`Receipt`] is bounded by the plan's step count. It names the plan, the +//! frame and the answer. It is not a row trace, not the plan's rewrite +//! history, and not the logical provenance of the answer. +//! +//! # How a word becomes a call +//! +//! An [`AlgebraRef`] names a field position under a lens classid. It is +//! **never** read as an [`FnIndex`] because the two happen to be `u8`. +//! [`prepare`] checks each effect before binding it: +//! +//! 1. the lens is the one this seam executes, and its concept half is +//! OGAR's R2IL fold reading ([`ogar_r2il::CONCEPT_R2IL_FOLD`], provisional +//! in OGAR: a lab lens, never a persisted identity); +//! 2. the lens is bound in the store and its basis matches its `ClassView` +//! at the stated schema revision ([`BindStore::resolve_algebra`]); +//! 3. the word's LABEL resolves to exactly one opcode by OGAR's own +//! vocabulary ([`R2ILVocabulary`]), so the position a lens gives a word is +//! free and only the name binds; +//! 4. the opcode is in the subset this seam translates faithfully +//! ([`EXECUTABLE`]); +//! 5. OGAR's stack arity, the arity the lens declared, and the effect's +//! argument count all agree, and OGAR says the opcode pushes one result. +//! +//! # What is checked when +//! +//! [`prepare`]: the frame, focus and scope guards; the route at the captured +//! version and now; the class basis; [`BindStore::admit`]; the binding's +//! WHEN and truth, which this reading cannot honour and so refuses; then +//! every effect as above. +//! +//! [`run`]: the guards and the route again, and an explicit execution permit +//! for the exact method reference. `admit` succeeding is not authorization. +//! +//! Arguments: `Arg::Field(p)` is the value an earlier effect wrote to `p`, +//! else the resident lane the caller bound `p` to ([`FieldLanes`]), whose +//! kind is read from the lane itself. `Arg::Const(c)` is pushed as is. A +//! constant compared against a lane must fit that lane's type; the dialect +//! would otherwise saturate it, so the seam refuses it first. +//! +//! The answer is the value each written field holds after the last effect. +//! A field that still holds a mask slot or an address is an intermediate +//! (a slot names dialect scratch and means nothing after the run); it is +//! listed in [`Executed::intermediates`], never surfaced as a value. + +use std::collections::BTreeMap; + +use lance_graph_contract::class_view::ClassView; +use lance_graph_contract::ogar_codebook::classid_concept; +use lance_graph_contract::set_rules::Truth; +use lance_graph_dt64::dr::{Focus, FrameRevisions, FrameStore, GuardRefusal, Guards, Scope}; +use lance_graph_dt64::{ + AlgebraRef, Arg, BindStore, Effect, MethodRef, Refusal, RouteVersion, SchemaRev, +}; +use lance_graph_mask_risc::{LaneKind, LaneRef}; +use ogar_loco::{Dialect, FnIndex, Vocabulary}; +use ogar_r2il::{R2ILVocabulary, ALL, ANY, CONCEPT_R2IL_FOLD, KEY_RUNS, MAX, MIN, SUM}; + +use crate::{ + Addr, FoldDialect, FoldError, Val, INT_ADD, INT_AND, INT_EQUAL, INT_MULT, INT_NOT, INT_OR, + INT_SUB, INT_S_DIV, INT_S_LESS, INT_XOR, POP_COUNT, +}; + +/// The opcodes this seam translates faithfully: each takes its operands from +/// the stack only (no immediate bytes), pushes exactly one value, and has a +/// `FoldDialect` arm. `NUMBER`, `LOAD` and `RANGE` read immediates an +/// [`Effect`] cannot carry; `GROUP_SUM` and `KEEP` write a sink and push +/// nothing; everything else is unimplemented in the dialect. They are +/// refused by name ([`ExecRefusal::NotExecutable`]). +pub const EXECUTABLE: [FnIndex; 17] = [ + INT_ADD, INT_SUB, INT_MULT, INT_S_DIV, INT_EQUAL, INT_S_LESS, INT_AND, INT_OR, INT_XOR, + INT_NOT, POP_COUNT, SUM, MIN, MAX, ANY, ALL, KEY_RUNS, +]; + +/// The compares whose constant operand the dialect narrows to the lane type. +const LANE_COMPARES: [FnIndex; 2] = [INT_EQUAL, INT_S_LESS]; + +/// Which resident lane each class field is read from: `lanes[p]` is the +/// index into `Planes::lanes` for field position `p`, or `None` when the +/// field has no resident lane. The consumer's field-to-lane binding; nothing +/// is inferred from positions. +#[derive(Debug, Clone, Copy)] +pub struct FieldLanes<'a> { + pub lanes: &'a [Option], +} + +/// The R2IL lens this seam executes, at its schema revision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct R2ilLens { + pub lens: u32, + pub schema_rev: SchemaRev, +} + +/// Explicit, limited permission to execute exact method references. Lab +/// scope: who issues it is not decided. `BindStore::admit` succeeding is +/// never a substitute for it. +#[derive(Debug, Clone, Default)] +pub struct ExecPermit { + pub methods: Vec, +} + +/// What a method was resolved under: the class's schema revision, where it +/// was routed, and the guards of the read. +#[derive(Debug, Clone)] +pub struct Resolution<'a> { + pub method: &'a MethodRef, + pub schema_rev: SchemaRev, + pub route: (RouteVersion, u16), + pub guards: &'a Guards, +} + +/// One bound step of a prepared plan: the opcode the word resolved to, the +/// field it writes and its arguments, in order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Step { + pub opcode: FnIndex, + pub target: u8, + pub args: Vec, +} + +/// A method resolved once, cold: every check that needs the `ClassView`, +/// OGAR's vocabulary or the store's lens is done, and the opcodes are bound. +/// [`run`] executes it without consulting any of them again. +#[derive(Debug, Clone)] +pub struct Prepared { + pub method: MethodRef, + pub lens: R2ilLens, + pub route: (RouteVersion, u16), + pub guards: Guards, + pub steps: Vec, + /// FNV-1a over the method reference, the lens and every step: the plan's + /// identity, carried into each [`Receipt`]. + pub plan_id: u64, +} + +/// A run's answer: the final scalar of every field the plan writes, +/// ascending by position, and the written fields whose final value was a +/// mask or an address (an intermediate; a slot names dialect scratch and +/// means nothing after the run). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Executed { + pub values: Vec<(u8, i64)>, + pub intermediates: Vec, +} + +/// What one run leaves behind: bounded by the plan's step count, never by +/// the row count. It names the plan, the frame it ran under and the answer; +/// it is not a trace of rows and not a proof of how the answer was derived. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Receipt { + pub plan_id: u64, + pub method: MethodRef, + pub frame: FrameRevisions, + pub steps_executed: usize, + /// mask-risc programs the run finalized (one per fold step). + pub programs_run: usize, + pub output: Executed, +} + +/// Why an invocation did not run. Every arm is named; none is an empty +/// answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExecRefusal { + /// The frame, focus or scope the method was resolved under moved. + Guards(GuardRefusal), + /// The route at the captured version, or now, does not name the + /// method's class. + RouteChanged { + position: u16, + captured: Option, + now: Option, + class: u32, + }, + /// The store refused the class, the lens or the method reference. + Binding(Refusal), + /// The reference is admitted but not permitted to execute. + NotPermitted, + /// The binding has a WHEN guard; this seam does not evaluate guards. + GuardNotExecutable, + /// The binding is three-valued; the dialect has no UNKNOWN rows. + ThreeValuedNotExecutable, + /// An effect names a lens other than the R2IL lens. + WrongLens { effect: usize, lens: u32 }, + /// The R2IL lens's concept half is not OGAR's R2IL fold reading. + NotR2ilReading { lens: u32 }, + /// The word's position is outside the lens's basis. + WordOutOfBasis { effect: usize, word: AlgebraRef }, + /// The word's label is not an R2IL opcode name, or names more than one. + UnknownWord { effect: usize, label: String }, + /// The opcode is not in [`EXECUTABLE`]. + NotExecutable { effect: usize, opcode: FnIndex }, + /// OGAR's stack arity, the lens's declared arity and the effect's + /// argument count disagree. + Arity { + effect: usize, + ogar: Option, + declared: Option, + args: usize, + }, + /// OGAR does not say the opcode pushes exactly one result. + NoResult { effect: usize, opcode: FnIndex }, + /// A field argument has neither an earlier value nor a resident lane. + FieldUnbound { effect: usize, field: u8 }, + /// The field's lane index is outside the planes, or its kind is not one + /// the dialect addresses. + LaneUnusable { effect: usize, field: u8, lane: u16 }, + /// A constant compared against a lane does not fit the lane's type. + ConstOutOfRange { effect: usize, value: i64 }, + /// The dialect refused the call. + Fold { effect: usize, error: FoldError }, + /// The call left other than one value on the stack. + ResultArity { effect: usize, left: usize }, +} + +/// Resolve `r`'s method into a [`Prepared`] plan, cold. See the module docs +/// for every check. Execution permission is not checked here: preparing a +/// plan runs nothing. +/// +/// # Errors +/// +/// An [`ExecRefusal`] naming the first check that failed. +pub fn prepare( + store: &BindStore, + view: &V, + lens: R2ilLens, + r: &Resolution<'_>, + frames: &FrameStore, + now: (Focus, Scope), +) -> Result { + r.guards.check(frames, now).map_err(ExecRefusal::Guards)?; + check_route(store, r.method.class, r.route)?; + store + .resolve(view, r.method.class, r.schema_rev) + .map_err(ExecRefusal::Binding)?; + let binding = store.admit(r.method).map_err(ExecRefusal::Binding)?; + if binding.when.is_some() { + return Err(ExecRefusal::GuardNotExecutable); + } + if binding.truth == Truth::ThreeValued { + return Err(ExecRefusal::ThreeValuedNotExecutable); + } + if classid_concept(lens.lens) != CONCEPT_R2IL_FOLD { + return Err(ExecRefusal::NotR2ilReading { lens: lens.lens }); + } + let steps = binding + .effects + .iter() + .enumerate() + .map(|(i, e)| { + Ok(Step { + opcode: opcode(store, view, lens, i, e)?, + target: e.target, + args: e.args.clone(), + }) + }) + .collect::, ExecRefusal>>()?; + let plan_id = plan_id(r.method, lens, &steps); + Ok(Prepared { + method: r.method.clone(), + lens, + route: r.route, + guards: r.guards.clone(), + steps, + plan_id, + }) +} + +/// Run a prepared plan, hot: the guards and the route are checked again +/// (each a comparison or one route lookup), the permit is checked, and the +/// bound opcodes run through `dialect`. Nothing is re-resolved. +/// +/// # Errors +/// +/// An [`ExecRefusal`]; nothing has executed when a check before the first +/// step fails. +pub fn run( + plan: &Prepared, + store: &BindStore, + frames: &FrameStore, + now: (Focus, Scope), + permit: &ExecPermit, + fields: FieldLanes<'_>, + dialect: &mut FoldDialect<'_>, +) -> Result { + plan.guards + .check(frames, now) + .map_err(ExecRefusal::Guards)?; + check_route(store, plan.method.class, plan.route)?; + if !permit.methods.contains(&plan.method) { + return Err(ExecRefusal::NotPermitted); + } + let programs_before = dialect.programs_run(); + let mut values: BTreeMap = BTreeMap::new(); + let mut stack: Vec = Vec::new(); + for (i, step) in plan.steps.iter().enumerate() { + for &a in &step.args { + let v = match a { + Arg::Field(p) => match values.get(&p) { + Some(&v) => v, + None => lane(dialect, fields, i, p)?, + }, + Arg::Const(c) => Val::Scalar(c), + }; + stack.push(v); + } + if LANE_COMPARES.contains(&step.opcode) { + check_compare(&stack, i)?; + } + dialect + .call(step.opcode, [0, 0, 0], &mut stack) + .map_err(|error| ExecRefusal::Fold { effect: i, error })?; + if stack.len() != 1 { + return Err(ExecRefusal::ResultArity { + effect: i, + left: stack.len(), + }); + } + let out = stack.pop().expect("length checked"); + values.insert(step.target, out); + } + let mut output = Executed { + values: Vec::new(), + intermediates: Vec::new(), + }; + for (field, v) in values { + match v { + Val::Scalar(x) => output.values.push((field, x)), + _ => output.intermediates.push(field), + } + } + Ok(Receipt { + plan_id: plan.plan_id, + method: plan.method.clone(), + frame: frames.revisions(), + steps_executed: plan.steps.len(), + programs_run: dialect.programs_run() - programs_before, + output, + }) +} + +/// The route at the captured version, and now, must both name `class`. +fn check_route( + store: &BindStore, + class: u32, + route: (RouteVersion, u16), +) -> Result<(), ExecRefusal> { + let (version, position) = route; + let captured = store + .routed_at(version, position) + .map_err(ExecRefusal::Binding)?; + let now = store + .routed_at(store.current_version(), position) + .map_err(ExecRefusal::Binding)?; + if captured == Some(class) && now == Some(class) { + Ok(()) + } else { + Err(ExecRefusal::RouteChanged { + position, + captured, + now, + class, + }) + } +} + +/// FNV-1a over the method reference's identity fields, the lens and the +/// steps. Prepared plans refuse guarded bindings, so the guard is absent. +fn plan_id(method: &MethodRef, lens: R2ilLens, steps: &[Step]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut eat = |bytes: &[u8]| { + for &b in bytes { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + }; + eat(&method.class.to_le_bytes()); + eat(&method.method.0.to_le_bytes()); + eat(&method.sig_rev.0.to_le_bytes()); + eat(&lens.lens.to_le_bytes()); + eat(&lens.schema_rev.0.to_le_bytes()); + for s in steps { + eat(&[s.opcode.0, s.target]); + eat(&(s.args.len() as u64).to_le_bytes()); + for a in &s.args { + match a { + Arg::Field(p) => eat(&[0, *p]), + Arg::Const(c) => { + eat(&[1]); + eat(&c.to_le_bytes()); + } + } + } + } + h +} + +/// The opcode an effect's word names, after every check in the module docs. +fn opcode( + store: &BindStore, + view: &V, + lens: R2ilLens, + effect: usize, + e: &Effect, +) -> Result { + if e.algebra.lens != lens.lens { + return Err(ExecRefusal::WrongLens { + effect, + lens: e.algebra.lens, + }); + } + let read = store + .resolve_algebra(view, lens.lens, lens.schema_rev) + .map_err(ExecRefusal::Binding)?; + let label = read + .fields + .get(usize::from(e.algebra.word)) + .ok_or(ExecRefusal::WordOutOfBasis { + effect, + word: e.algebra, + })? + .label + .as_str(); + let mut named = (0..=u8::MAX) + .map(FnIndex) + .filter(|&f| R2ILVocabulary.name(f) == Some(label)); + let f = match (named.next(), named.next()) { + (Some(f), None) => f, + _ => { + return Err(ExecRefusal::UnknownWord { + effect, + label: label.to_string(), + }) + } + }; + if !EXECUTABLE.contains(&f) { + return Err(ExecRefusal::NotExecutable { effect, opcode: f }); + } + let ogar = R2ILVocabulary.stack_arity(f); + let declared = store.arity(e.algebra); + if ogar != declared || ogar.map(usize::from) != Some(e.args.len()) { + return Err(ExecRefusal::Arity { + effect, + ogar, + declared, + args: e.args.len(), + }); + } + if R2ILVocabulary.pushes_result(f) != Some(true) { + return Err(ExecRefusal::NoResult { effect, opcode: f }); + } + Ok(f) +} + +/// The address of the resident lane field `p` is bound to. +fn lane( + dialect: &FoldDialect<'_>, + fields: FieldLanes<'_>, + effect: usize, + field: u8, +) -> Result { + let idx = fields + .lanes + .get(usize::from(field)) + .copied() + .flatten() + .ok_or(ExecRefusal::FieldUnbound { effect, field })?; + let unusable = ExecRefusal::LaneUnusable { + effect, + field, + lane: idx, + }; + let kind = match dialect.planes.lanes.get(usize::from(idx)) { + Some(LaneRef::I32(_)) => LaneKind::I32, + Some(LaneRef::U32(_)) => LaneKind::U32, + Some(LaneRef::U64(_)) => LaneKind::U64, + _ => return Err(unusable), + }; + Ok(Val::Address(Addr::Lane { idx, kind })) +} + +/// A lane compare's constant must fit the lane's type: the dialect narrows +/// it with a saturating conversion, which would change the predicate. +fn check_compare(stack: &[Val], effect: usize) -> Result<(), ExecRefusal> { + let [.., a, b] = stack else { + return Ok(()); + }; + let (kind, value) = match (*a, *b) { + (Val::Address(Addr::Lane { kind, .. }), Val::Scalar(c)) + | (Val::Scalar(c), Val::Address(Addr::Lane { kind, .. })) => (kind, c), + _ => return Ok(()), + }; + let fits = match kind { + LaneKind::I32 => i32::try_from(value).is_ok(), + LaneKind::U32 => u32::try_from(value).is_ok(), + LaneKind::U64 => u64::try_from(value).is_ok(), + _ => false, + }; + if fits { + Ok(()) + } else { + Err(ExecRefusal::ConstOutOfRange { effect, value }) + } +} diff --git a/crates/lance-graph-r2il/src/lib.rs b/crates/lance-graph-r2il/src/lib.rs index 89560df35..407543f19 100644 --- a/crates/lance-graph-r2il/src/lib.rs +++ b/crates/lance-graph-r2il/src/lib.rs @@ -101,6 +101,7 @@ //! are pinned against `ogar_r2il::R2ILFn::MNEMONICS` by this crate's tests, //! so the arity table stays in `ogar_r2il` alone. +pub mod binding; pub mod procs; use std::cell::Cell; diff --git a/crates/lance-graph-r2il/tests/integration/binding.rs b/crates/lance-graph-r2il/tests/integration/binding.rs new file mode 100644 index 000000000..622a14e8a --- /dev/null +++ b/crates/lance-graph-r2il/tests/integration/binding.rs @@ -0,0 +1,765 @@ +//! The optional R2IL reading of a DT64 `MethodBinding` (`binding`). +//! +//! Every answer is checked against a scalar oracle over the same lane slices. +//! Lab classids: `0x7F0B_0001` (the consumer class) and `0x7F0A_0001` (a +//! second, non-R2IL lens), as in DT64's own tests. The R2IL lens uses the +//! concept half OGAR declares for the fold reading (`0xC401`), which OGAR +//! marks provisional: it is never persisted here. + +use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +use lance_graph_contract::set_algebra::{Logic, SetExpr}; +use lance_graph_contract::set_rules::{Population, Truth}; +use lance_graph_dt64::dr::{ + Focus, FrameRefusal, FrameRevisions, FrameStore, GuardRefusal, Guards, Scope, +}; +use lance_graph_dt64::{ + AlgebraRef, Arg, BindStore, ClassSchema, Effect, Extent, Guard, MethodBinding, MethodId, + MethodRef, Refusal, RouteVersion, SchemaRev, SigRev, ValueDemand, +}; +use lance_graph_mask_risc::{Foreign, LaneRef, Planes}; +use lance_graph_r2il::binding::{ + prepare, run, ExecPermit, ExecRefusal, FieldLanes, Prepared, R2ilLens, Receipt, Resolution, +}; +use lance_graph_r2il::{FoldDialect, FoldError, INT_S_LESS}; +use ogar_loco::{FnIndex, Vocabulary}; +use ogar_r2il::{R2ILVocabulary, CONCEPT_R2IL_FOLD}; + +const CLASS: u32 = 0x7F0B_0001; +const CLASS_VIEW: ClassId = 0x7F0B; +const OTHER_CLASS: u32 = 0x7F0C_0001; +const OTHER_VIEW: ClassId = 0x7F0C; +const THIRD_CLASS: u32 = 0x7F0D_0001; +const THIRD_VIEW: ClassId = 0x7F0D; +const LENS: u32 = (CONCEPT_R2IL_FOLD as u32) << 16; +const OTHER_LENS: u32 = 0x7F0A_0001; +const OTHER_LENS_VIEW: ClassId = 0x7F0A; +const REV: SchemaRev = SchemaRev(1); +const R2IL: R2ilLens = R2ilLens { + lens: LENS, + schema_rev: REV, +}; + +/// The class's fields: two I32 lanes, one U32 lane, then computed fields. +const FIELDS: [&str; 8] = ["a", "b", "k", "m", "s", "t", "u", "free"]; +/// Field → resident lane. `free` (7) has none. +const LANES: [Option; 8] = [Some(0), Some(1), Some(2), None, None, None, None, None]; + +/// The lens's words, deliberately NOT at their opcode numbers: only the +/// label binds a word to an opcode. +const WORDS: [&str; 9] = [ + "SUM", + "IntSLess", + "IntSub", + "IntAdd", + "IntMult", + "PopCount", + "IntEqual", + "GROUP_SUM", + "NotAnOpcode", +]; +const W_SUM: u8 = 0; +const W_LT: u8 = 1; +const W_SUB: u8 = 2; +const W_ADD: u8 = 3; +const W_MUL: u8 = 4; +const W_POP: u8 = 5; +const W_GSUM: u8 = 7; +const W_BAD: u8 = 8; + +const FOCUS: Focus = Focus { revision: 1 }; +const SCOPE: Scope = Scope { id: 1 }; +const NOW: (Focus, Scope) = (FOCUS, SCOPE); +const POSITION: u16 = 3; + +struct Views { + class: Vec, + lens: Vec, +} + +fn refs(labels: &[&str], ns: &str) -> Vec { + labels + .iter() + .map(|l| FieldRef::new(format!("lab:{ns}/{l}"), (*l).to_string())) + .collect() +} + +impl Views { + fn new() -> Self { + Self { + class: refs(&FIELDS, "class"), + lens: refs(&WORDS, "r2il"), + } + } +} + +impl ClassView for Views { + fn fields(&self, class: ClassId) -> &[FieldRef] { + match class { + CLASS_VIEW | OTHER_VIEW | THIRD_VIEW => &self.class, + c if c == CONCEPT_R2IL_FOLD || c == OTHER_LENS_VIEW => &self.lens, + _ => &[], + } + } + fn template(&self, _class: ClassId) -> DisplayTemplate { + DisplayTemplate::Card + } + fn dolce_category_id(&self, _class: ClassId) -> u8 { + 0 + } +} + +/// The lens arities, read from OGAR by label. `NotAnOpcode` has none there, +/// so the lens declares 1 for it. +fn ogar_arities() -> Vec { + WORDS + .iter() + .map(|w| { + (0..=u8::MAX) + .map(FnIndex) + .find(|&f| R2ILVocabulary.name(f) == Some(*w)) + .and_then(|f| R2ILVocabulary.stack_arity(f)) + .unwrap_or(1) + }) + .collect() +} + +fn w(lens: u32, word: u8) -> AlgebraRef { + AlgebraRef { lens, word } +} + +fn step(target: u8, word: u8, args: Vec) -> Effect { + Effect { + target, + algebra: w(LENS, word), + args, + } +} + +fn method(id: u32, reads: &[u8], effects: Vec) -> MethodBinding { + MethodBinding { + id: MethodId(id), + sig_rev: SigRev(1), + name: format!("m{id}"), + on: WideFieldMask::from_positions(reads), + when: None, + reads: WideFieldMask::from_positions(reads), + effects, + population: Population::Distinct, + truth: Truth::TwoValued, + demand: ValueDemand::Values, + } +} + +/// `a < 5` as a mask in `m`, then `SUM(b)` under it into `s`. +fn masked_sum() -> Vec { + vec![ + step(3, W_LT, vec![Arg::Field(0), Arg::Const(5)]), + step(4, W_SUM, vec![Arg::Field(3), Arg::Field(1)]), + ] +} + +const ORDER_A: u32 = 1; // t = s + 1, then s = s * 2 +const ORDER_B: u32 = 2; // s = s * 2, then t = s + 1 +const SUM_A: u32 = 3; // the masked sum over a instead of b +const SUB_RIGHT: u32 = 4; // t = s - 7 +const SUB_LEFT: u32 = 5; // t = 7 - s +const WRONG_KIND: u32 = 6; // a signed compare on the U32 lane +const WIDE_CONST: u32 = 7; // a < 2^40 +const UNBOUND: u32 = 8; // free < 5 +const BAD_WORD: u32 = 9; +const SINK_WORD: u32 = 10; // GROUP_SUM +const GUARDED: u32 = 11; +const THREE_VALUED: u32 = 12; +const COUNT: u32 = 13; // POP_COUNT of the mask + +fn methods() -> Vec { + let with = |id, extra: Vec| { + let mut e = masked_sum(); + e.extend(extra); + method(id, &[0, 1], e) + }; + let mut guarded = method(GUARDED, &[0, 1], masked_sum()); + guarded.when = Some(Guard::new(SetExpr::Sym(1), Logic::TwoValued)); + let mut three = method(THREE_VALUED, &[0, 1], masked_sum()); + three.truth = Truth::ThreeValued; + vec![ + with( + ORDER_A, + vec![ + step(5, W_ADD, vec![Arg::Field(4), Arg::Const(1)]), + step(4, W_MUL, vec![Arg::Field(4), Arg::Const(2)]), + ], + ), + with( + ORDER_B, + vec![ + step(4, W_MUL, vec![Arg::Field(4), Arg::Const(2)]), + step(5, W_ADD, vec![Arg::Field(4), Arg::Const(1)]), + ], + ), + method( + SUM_A, + &[0], + vec![ + step(3, W_LT, vec![Arg::Field(0), Arg::Const(5)]), + step(4, W_SUM, vec![Arg::Field(3), Arg::Field(0)]), + ], + ), + with( + SUB_RIGHT, + vec![step(5, W_SUB, vec![Arg::Field(4), Arg::Const(7)])], + ), + with( + SUB_LEFT, + vec![step(5, W_SUB, vec![Arg::Const(7), Arg::Field(4)])], + ), + method( + WRONG_KIND, + &[2], + vec![step(3, W_LT, vec![Arg::Field(2), Arg::Const(5)])], + ), + method( + WIDE_CONST, + &[0], + vec![step(3, W_LT, vec![Arg::Field(0), Arg::Const(1 << 40)])], + ), + method( + UNBOUND, + &[7], + vec![step(3, W_LT, vec![Arg::Field(7), Arg::Const(5)])], + ), + method(BAD_WORD, &[0], vec![step(3, W_BAD, vec![Arg::Field(0)])]), + method( + SINK_WORD, + &[0, 1, 2], + vec![step( + 3, + W_GSUM, + vec![Arg::Field(0), Arg::Field(2), Arg::Field(1)], + )], + ), + guarded, + three, + method( + COUNT, + &[0], + vec![ + step(3, W_LT, vec![Arg::Field(0), Arg::Const(5)]), + step(4, W_POP, vec![Arg::Field(3)]), + ], + ), + ] +} + +fn schema() -> ClassSchema { + ClassSchema { + schema_rev: REV, + field_count: FIELDS.len(), + extent: Extent::DR64_CUBE, + } +} + +/// The store: the R2IL lens, a second lens, the class and a second class. +fn store(arities: Vec) -> (BindStore, RouteVersion) { + let mut s = BindStore::new(); + s.declare_algebra(LENS, REV, Extent::DR64_CUBE, arities.clone()) + .unwrap(); + s.declare_algebra(OTHER_LENS, REV, Extent::DR64_CUBE, arities) + .unwrap(); + s.register(CLASS, schema(), methods()).unwrap(); + s.register(OTHER_CLASS, schema(), vec![]).unwrap(); + let v = s.route(POSITION, CLASS).unwrap(); + (s, v) +} + +const REVS: FrameRevisions = FrameRevisions { + source: 1, + relation_map: 1, + coordinates: 1, +}; + +fn guards(frames: &FrameStore) -> Guards { + Guards { + frame: frames.seal(), + focus: FOCUS, + scope: SCOPE, + } +} + +fn mref(id: u32) -> MethodRef { + MethodRef { + class: CLASS, + method: MethodId(id), + sig_rev: SigRev(1), + guard: None, + } +} + +/// Lanes of `n` rows: `a = i % 37 - 10`, `b = (7i) % 23`, `k = i % 5`. +struct Data { + a: Vec, + b: Vec, + k: Vec, +} + +impl Data { + fn new(n: usize) -> Self { + Self { + a: (0..n).map(|i| (i % 37) as i32 - 10).collect(), + b: (0..n).map(|i| ((7 * i) % 23) as i32).collect(), + k: (0..n).map(|i| (i % 5) as u32).collect(), + } + } + /// The scalar oracle: `SUM(over)` where `a < t`. + fn masked_sum(&self, over: &[i32], t: i32) -> i64 { + self.a + .iter() + .zip(over) + .filter(|(a, _)| **a < t) + .map(|(_, v)| i64::from(*v)) + .sum() + } + fn count_below(&self, t: i32) -> i64 { + self.a.iter().filter(|a| **a < t).count() as i64 + } +} + +struct Fixture { + views: Views, + store: BindStore, + version: RouteVersion, + frames: FrameStore, +} + +impl Fixture { + fn new() -> Self { + let (store, version) = store(ogar_arities()); + Self { + views: Views::new(), + store, + version, + frames: FrameStore::new(REVS), + } + } + fn prepare(&self, id: u32) -> Result { + let r = mref(id); + let g = guards(&self.frames); + prepare( + &self.store, + &self.views, + R2IL, + &Resolution { + method: &r, + schema_rev: REV, + route: (self.version, POSITION), + guards: &g, + }, + &self.frames, + NOW, + ) + } +} + +fn permit(ids: &[u32]) -> ExecPermit { + ExecPermit { + methods: ids.iter().map(|&i| mref(i)).collect(), + } +} + +fn exec( + f: &Fixture, + plan: &Prepared, + data: &Data, + permit: &ExecPermit, + now: (Focus, Scope), +) -> Result { + let lanes = [ + LaneRef::I32(&data.a), + LaneRef::I32(&data.b), + LaneRef::U32(&data.k), + ]; + let planes = Planes { + n_rows: data.a.len(), + masks: &[], + lanes: &lanes, + }; + let foreign = Foreign::NONE; + let mut d = FoldDialect::new(&planes, &foreign); + run( + plan, + &f.store, + &f.frames, + now, + permit, + FieldLanes { lanes: &LANES }, + &mut d, + ) +} + +fn values(r: &Receipt) -> Vec<(u8, i64)> { + r.output.values.clone() +} + +const N: usize = 1_000; + +#[test] +fn the_binding_determines_what_executes() { + let f = Fixture::new(); + let data = Data::new(N); + let plan = f.prepare(ORDER_A).unwrap(); + let r = exec(&f, &plan, &data, &permit(&[ORDER_A]), NOW).unwrap(); + let s = data.masked_sum(&data.b, 5); + assert!(s > 0, "the fixture must select rows"); + assert_eq!(values(&r), vec![(4, 2 * s), (5, s + 1)]); + assert_eq!(r.output.intermediates, vec![3], "the mask is not an answer"); + assert_eq!(r.plan_id, plan.plan_id); + assert_eq!(r.method, mref(ORDER_A)); + assert_eq!(r.frame, REVS); +} + +#[test] +fn changing_the_recipe_changes_the_answer() { + let f = Fixture::new(); + let data = Data::new(N); + let over_b = data.masked_sum(&data.b, 5); + let over_a = data.masked_sum(&data.a, 5); + assert_ne!(over_a, over_b, "the fixture must tell the recipes apart"); + let a = exec( + &f, + &f.prepare(SUM_A).unwrap(), + &data, + &permit(&[SUM_A]), + NOW, + ) + .unwrap(); + assert_eq!(values(&a), vec![(4, over_a)]); + let count = exec( + &f, + &f.prepare(COUNT).unwrap(), + &data, + &permit(&[COUNT]), + NOW, + ) + .unwrap(); + assert_eq!(values(&count), vec![(4, data.count_below(5))]); + assert_ne!( + f.prepare(SUM_A).unwrap().plan_id, + f.prepare(ORDER_A).unwrap().plan_id + ); +} + +#[test] +fn reordering_effects_changes_the_answer_as_the_oracle_predicts() { + let f = Fixture::new(); + let data = Data::new(N); + let s = data.masked_sum(&data.b, 5); + let a = exec( + &f, + &f.prepare(ORDER_A).unwrap(), + &data, + &permit(&[ORDER_A]), + NOW, + ) + .unwrap(); + let b = exec( + &f, + &f.prepare(ORDER_B).unwrap(), + &data, + &permit(&[ORDER_B]), + NOW, + ) + .unwrap(); + assert_eq!(values(&a), vec![(4, 2 * s), (5, s + 1)]); + assert_eq!(values(&b), vec![(4, 2 * s), (5, 2 * s + 1)]); + let right = exec( + &f, + &f.prepare(SUB_RIGHT).unwrap(), + &data, + &permit(&[SUB_RIGHT]), + NOW, + ) + .unwrap(); + let left = exec( + &f, + &f.prepare(SUB_LEFT).unwrap(), + &data, + &permit(&[SUB_LEFT]), + NOW, + ) + .unwrap(); + assert_eq!(values(&right), vec![(4, s), (5, s - 7)]); + assert_eq!(values(&left), vec![(4, s), (5, 7 - s)]); +} + +#[test] +fn a_word_binds_by_its_label_not_its_number() { + // `SUM` sits at lens position 0, far from its opcode number; the plan + // still binds the opcode OGAR names `SUM`. + let f = Fixture::new(); + let plan = f.prepare(ORDER_A).unwrap(); + assert_eq!(plan.steps[0].opcode, INT_S_LESS); + assert_eq!(R2ILVocabulary.name(plan.steps[1].opcode), Some("SUM")); + assert_ne!(plan.steps[1].opcode.0, W_SUM); +} + +#[test] +fn a_prepared_plan_runs_without_the_class_view() { + let f = Fixture::new(); + let plan = f.prepare(ORDER_A).unwrap(); + // `run` takes no ClassView: the views could be dropped here. + let data = Data::new(N); + assert!(exec(&f, &plan, &data, &permit(&[ORDER_A]), NOW).is_ok()); +} + +#[test] +fn execution_needs_a_permit_beyond_admission() { + let f = Fixture::new(); + let data = Data::new(N); + let plan = f.prepare(ORDER_A).unwrap(); + assert!(f.store.admit(&mref(ORDER_A)).is_ok()); + assert_eq!( + exec(&f, &plan, &data, &ExecPermit::default(), NOW), + Err(ExecRefusal::NotPermitted) + ); + assert_eq!( + exec(&f, &plan, &data, &permit(&[ORDER_B]), NOW), + Err(ExecRefusal::NotPermitted) + ); +} + +#[test] +fn a_moved_frame_focus_or_scope_is_never_reused() { + let mut f = Fixture::new(); + let data = Data::new(N); + let plan = f.prepare(ORDER_A).unwrap(); + let p = permit(&[ORDER_A]); + assert_eq!( + exec(&f, &plan, &data, &p, (Focus { revision: 2 }, SCOPE)), + Err(ExecRefusal::Guards(GuardRefusal::FocusChanged)) + ); + assert_eq!( + exec(&f, &plan, &data, &p, (FOCUS, Scope { id: 2 })), + Err(ExecRefusal::Guards(GuardRefusal::ScopeChanged)) + ); + f.frames.advance(FrameRevisions { + coordinates: 2, + ..REVS + }); + assert_eq!( + exec(&f, &plan, &data, &p, NOW), + Err(ExecRefusal::Guards(GuardRefusal::Frame( + FrameRefusal::CoordinatesChanged + ))) + ); +} + +#[test] +fn a_changed_route_is_never_reused() { + let mut f = Fixture::new(); + let data = Data::new(N); + let plan = f.prepare(ORDER_A).unwrap(); + f.store.route(POSITION, OTHER_CLASS).unwrap(); + assert_eq!( + exec(&f, &plan, &data, &permit(&[ORDER_A]), NOW), + Err(ExecRefusal::RouteChanged { + position: POSITION, + captured: Some(CLASS), + now: Some(OTHER_CLASS), + class: CLASS, + }) + ); +} + +#[test] +fn a_wrong_signature_or_dropped_guard_refuses() { + let f = Fixture::new(); + let g = guards(&f.frames); + let res = |r: &MethodRef| { + prepare( + &f.store, + &f.views, + R2IL, + &Resolution { + method: r, + schema_rev: REV, + route: (f.version, POSITION), + guards: &g, + }, + &f.frames, + NOW, + ) + .map(|_| ()) + }; + let mut wrong_sig = mref(ORDER_A); + wrong_sig.sig_rev = SigRev(9); + assert!(matches!( + res(&wrong_sig), + Err(ExecRefusal::Binding( + Refusal::SignatureRevisionMismatch { .. } + )) + )); + assert!(matches!( + res(&mref(GUARDED)), + Err(ExecRefusal::Binding(Refusal::GuardDropped { .. })) + )); +} + +#[test] +fn what_the_reading_cannot_honour_is_refused_at_prepare() { + let f = Fixture::new(); + // The guard is kept (the reference witnesses it), so admit passes, and + // the reading still refuses: it does not evaluate WHEN. + let guarded = MethodRef::of(CLASS, &methods()[10]); + assert_eq!(guarded.method, MethodId(GUARDED)); + let g = guards(&f.frames); + let res = Resolution { + method: &guarded, + schema_rev: REV, + route: (f.version, POSITION), + guards: &g, + }; + assert!(f.store.admit(&guarded).is_ok()); + assert_eq!( + prepare(&f.store, &f.views, R2IL, &res, &f.frames, NOW).map(|_| ()), + Err(ExecRefusal::GuardNotExecutable) + ); + assert_eq!( + f.prepare(THREE_VALUED).map(|_| ()), + Err(ExecRefusal::ThreeValuedNotExecutable) + ); + assert!(matches!( + f.prepare(BAD_WORD), + Err(ExecRefusal::UnknownWord { effect: 0, .. }) + )); + assert!(matches!( + f.prepare(SINK_WORD), + Err(ExecRefusal::NotExecutable { effect: 0, .. }) + )); +} + +#[test] +fn a_word_under_another_lens_or_reading_refuses() { + let views = Views::new(); + let (mut store, _) = store(ogar_arities()); + let other = method( + 1, + &[0], + vec![Effect { + target: 3, + algebra: w(OTHER_LENS, W_LT), + args: vec![Arg::Field(0), Arg::Const(5)], + }], + ); + let class = THIRD_CLASS; + store + .register(class, schema(), vec![other.clone()]) + .unwrap(); + let v = store.route(9, class).unwrap(); + let frames = FrameStore::new(REVS); + let g = guards(&frames); + let r = MethodRef::of(class, &other); + let res = Resolution { + method: &r, + schema_rev: REV, + route: (v, 9), + guards: &g, + }; + assert_eq!( + prepare(&store, &views, R2IL, &res, &frames, NOW).map(|_| ()), + Err(ExecRefusal::WrongLens { + effect: 0, + lens: OTHER_LENS + }) + ); + let not_r2il = R2ilLens { + lens: OTHER_LENS, + schema_rev: REV, + }; + assert_eq!( + prepare(&store, &views, not_r2il, &res, &frames, NOW).map(|_| ()), + Err(ExecRefusal::NotR2ilReading { lens: OTHER_LENS }) + ); +} + +#[test] +fn an_arity_the_lens_declares_against_ogar_refuses() { + // The lens claims SUM takes one operand; OGAR says two. + let mut arities = ogar_arities(); + arities[usize::from(W_SUM)] = 1; + let views = Views::new(); + let mut store = BindStore::new(); + store + .declare_algebra(LENS, REV, Extent::DR64_CUBE, arities) + .unwrap(); + let m = method(1, &[1], vec![step(4, W_SUM, vec![Arg::Field(1)])]); + store.register(CLASS, schema(), vec![m.clone()]).unwrap(); + let v = store.route(POSITION, CLASS).unwrap(); + let frames = FrameStore::new(REVS); + let g = guards(&frames); + let r = MethodRef::of(CLASS, &m); + let res = Resolution { + method: &r, + schema_rev: REV, + route: (v, POSITION), + guards: &g, + }; + assert_eq!( + prepare(&store, &views, R2IL, &res, &frames, NOW).map(|_| ()), + Err(ExecRefusal::Arity { + effect: 0, + ogar: Some(2), + declared: Some(1), + args: 1 + }) + ); +} + +#[test] +fn operands_that_do_not_translate_faithfully_refuse_at_run() { + let f = Fixture::new(); + let data = Data::new(N); + let go = |id| exec(&f, &f.prepare(id).unwrap(), &data, &permit(&[id]), NOW); + assert!(matches!( + go(WRONG_KIND), + Err(ExecRefusal::Fold { + effect: 0, + error: FoldError::WrongOperandKind(_) + }) + )); + assert_eq!( + go(WIDE_CONST), + Err(ExecRefusal::ConstOutOfRange { + effect: 0, + value: 1 << 40 + }) + ); + assert_eq!( + go(UNBOUND), + Err(ExecRefusal::FieldUnbound { + effect: 0, + field: 7 + }) + ); +} + +#[test] +fn work_per_run_does_not_grow_with_rows() { + let f = Fixture::new(); + let plan = f.prepare(ORDER_A).unwrap(); + let p = permit(&[ORDER_A]); + let small = exec(&f, &plan, &Data::new(200), &p, NOW).unwrap(); + let large = exec(&f, &plan, &Data::new(200_000), &p, NOW).unwrap(); + // One mask-risc program per fold step (SUM), whatever the row count. + assert_eq!(small.programs_run, 1); + assert_eq!(large.programs_run, 1); + assert_eq!(small.steps_executed, large.steps_executed); + let s = Data::new(200_000); + assert_eq!( + values(&large), + vec![ + (4, 2 * s.masked_sum(&s.b, 5)), + (5, s.masked_sum(&s.b, 5) + 1) + ] + ); +} diff --git a/crates/lance-graph-r2il/tests/integration/main.rs b/crates/lance-graph-r2il/tests/integration/main.rs new file mode 100644 index 000000000..aa1741e0d --- /dev/null +++ b/crates/lance-graph-r2il/tests/integration/main.rs @@ -0,0 +1,4 @@ +//! One integration binary for the crate's newer tests (see +//! `.claude/rules/integration-tests.md`). + +pub mod binding; From 6453b5609becb464d8783a80c29ff7ad69a6bf93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 15:19:16 +0000 Subject: [PATCH 02/10] binding: drop the declared-arity comparison registration already enforces BindStore::register refuses an effect whose argument count differs from the lens's declared arity, so comparing OGAR's arity with the declared one could never fire on its own (a disable run left every test green). The check is OGAR's arity against the argument count; the declared arity is still reported in the refusal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- crates/lance-graph-r2il/src/binding.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/lance-graph-r2il/src/binding.rs b/crates/lance-graph-r2il/src/binding.rs index 72a978ca0..bad9587a7 100644 --- a/crates/lance-graph-r2il/src/binding.rs +++ b/crates/lance-graph-r2il/src/binding.rs @@ -36,8 +36,9 @@ //! free and only the name binds; //! 4. the opcode is in the subset this seam translates faithfully //! ([`EXECUTABLE`]); -//! 5. OGAR's stack arity, the arity the lens declared, and the effect's -//! argument count all agree, and OGAR says the opcode pushes one result. +//! 5. OGAR's stack arity equals the effect's argument count (which DT64's +//! registration already ties to the lens's declared arity), and OGAR says +//! the opcode pushes one result. //! //! # What is checked when //! @@ -206,8 +207,9 @@ pub enum ExecRefusal { UnknownWord { effect: usize, label: String }, /// The opcode is not in [`EXECUTABLE`]. NotExecutable { effect: usize, opcode: FnIndex }, - /// OGAR's stack arity, the lens's declared arity and the effect's - /// argument count disagree. + /// OGAR's stack arity differs from the effect's argument count. The + /// lens's declared arity is reported; registration ties it to the + /// argument count. Arity { effect: usize, ogar: Option, @@ -454,7 +456,10 @@ fn opcode( } let ogar = R2ILVocabulary.stack_arity(f); let declared = store.arity(e.algebra); - if ogar != declared || ogar.map(usize::from) != Some(e.args.len()) { + // `BindStore::register` already refuses an effect whose argument count + // differs from the lens's declared arity, so `declared == args` here; + // the independent check is OGAR's arity against both. + if ogar.map(usize::from) != Some(e.args.len()) { return Err(ExecRefusal::Arity { effect, ogar, From a2786e317fce99408a13def5feb0eddab7d7e5f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 15:24:24 +0000 Subject: [PATCH 03/10] board: R1 entry (optional R2IL reading, disable table, open points) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- .../2026-10-31-r1-r2il-binding-reading.md | 44 +++++++++++++++++++ .claude/board/entries/README.md | 3 +- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .claude/board/entries/2026-10-31-r1-r2il-binding-reading.md diff --git a/.claude/board/entries/2026-10-31-r1-r2il-binding-reading.md b/.claude/board/entries/2026-10-31-r1-r2il-binding-reading.md new file mode 100644 index 000000000..3e556d0a3 --- /dev/null +++ b/.claude/board/entries/2026-10-31-r1-r2il-binding-reading.md @@ -0,0 +1,44 @@ +# R1: optional R2IL reading of a DT64 MethodBinding (prepare/run) + +**Date 2026-10-31 · D-id D-DYNBAG §19.3 R1 (scope corrected by §20) · Status DONE (lab-tested, optional reading)** + +`lance_graph_r2il::binding` lets an admitted DT64 `MethodBinding` be run through the existing `FoldDialect`, when a consumer asks for the R2IL reading. It is not the path any consumer must take. Native execution (Quack, mask-risc, `ndarray::simd`) runs its own prepared plan without consulting a codegraph. + +Lifecycle, as in the Tract probe (`tract-harvest-probe/src/plan.rs:116-128,171,204`): +- `prepare` (cold), in this order: + - frame/focus/scope guards; + - route at the captured version and now; + - class basis via `BindStore::resolve`; + - `BindStore::admit`; + - refuses WHEN guards and three-valued truth; + - lens concept = OGAR `CONCEPT_R2IL_FOLD` (provisional; lab only); + - per effect: lens, word label resolved to exactly one opcode by OGAR's `R2ILVocabulary` (the position is free; only the name binds), translatable subset (17 opcodes: stack operands only, one pushed result), and OGAR arity = argument count. +- `run` (hot): guards, route and an explicit `ExecPermit` again, then the bound opcodes through `FoldDialect`. No `ClassView`, vocabulary or lens lookup. +- `Receipt`: plan id (FNV-1a over method ref, lens, steps), frame revisions, steps executed, mask-risc programs run, output. Bounded by step count. It is not a row trace, not rewrite history, not logical provenance. + +MEASURED (`cargo test -p lance-graph-r2il`): 14 new tests, the existing 9 + 22, and `r2il-mask-abi-probe`'s parity suite all green. 11 disable runs, each red: + +| guard | red | +|---|---| +| D1 label binds, not word number | 12 tests | +| D2 OGAR arity = argument count | an_arity_the_lens_declares_against_ogar_refuses | +| D3 run needs a permit | execution_needs_a_permit_beyond_admission | +| D4 run rechecks guards | a_moved_frame_focus_or_scope_is_never_reused | +| D5 run rechecks route | a_changed_route_is_never_reused | +| D6 lane constant fits lane type | operands_that_do_not_translate_faithfully_refuse_at_run | +| D7 WHEN refused | what_the_reading_cannot_honour_is_refused_at_prepare | +| D8 lens is the R2IL reading | a_word_under_another_lens_or_reading_refuses | +| D9 executable subset | what_the_reading_cannot_honour_is_refused_at_prepare | +| D10 effect names the R2IL lens | a_word_under_another_lens_or_reading_refuses | +| D11 three-valued refused | what_the_reading_cannot_honour_is_refused_at_prepare | + +A first disable of "OGAR arity vs declared arity" stayed green: `BindStore::register` already ties argument count to the declared arity, so that comparison could never fire on its own. It was removed; the refusal still reports the declared arity. + +**Found:** `FoldDialect`'s lane compares narrow an `i64` constant with `unwrap_or(i32::MAX)` / `unwrap_or(u32::MAX)` (`lance-graph-r2il/src/lib.rs` `INT_EQUAL`, `INT_S_LESS` arms), which changes the predicate for an out-of-range constant. The seam refuses such a constant first; the dialect's own saturation is unchanged (OPEN, owner: lance-graph-r2il). + +OPEN: +- who issues `ExecPermit`; +- `0xC401` is provisional in OGAR; +- `GROUP_SUM`/`KEEP` (sinks) and immediates (`NUMBER`, `LOAD`, `RANGE`) are refused, not translated. + +**D-DYNBAG-0 does not depend on R1** (§20). Its seam is binding → prepared native (Quack) plan. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index dd2adb759..b66695bb4 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. -523 entries, 2026-08-06 .. 2026-10-31. +524 entries, 2026-08-06 .. 2026-10-31. | date | entry id | finding | file | |---|---|---|---| +| 2026-10-31 | `r1-r2il-binding-reading` | | [2026-10-31-r1-r2il-binding-reading.md](2026-10-31-r1-r2il-binding-reading.md) | | 2026-10-31 | `D-XLOG-NATIVE-6` | | [2026-10-31-d-xlog-native-6-ndarray-simd-execution.md](2026-10-31-d-xlog-native-6-ndarray-simd-execution.md) | | 2026-10-31 | `D-XLOG-NATIVE-5` | | [2026-10-31-d-xlog-native-5-bindstore-epi5.md](2026-10-31-d-xlog-native-5-bindstore-epi5.md) | | 2026-10-31 | `D-SAG-6-2-JOIN-IDENT` | | [2026-10-31-d-sag-6-2-span-join-identity.md](2026-10-31-d-sag-6-2-span-join-identity.md) | From bdd62d01926aed247bc2cb73ada9ac1fe3199450 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 16:40:05 +0000 Subject: [PATCH 04/10] math-polyfill-probe: draft R1 vs prebound polyfill benchmark (not yet compiled) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven arms on SUM { a : a < t }: direct ndarray, ClassId → LazyLock prebound callable, prebound plus the dt64 guard R1 runs, mask-risc prepared, R2IL prepared (FoldDialect once, CompiledProgram kept), FoldDialect per call, and R1 binding::run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- crates/math-polyfill-probe/Cargo.toml | 5 + .../examples/r1_vs_prebound.rs | 544 ++++++++++++++++++ 2 files changed, 549 insertions(+) create mode 100644 crates/math-polyfill-probe/examples/r1_vs_prebound.rs diff --git a/crates/math-polyfill-probe/Cargo.toml b/crates/math-polyfill-probe/Cargo.toml index 334cbb07f..78303ea29 100644 --- a/crates/math-polyfill-probe/Cargo.toml +++ b/crates/math-polyfill-probe/Cargo.toml @@ -25,6 +25,11 @@ ndarray = { git = "https://github.com/AdaWorldAPI/ndarray", branch = "master" } # prebound route is checked by the existing frame-focus-scope validator # instead of a stand-in. dr-math-binding-probe = { path = "../dr-math-binding-probe" } +# r1_vs_prebound only: the R1 binding fixture (DT64 store, dr frames) and the +# R2IL lens arities, on the coordinates lance-graph-r2il already uses. +lance-graph-dt64 = { path = "../lance-graph-dt64" } +lance-graph-contract = { path = "../lance-graph-contract" } +ogar-r2il = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" } [patch."https://github.com/AdaWorldAPI/lance-graph"] lance-graph-contract = { path = "../lance-graph-contract" } diff --git a/crates/math-polyfill-probe/examples/r1_vs_prebound.rs b/crates/math-polyfill-probe/examples/r1_vs_prebound.rs new file mode 100644 index 000000000..7776928e2 --- /dev/null +++ b/crates/math-polyfill-probe/examples/r1_vs_prebound.rs @@ -0,0 +1,544 @@ +//! R1 against the prebound route. Lab probe, reported not gated. Adds nothing +//! to any production crate: everything below lives in this example. +//! +//! Operation, identical in every arm: `SUM { a[r] : a[r] < t }` over one i32 +//! lane, i64 result, t = 5. Every arm is checked against `sum_where_oracle` +//! before timing, at every row count. All preparation happens before the +//! timed loop. +//! +//! Arms: +//! 1 direct nd `lt_i32_to_mask` + `masked_sum_i32` into a reused mask +//! 2 prebound ClassId -> LazyLock -> resolved once -> stored fn pointer +//! 3 prebound+guard arm 2 plus `Guards::check(frames, (focus, scope))` +//! 4 mr prepared hand-built mask-risc program, `execute_compiled` +//! 5 r2il prepared FoldDialect run once, `last_program` kept, `execute_compiled` +//! 6 dialect `SumWhere::eval` (FoldDialect fold) per call +//! 7 R1 run `binding::prepare` once, `binding::run` per call +//! +//! `MPF_REPS`, `MPF_ITERS` as in `classid_route`. ns per call, median. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::LazyLock; +use std::time::Instant; + +use lance_graph_contract::class_view::{ClassId as ViewClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +use lance_graph_contract::set_rules::{Population, Truth}; +use lance_graph_dt64::dr::{Focus, FrameRevisions, FrameStore, Guards, Scope}; +use lance_graph_dt64::{ + AlgebraRef, Arg, BindStore, ClassSchema, Effect, Extent, MethodBinding, MethodId, MethodRef, + SchemaRev, SigRev, ValueDemand, +}; +use lance_graph_mask_risc::exec::{execute_compiled, tile_words_for, Scratch}; +use lance_graph_mask_risc::ir::{ + CompiledProgram, MaskOp, Operand, Pred as MPred, Program, Terminal, +}; +use lance_graph_mask_risc::{words_for, Foreign, LaneRef, Out, Planes, Value}; +use lance_graph_r2il::binding::{ + prepare, run, ExecPermit, FieldLanes, R2ilLens, Receipt, Resolution, +}; +use lance_graph_r2il::FoldDialect; +use math_polyfill_probe::native::{sum_where_oracle, Pred, SumWhere}; +use ndarray::simd as nd; +use ogar_loco::{FnIndex, Vocabulary}; +use ogar_r2il::{R2ILVocabulary, CONCEPT_R2IL_FOLD}; + +// ── counting allocator ───────────────────────────────────────────────────── + +struct Counting; +static ALLOCS: AtomicUsize = AtomicUsize::new(0); + +// SAFETY: forwards every call to `System` unchanged; only counts. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + ALLOCS.fetch_add(1, Ordering::Relaxed); + // SAFETY: same contract as the caller's. + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + // SAFETY: same contract as the caller's. + unsafe { System.dealloc(p, l) } + } +} + +#[global_allocator] +static A: Counting = Counting; + +// ── prebound route (arms 2, 3) ───────────────────────────────────────────── + +type Kernel = fn(&[i32], i32, &mut [u64]) -> i64; + +#[derive(Clone, Copy)] +struct ClassId(usize); +const SUM_LT: ClassId = ClassId(0); + +static INITS: AtomicUsize = AtomicUsize::new(0); + +fn k_sum_lt(x: &[i32], t: i32, m: &mut [u64]) -> i64 { + nd::lt_i32_to_mask(x, t, m); + nd::masked_sum_i32(x, m) +} + +static ROUTES: [LazyLock; 1] = [LazyLock::new(|| { + INITS.fetch_add(1, Ordering::Relaxed); + k_sum_lt as Kernel +})]; + +struct Prebound { + kernel: Kernel, + guards: Guards, +} + +fn bind(c: ClassId, guards: Guards) -> Prebound { + Prebound { + kernel: *ROUTES[c.0], + guards, + } +} + +#[inline(never)] +fn probe_direct(x: &[i32], t: i32, m: &mut [u64]) -> i64 { + nd::lt_i32_to_mask(x, t, m); + nd::masked_sum_i32(x, m) +} + +#[inline(never)] +fn probe_prebound(b: &Prebound, x: &[i32], t: i32, m: &mut [u64]) -> i64 { + (b.kernel)(x, t, m) +} + +#[inline(never)] +fn probe_guarded( + b: &Prebound, + frames: &FrameStore, + now: (Focus, Scope), + x: &[i32], + t: i32, + m: &mut [u64], +) -> Option { + b.guards.check(frames, now).ok()?; + Some((b.kernel)(x, t, m)) +} + +// ── R1 fixture (after lance-graph-r2il/tests/integration/binding.rs) ─────── + +const CLASS: u32 = 0x7F0B_0001; +const CLASS_VIEW: ViewClassId = 0x7F0B; +const LENS: u32 = (CONCEPT_R2IL_FOLD as u32) << 16; +const REV: SchemaRev = SchemaRev(1); +const R2IL: R2ilLens = R2ilLens { + lens: LENS, + schema_rev: REV, +}; +const FIELDS: [&str; 3] = ["a", "m", "s"]; +/// Field `a` (0) is resident lane 0; `m` and `s` are computed. +const LANES: [Option; 3] = [Some(0), None, None]; +/// The lens's words; only the label binds a word to an opcode. +const WORDS: [&str; 2] = ["IntSLess", "SUM"]; +const W_LT: u8 = 0; +const W_SUM: u8 = 1; +const F_A: u8 = 0; +const F_M: u8 = 1; +const F_S: u8 = 2; +const METHOD: u32 = 1; +const POSITION: u16 = 3; + +struct Views { + class: Vec, + lens: Vec, +} + +fn refs(labels: &[&str], ns: &str) -> Vec { + labels + .iter() + .map(|l| FieldRef::new(format!("lab:{ns}/{l}"), (*l).to_string())) + .collect() +} + +impl ClassView for Views { + fn fields(&self, class: ViewClassId) -> &[FieldRef] { + match class { + CLASS_VIEW => &self.class, + c if c == CONCEPT_R2IL_FOLD => &self.lens, + _ => &[], + } + } + fn template(&self, _class: ViewClassId) -> DisplayTemplate { + DisplayTemplate::Card + } + fn dolce_category_id(&self, _class: ViewClassId) -> u8 { + 0 + } +} + +fn ogar_arities() -> Vec { + WORDS + .iter() + .map(|w| { + (0..=u8::MAX) + .map(FnIndex) + .find(|&f| R2ILVocabulary.name(f) == Some(*w)) + .and_then(|f| R2ILVocabulary.stack_arity(f)) + .unwrap_or(1) + }) + .collect() +} + +fn step(target: u8, word: u8, args: Vec) -> Effect { + Effect { + target, + algebra: AlgebraRef { lens: LENS, word }, + args, + } +} + +fn masked_sum_method(t: i32) -> MethodBinding { + let reads = WideFieldMask::from_positions(&[F_A]); + MethodBinding { + id: MethodId(METHOD), + sig_rev: SigRev(1), + name: "sum_where_lt".to_string(), + on: reads, + when: None, + reads, + effects: vec![ + step( + F_M, + W_LT, + vec![Arg::Field(F_A), Arg::Const(i64::from(t))], + ), + step(F_S, W_SUM, vec![Arg::Field(F_M), Arg::Field(F_A)]), + ], + population: Population::Distinct, + truth: Truth::TwoValued, + demand: ValueDemand::Values, + } +} + +// ── measurement ──────────────────────────────────────────────────────────── + +fn env(name: &str, d: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(d) +} + +struct Measured { + ns: f64, + allocs: f64, + /// Every call `measure` made, warm-up included. + calls: usize, +} + +/// Median ns per call, plus heap allocations per call. +fn measure(reps: usize, iters: usize, mut f: impl FnMut()) -> Measured { + for _ in 0..100 { + f(); + } + let before = ALLOCS.load(Ordering::Relaxed); + for _ in 0..100 { + f(); + } + let allocs = (ALLOCS.load(Ordering::Relaxed) - before) as f64 / 100.0; + let mut v: Vec = (0..reps) + .map(|_| { + let t = Instant::now(); + for _ in 0..iters { + f(); + } + t.elapsed().as_nanos() as f64 / iters as f64 + }) + .collect(); + v.sort_by(f64::total_cmp); + Measured { + ns: v[v.len() / 2], + allocs, + calls: 200 + reps * iters, + } +} + +fn sum_of(v: Value) -> i64 { + match v { + Value::SumI64(s) => s, + other => panic!("MaskedSumI32 yields SumI64, got {other:?}"), + } +} + +fn answer(r: &Receipt) -> i64 { + r.output + .values + .iter() + .find(|(f, _)| *f == F_S) + .map(|&(_, v)| v) + .expect("R1 receipt carries the SUM field") +} + +fn main() { + let reps = env("MPF_REPS", 21); + let iters0 = env("MPF_ITERS", 4000); + let t: i32 = 5; + let focus = Focus { revision: 1 }; + let scope = Scope { id: 1 }; + let now = (focus, scope); + let frames = FrameStore::new(FrameRevisions { + source: 1, + relation_map: 1, + coordinates: 1, + }); + + // R1 preparation: cold, once, outside every timer. + let views = Views { + class: refs(&FIELDS, "class"), + lens: refs(&WORDS, "r2il"), + }; + let method = masked_sum_method(t); + let mut bstore = BindStore::new(); + bstore + .declare_algebra(LENS, REV, Extent::DR64_CUBE, ogar_arities()) + .unwrap(); + bstore + .register( + CLASS, + ClassSchema { + schema_rev: REV, + field_count: FIELDS.len(), + extent: Extent::DR64_CUBE, + }, + vec![method.clone()], + ) + .unwrap(); + let route_version = bstore.route(POSITION, CLASS).unwrap(); + let mref = MethodRef::of(CLASS, &method); + let r1_guards = Guards { + frame: frames.seal(), + focus, + scope, + }; + let plan = prepare( + &bstore, + &views, + R2IL, + &Resolution { + method: &mref, + schema_rev: REV, + route: (route_version, POSITION), + guards: &r1_guards, + }, + &frames, + now, + ) + .expect("R1 prepare"); + let permit = ExecPermit { + methods: vec![mref.clone()], + }; + + let prebound2 = bind( + SUM_LT, + Guards { + frame: frames.seal(), + focus, + scope, + }, + ); + let prebound3 = bind( + SUM_LT, + Guards { + frame: frames.seal(), + focus, + scope, + }, + ); + + let arms = [ + "direct nd", + "prebound", + "prebound+guard", + "mr prepared", + "r2il prepared", + "dialect", + "R1 run", + ]; + let mut time_rows = Vec::new(); + let mut alloc_rows = Vec::new(); + let mut prog_rows = Vec::new(); + + for n in [64usize, 512, 4_096, 65_536] { + let iters = (iters0 * 64 / n).max(50); + let x: Vec = (0..n).map(|i| ((i * 13) % 97) as i32 - 48).collect(); + let mut m = vec![0u64; words_for(n)]; + let lanes = [LaneRef::I32(&x)]; + let planes = Planes { + n_rows: n, + masks: &[], + lanes: &lanes, + }; + let want = sum_where_oracle(&x, None, Pred::Lt(t)) as i64; + + // arm 4: hand-built mask-risc program + let cp = CompiledProgram::new(Program::new( + vec![MaskOp::Pred { + pred: MPred::LtI32 { lane: 0, t }, + under: None, + dst: 0, + }], + Terminal::MaskedSumI32 { + mask: Operand::Scratch(0), + lane: 0, + }, + )); + let mut buf = vec![0u64; tile_words_for(n) * 4]; + let run_cp = |cp: &CompiledProgram, buf: &mut [u64]| { + let mut s = Scratch::over_for_program(buf, cp.program(), n).unwrap(); + sum_of( + execute_compiled( + &cp.compiled(), + &planes, + &Foreign::NONE, + &mut s, + Out::None, + 0..n, + ) + .unwrap(), + ) + }; + + // arm 5: FoldDialect run once, program kept + let op = SumWhere { + lane: 0, + present: None, + pred: Pred::Lt(t), + }; + let mut d5 = FoldDialect::new(&planes, &Foreign::NONE); + let mut stack5 = Vec::with_capacity(8); + assert_eq!(op.eval(&mut d5, &mut stack5), Ok(want)); + let r2 = CompiledProgram::new(d5.last_program().expect("one fold ran").clone()); + + // arm 6: the interpreted reading, per call + let mut d6 = FoldDialect::new(&planes, &Foreign::NONE); + let mut stack6 = Vec::with_capacity(8); + + // arm 7: R1 run, dialect lent once + let mut d7 = FoldDialect::new(&planes, &Foreign::NONE); + let fields = FieldLanes { lanes: &LANES }; + + // oracle check, every arm, before timing + assert!(want != 0, "the fixture must select rows"); + assert_eq!(probe_direct(&x, t, &mut m), want, "direct nd, n={n}"); + assert_eq!( + probe_prebound(&prebound2, &x, t, &mut m), + want, + "prebound, n={n}" + ); + assert_eq!( + probe_guarded(&prebound3, &frames, now, &x, t, &mut m), + Some(want), + "prebound+guard, n={n}" + ); + assert_eq!(run_cp(&cp, &mut buf), want, "mr prepared, n={n}"); + assert_eq!(run_cp(&r2, &mut buf), want, "r2il prepared, n={n}"); + assert_eq!(op.eval(&mut d6, &mut stack6), Ok(want), "dialect, n={n}"); + let r1 = run(&plan, &bstore, &frames, now, &permit, fields, &mut d7).expect("R1 run"); + assert_eq!(answer(&r1), want, "R1 run, n={n}"); + + let p5 = d5.programs_run(); + let (p6, p7) = (d6.programs_run(), d7.programs_run()); + let mut res = Vec::with_capacity(arms.len()); + res.push(measure(reps, iters, || { + black_box(probe_direct(black_box(&x), black_box(t), &mut m)); + })); + res.push(measure(reps, iters, || { + black_box(probe_prebound( + black_box(&prebound2), + black_box(&x), + black_box(t), + &mut m, + )); + })); + res.push(measure(reps, iters, || { + black_box(probe_guarded( + black_box(&prebound3), + &frames, + now, + black_box(&x), + black_box(t), + &mut m, + )); + })); + res.push(measure(reps, iters, || { + black_box(run_cp(&cp, &mut buf)); + })); + res.push(measure(reps, iters, || { + black_box(run_cp(&r2, &mut buf)); + })); + res.push(measure(reps, iters, || { + let _ = black_box(op.eval(&mut d6, &mut stack6)); + })); + res.push(measure(reps, iters, || { + if let Ok(r) = run(&plan, &bstore, &frames, now, &permit, fields, &mut d7) { + black_box(answer(&r)); + } + })); + + // arm 5 never touches its dialect after the first fold. + let progs = [ + (d5.programs_run() - p5) as f64 / res[4].calls as f64, + (d6.programs_run() - p6) as f64 / res[5].calls as f64, + (d7.programs_run() - p7) as f64 / res[6].calls as f64, + ]; + + // the timed loops must still produce the oracle's answer + assert_eq!(op.eval(&mut d6, &mut stack6), Ok(want)); + let again = run(&plan, &bstore, &frames, now, &permit, fields, &mut d7).expect("R1 run"); + assert_eq!(answer(&again), want, "R1 run after timing, n={n}"); + + time_rows.push((n, res.iter().map(|r| r.ns).collect::>())); + alloc_rows.push((n, res.iter().map(|r| r.allocs).collect::>())); + prog_rows.push((n, progs)); + } + + let resolutions = INITS.load(Ordering::Relaxed); + assert_eq!(resolutions, 1, "one resolution per ClassId"); + + println!("\nns per call, median of MPF_REPS"); + print!("\n| rows |"); + for a in arms { + print!(" {a} |"); + } + println!(); + println!("|---:|{}", "---:|".repeat(arms.len())); + for (n, v) in &time_rows { + print!("| {n} |"); + for ns in v { + print!(" {ns:.1} |"); + } + println!(); + } + + println!("\nallocations per call"); + print!("\n| rows |"); + for a in arms { + print!(" {a} |"); + } + println!(); + println!("|---:|{}", "---:|".repeat(arms.len())); + for (n, v) in &alloc_rows { + print!("| {n} |"); + for a in v { + print!(" {a:.1} |"); + } + println!(); + } + + println!("\nFoldDialect::programs_run() per call (the dialect each arm owns)"); + println!("\n| rows | r2il prepared | dialect | R1 run |"); + println!("|---:|---:|---:|---:|"); + for (n, p) in &prog_rows { + println!("| {n} | {:.2} | {:.2} | {:.2} |", p[0], p[1], p[2]); + } + + println!("\nresolutions of the prebound ClassId: {resolutions}"); + println!("Reported, not gated."); +} From a40146dc26f52d1d95db19a171a44ccad7f4ea21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:46:42 +0000 Subject: [PATCH 05/10] math-polyfill-probe: r1_vs_prebound compiles (clone the ON mask in setup) Measured on 4 cores, median ns per call, answers asserted equal across arms: 64 rows: direct 29.4, prebound 30.0, +guard 60.9, mask-risc prepared 103.3, r2il prepared 103.2, dialect 205.9, R1 run 398.4. R1 runs FoldDialect once per call and allocates 5 times per call; prepared paths allocate 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- crates/math-polyfill-probe/examples/r1_vs_prebound.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/math-polyfill-probe/examples/r1_vs_prebound.rs b/crates/math-polyfill-probe/examples/r1_vs_prebound.rs index 7776928e2..32ac19793 100644 --- a/crates/math-polyfill-probe/examples/r1_vs_prebound.rs +++ b/crates/math-polyfill-probe/examples/r1_vs_prebound.rs @@ -200,7 +200,7 @@ fn masked_sum_method(t: i32) -> MethodBinding { id: MethodId(METHOD), sig_rev: SigRev(1), name: "sum_where_lt".to_string(), - on: reads, + on: reads.clone(), when: None, reads, effects: vec![ From aa4a0527d69339d062646c0bdf27e3099faf6947 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 18:20:15 +0000 Subject: [PATCH 06/10] r1_vs_prebound: every timed call is checked; record the benchmark conclusion The R1 arm discarded failures with if let Ok; every arm now counts calls that returned the oracle's answer and the run fails unless that count equals the call count. Board entry records the 4-row-size table (65,536 included), the conclusion (prepared R2IL equals prepared mask-risc; R1's extra cost is its interpreter and receipt) and the operator decision on #1892. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- .../2026-10-31-r1-r2il-binding-reading.md | 26 ++++++++++++ .../examples/r1_vs_prebound.rs | 40 ++++++++++++------- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/.claude/board/entries/2026-10-31-r1-r2il-binding-reading.md b/.claude/board/entries/2026-10-31-r1-r2il-binding-reading.md index 3e556d0a3..52bca126c 100644 --- a/.claude/board/entries/2026-10-31-r1-r2il-binding-reading.md +++ b/.claude/board/entries/2026-10-31-r1-r2il-binding-reading.md @@ -42,3 +42,29 @@ OPEN: - `GROUP_SUM`/`KEEP` (sinks) and immediates (`NUMBER`, `LOAD`, `RANGE`) are refused, not translated. **D-DYNBAG-0 does not depend on R1** (§20). Its seam is binding → prepared native (Quack) plan. + +## Benchmark result and decision (2026-10-31) + +`crates/math-polyfill-probe/examples/r1_vs_prebound.rs` times seven arms over `SUM{a : a < t}`, with identical inputs and outputs. Every timed call of every arm is checked against the oracle, and the run fails if the count of correct answers differs from the call count. An error or a wrong value is a failed sample, never a skipped one. This replaces an `if let Ok(r)` in the R1 arm that discarded failures. + +Median ns per call, `MPF_REPS=31`, 4 cores. Three runs: one was disturbed by load and is not used, and the other two agree. + +| rows | direct | prebound | +guard | mask-risc prepared | R2IL prepared | dialect | R1 run | +|---:|---:|---:|---:|---:|---:|---:|---:| +| 64 | 31.5 | 31.8 | 62.3 | 103.3 | 109.8 | 211.0 | 384.1 | +| 512 | 123.4 | 123.9 | 150.6 | 212.3 | 215.3 | 352.1 | 497.3 | +| 4096 | 947.2 | 945.9 | 996.3 | 1043.8 | 1049.4 | 1140.6 | 1320.9 | +| 65536 | 18158.6 | 18078.9 | 18504.9 | 18301.8 | 18338.4 | 18509.1 | 18663.0 | + +Allocations per call: 0 for every prepared arm, 1 for the dialect run, and 5 for R1. At 65,536 rows the row scan dominates, and the arms differ by less than the run-to-run variance. + +**Conclusion.** +- Prepared R2IL and prepared mask-risc have equivalent measured runtime. +- R1's extra ~280 ns at 64 rows and its five allocations per invocation belong to its current implementation, not to R2IL. That implementation interprets every step through `FoldDialect` on each run, keeps per-run `BTreeMap` field values, rebuilds the stack, and allocates the receipt. + +**Decision (operator).** Keep #1892 intact as measured, historical evidence until a clean successor replaces it. Its OGAR-body rationale is superseded and is not a merge requirement. The successor: +- prepares executable mask-risc programs once, through the `FoldDialect` compilation and `CompiledProgram` machinery D-MPF-4 proved; +- keeps multi-fold ordering and every refusal; +- keeps frame, route, focus, scope and permit validation independent. + +`ExecPermit` remains a lab permission, not a production authorization claim. diff --git a/crates/math-polyfill-probe/examples/r1_vs_prebound.rs b/crates/math-polyfill-probe/examples/r1_vs_prebound.rs index 32ac19793..cceb75b79 100644 --- a/crates/math-polyfill-probe/examples/r1_vs_prebound.rs +++ b/crates/math-polyfill-probe/examples/r1_vs_prebound.rs @@ -204,11 +204,7 @@ fn masked_sum_method(t: i32) -> MethodBinding { when: None, reads, effects: vec![ - step( - F_M, - W_LT, - vec![Arg::Field(F_A), Arg::Const(i64::from(t))], - ), + step(F_M, W_LT, vec![Arg::Field(F_A), Arg::Const(i64::from(t))]), step(F_S, W_SUM, vec![Arg::Field(F_M), Arg::Field(F_A)]), ], population: Population::Distinct, @@ -446,19 +442,25 @@ fn main() { let p5 = d5.programs_run(); let (p6, p7) = (d6.programs_run(), d7.programs_run()); let mut res = Vec::with_capacity(arms.len()); + // Every timed call is checked: `ok[i]` counts calls of arm `i` that + // returned the oracle's answer, and must equal the call count. An + // error or a wrong value is a failed sample, never a skipped one. + let mut ok = [0usize; 7]; res.push(measure(reps, iters, || { - black_box(probe_direct(black_box(&x), black_box(t), &mut m)); + let v = black_box(probe_direct(black_box(&x), black_box(t), &mut m)); + ok[0] += usize::from(v == want); })); res.push(measure(reps, iters, || { - black_box(probe_prebound( + let v = black_box(probe_prebound( black_box(&prebound2), black_box(&x), black_box(t), &mut m, )); + ok[1] += usize::from(v == want); })); res.push(measure(reps, iters, || { - black_box(probe_guarded( + let v = black_box(probe_guarded( black_box(&prebound3), &frames, now, @@ -466,21 +468,31 @@ fn main() { black_box(t), &mut m, )); + ok[2] += usize::from(v == Some(want)); })); res.push(measure(reps, iters, || { - black_box(run_cp(&cp, &mut buf)); + let v = black_box(run_cp(&cp, &mut buf)); + ok[3] += usize::from(v == want); })); res.push(measure(reps, iters, || { - black_box(run_cp(&r2, &mut buf)); + let v = black_box(run_cp(&r2, &mut buf)); + ok[4] += usize::from(v == want); })); res.push(measure(reps, iters, || { - let _ = black_box(op.eval(&mut d6, &mut stack6)); + let v = black_box(op.eval(&mut d6, &mut stack6)); + ok[5] += usize::from(v == Ok(want)); })); res.push(measure(reps, iters, || { - if let Ok(r) = run(&plan, &bstore, &frames, now, &permit, fields, &mut d7) { - black_box(answer(&r)); - } + let v = black_box(run(&plan, &bstore, &frames, now, &permit, fields, &mut d7)); + ok[6] += usize::from(matches!(&v, Ok(r) if answer(r) == want)); })); + for (i, (r, &good)) in res.iter().zip(&ok).enumerate() { + assert_eq!( + good, r.calls, + "arm {} returned a wrong or failed result, n={n}", + arms[i] + ); + } // arm 5 never touches its dialect after the first fold. let progs = [ From c452cb36c804eda55090d61f9010c9d27244723c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 18:32:54 +0000 Subject: [PATCH 07/10] lance-graph-r2il: R1 successor, native prepared plans over CompiledProgram prepare_native runs a Prepared plan once through FoldDialect, keeps each fold's program as a mask-risc CompiledProgram in step order, and records the output layout. run_native checks guards, route and permit exactly as run does, then executes the programs with no allocation and returns a fixed-size receipt. Plans whose values compiling once would freeze are refused by name: a fold fed by a fold result, a scalar computed after a fold, a fold-free scalar read from the planes, too many outputs, and changed planes. ExecPermit stays a lab permission. Tests: agreement with run on every runnable fixture, step-ordered multi-fold, each refusal with a can-stay-silent twin, run's early refusals reproduced, and zero allocations per run_native call (the counter is shown to see run allocate). Benchmark gains an 8th arm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- crates/lance-graph-r2il/src/binding.rs | 39 +- crates/lance-graph-r2il/src/binding_native.rs | 411 +++++++++ crates/lance-graph-r2il/src/lib.rs | 1 + .../tests/integration/binding_native.rs | 828 ++++++++++++++++++ .../tests/integration/main.rs | 1 + .../examples/r1_vs_prebound.rs | 44 +- 6 files changed, 1312 insertions(+), 12 deletions(-) create mode 100644 crates/lance-graph-r2il/src/binding_native.rs create mode 100644 crates/lance-graph-r2il/tests/integration/binding_native.rs diff --git a/crates/lance-graph-r2il/src/binding.rs b/crates/lance-graph-r2il/src/binding.rs index bad9587a7..d666ed47f 100644 --- a/crates/lance-graph-r2il/src/binding.rs +++ b/crates/lance-graph-r2il/src/binding.rs @@ -70,7 +70,7 @@ use lance_graph_dt64::dr::{Focus, FrameRevisions, FrameStore, GuardRefusal, Guar use lance_graph_dt64::{ AlgebraRef, Arg, BindStore, Effect, MethodRef, Refusal, RouteVersion, SchemaRev, }; -use lance_graph_mask_risc::{LaneKind, LaneRef}; +use lance_graph_mask_risc::{LaneKind, LaneRef, Planes}; use ogar_loco::{Dialect, FnIndex, Vocabulary}; use ogar_r2il::{R2ILVocabulary, ALL, ANY, CONCEPT_R2IL_FOLD, KEY_RUNS, MAX, MIN, SUM}; @@ -91,7 +91,7 @@ pub const EXECUTABLE: [FnIndex; 17] = [ ]; /// The compares whose constant operand the dialect narrows to the lane type. -const LANE_COMPARES: [FnIndex; 2] = [INT_EQUAL, INT_S_LESS]; +pub(crate) const LANE_COMPARES: [FnIndex; 2] = [INT_EQUAL, INT_S_LESS]; /// Which resident lane each class field is read from: `lanes[p]` is the /// index into `Planes::lanes` for field position `p`, or `None` when the @@ -109,9 +109,9 @@ pub struct R2ilLens { pub schema_rev: SchemaRev, } -/// Explicit, limited permission to execute exact method references. Lab -/// scope: who issues it is not decided. `BindStore::admit` succeeding is -/// never a substitute for it. +/// Explicit, limited permission to execute exact method references. This is +/// a lab permission, not a production authorization: who issues it is not +/// decided. `BindStore::admit` succeeding is never a substitute for it. #[derive(Debug, Clone, Default)] pub struct ExecPermit { pub methods: Vec, @@ -229,6 +229,23 @@ pub enum ExecRefusal { Fold { effect: usize, error: FoldError }, /// The call left other than one value on the stack. ResultArity { effect: usize, left: usize }, + /// A fold step consumes a value computed from an earlier fold's result; + /// a native plan would freeze that value into the compiled ops. + FoldResultFeedsFold { effect: usize }, + /// A step that is not a fold computes a scalar from an earlier fold's + /// result; a native plan would have to re-evaluate it on every run. + ScalarAfterFold { effect: usize }, + /// A scalar output read from the planes without a fold. A native plan + /// would store it as a fixed value and miss a later change in the lanes. + DataScalarNotPrepared { field: u8 }, + /// The plan writes more scalar fields than a native receipt carries. + TooManyOutputs { outputs: usize }, + /// The planes' shape differs from the one the native plan was prepared + /// over: `(n_rows, lanes, masks)`. + PlanesChanged { + recorded: (usize, usize, usize), + now: (usize, usize, usize), + }, } /// Resolve `r`'s method into a [`Prepared`] plan, cold. See the module docs @@ -316,7 +333,7 @@ pub fn run( let v = match a { Arg::Field(p) => match values.get(&p) { Some(&v) => v, - None => lane(dialect, fields, i, p)?, + None => lane(dialect.planes, fields, i, p)?, }, Arg::Const(c) => Val::Scalar(c), }; @@ -358,7 +375,7 @@ pub fn run( } /// The route at the captured version, and now, must both name `class`. -fn check_route( +pub(crate) fn check_route( store: &BindStore, class: u32, route: (RouteVersion, u16), @@ -474,8 +491,8 @@ fn opcode( } /// The address of the resident lane field `p` is bound to. -fn lane( - dialect: &FoldDialect<'_>, +pub(crate) fn lane( + planes: &Planes<'_>, fields: FieldLanes<'_>, effect: usize, field: u8, @@ -491,7 +508,7 @@ fn lane( field, lane: idx, }; - let kind = match dialect.planes.lanes.get(usize::from(idx)) { + let kind = match planes.lanes.get(usize::from(idx)) { Some(LaneRef::I32(_)) => LaneKind::I32, Some(LaneRef::U32(_)) => LaneKind::U32, Some(LaneRef::U64(_)) => LaneKind::U64, @@ -502,7 +519,7 @@ fn lane( /// A lane compare's constant must fit the lane's type: the dialect narrows /// it with a saturating conversion, which would change the predicate. -fn check_compare(stack: &[Val], effect: usize) -> Result<(), ExecRefusal> { +pub(crate) fn check_compare(stack: &[Val], effect: usize) -> Result<(), ExecRefusal> { let [.., a, b] = stack else { return Ok(()); }; diff --git a/crates/lance-graph-r2il/src/binding_native.rs b/crates/lance-graph-r2il/src/binding_native.rs new file mode 100644 index 000000000..1e414575c --- /dev/null +++ b/crates/lance-graph-r2il/src/binding_native.rs @@ -0,0 +1,411 @@ +//! The prepared native form of an R1 plan ([`crate::binding`]): the plan's +//! folds are run through [`FoldDialect`] once, cold, and the mask-risc +//! programs it finalizes are kept as [`CompiledProgram`]s. A hot +//! [`run_native`] executes those programs and nothing else. +//! +//! [`crate::binding::run`] re-interprets every step on every call. This +//! module is its successor for plans whose folds do not depend on each other: +//! [`prepare_native`] refuses, by name, every plan whose answer would need a +//! step re-evaluated per run. +//! +//! # What a native plan accepts +//! +//! Each field the plan writes has an origin: +//! +//! - `Const`: derived only from `Arg::Const`; +//! - `Data`: a lane, or a value derived from lanes, with no fold result in it; +//! - `Fold(k)`: the result of the k-th finalized program, or a value computed +//! from one. +//! +//! A fold fixes the data it reads when its program is built, so a value that +//! depends on a fold result cannot be frozen. A mask computed from a fold +//! result is allowed to exist (it is only a field id in the plan's +//! intermediates), but: +//! +//! - a fold that consumes a `Fold`-origin value is refused +//! ([`ExecRefusal::FoldResultFeedsFold`]); +//! - a step that is not a fold and computes a scalar from a `Fold`-origin +//! value is refused ([`ExecRefusal::ScalarAfterFold`]); +//! - a plan that writes more scalar fields than [`MAX_NATIVE_OUTPUTS`] is +//! refused ([`ExecRefusal::TooManyOutputs`]). +//! +//! # What is checked when +//! +//! [`prepare_native`]: the guards and the route, then every step through +//! [`FoldDialect`] with the argument resolution, compare-range check and +//! result-arity check of [`crate::binding::run`]. +//! +//! [`run_native`]: the guards, the route and the execution permit, in the +//! order [`crate::binding::run`] checks them, then the planes' shape. The +//! permit is a lab permission, not a production authorization. `run_native` +//! allocates nothing. + +use std::collections::BTreeMap; + +use lance_graph_dt64::dr::{Focus, FrameRevisions, FrameStore, Scope}; +use lance_graph_dt64::{Arg, BindStore, MethodRef, RouteVersion}; +use lance_graph_mask_risc::{ + execute_compiled, scratch_words_for, tile_words_for, CompiledProgram, Foreign, Out, Planes, + Scratch, Terminal, Value, +}; +use ogar_loco::{Dialect, FnIndex}; +use ogar_r2il::{ALL, ANY, KEY_RUNS, MAX, MIN, SUM}; + +use crate::binding::{ + check_compare, check_route, lane, ExecPermit, ExecRefusal, FieldLanes, Prepared, LANE_COMPARES, +}; +use crate::{FoldDialect, FoldError, Val, POP_COUNT}; + +/// The most scalar fields a [`NativeReceipt`] carries. +pub const MAX_NATIVE_OUTPUTS: usize = 8; + +/// The opcodes that finalize a mask-risc program and push its scalar result. +const FOLD_OPCODES: [FnIndex; 7] = [POP_COUNT, SUM, MIN, MAX, ANY, ALL, KEY_RUNS]; + +/// Where a value comes from; see the module docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Origin { + Const, + Data, + Fold(usize), +} + +/// One finalized fold program, kept for the hot path. +#[derive(Debug, Clone)] +struct ProgramPlan { + cp: CompiledProgram, + /// The index of the step that produced the program. + effect: usize, + /// The fold opcode, for reporting an unexpected result shape. + opcode: FnIndex, + /// Scratch slots the program touches. + slots: usize, + /// The scalar an empty `MIN`/`MAX` yields, as the dialect reports it. + empty: i64, + /// The index into the receipt's values that holds this program's result, + /// or `None` when a later step overwrote the field. + out: Option, +} + +/// The planes' shape a native plan was prepared over: +/// `(n_rows, lanes, masks)`. +type Shape = (usize, usize, usize); + +fn shape_of(planes: &Planes<'_>) -> Shape { + (planes.n_rows, planes.lanes.len(), planes.masks.len()) +} + +/// A prepared plan whose folds are compiled mask-risc programs. Built by +/// [`prepare_native`], executed by [`run_native`]. +#[derive(Debug, Clone)] +pub struct NativePlan { + prepared: Prepared, + programs: Vec, + shape: Shape, + /// The receipt's values before any program runs: the fixed scalars are + /// filled in, the fold slots are zero until their program has run. + template: [(u8, i64); MAX_NATIVE_OUTPUTS], + outputs: usize, + intermediates: Vec, + tile_words: usize, + scratch_words: usize, +} + +impl NativePlan { + /// The plan's identity, the one [`Prepared::plan_id`] carries. + pub fn plan_id(&self) -> u64 { + self.prepared.plan_id + } + + /// The method reference the plan was prepared for. + pub fn method(&self) -> &MethodRef { + &self.prepared.method + } + + /// The route the plan was prepared under. + pub fn route(&self) -> (RouteVersion, u16) { + self.prepared.route + } + + /// How many mask-risc programs one run executes. + pub fn program_count(&self) -> usize { + self.programs.len() + } + + /// For each program, the index of the step that produced it, in + /// execution order. + pub fn program_effects(&self) -> Vec { + self.programs.iter().map(|p| p.effect).collect() + } + + /// How many scalar fields a run reports. + pub fn outputs(&self) -> usize { + self.outputs + } + + /// The written fields whose final value is a mask or an address, which a + /// receipt never carries. Static for the plan. + pub fn intermediates(&self) -> &[u8] { + &self.intermediates + } + + /// Words of scratch a run needs. + pub fn scratch_words(&self) -> usize { + self.scratch_words + } +} + +/// The caller-owned scratch words [`run_native`] carves for each program. +/// Allocated once by [`NativeScratch::for_plan`]. +#[derive(Debug, Clone)] +pub struct NativeScratch { + words: Vec, +} + +impl NativeScratch { + /// Scratch sized for `plan`. + pub fn for_plan(plan: &NativePlan) -> Self { + Self { + words: vec![0u64; plan.scratch_words], + } + } +} + +/// What one native run leaves behind. It names the plan, the frame and the +/// scalar answer; it holds no heap data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NativeReceipt { + pub plan_id: u64, + pub frame: FrameRevisions, + /// mask-risc programs the run executed. + pub programs_run: usize, + /// How many entries of `values` are filled. + pub len: usize, + /// `(field, value)`, ascending by field; entries past `len` are unused. + pub values: [(u8, i64); MAX_NATIVE_OUTPUTS], +} + +impl NativeReceipt { + /// The filled `(field, value)` entries, ascending by field. + pub fn values(&self) -> &[(u8, i64)] { + &self.values[..self.len] + } +} + +/// Run `plan`'s steps once through [`FoldDialect`] over `planes` and keep the +/// programs its folds finalize, cold. +/// +/// # Errors +/// +/// An [`ExecRefusal`]: the guard, route, argument, compare and fold refusals +/// of [`crate::binding::run`], and the named refusals in the module docs. +pub fn prepare_native( + plan: &Prepared, + store: &BindStore, + frames: &FrameStore, + now: (Focus, Scope), + fields: FieldLanes<'_>, + planes: &Planes<'_>, +) -> Result { + plan.guards + .check(frames, now) + .map_err(ExecRefusal::Guards)?; + check_route(store, plan.method.class, plan.route)?; + let mut dialect = FoldDialect::new(planes, &Foreign::NONE); + let mut values: BTreeMap = BTreeMap::new(); + let mut stack: Vec = Vec::new(); + let mut programs: Vec = Vec::new(); + for (i, step) in plan.steps.iter().enumerate() { + let mut all_const = true; + let mut tainted: Option = None; + for &a in &step.args { + let (v, origin) = match a { + Arg::Field(p) => match values.get(&p) { + Some(&held) => held, + None => (lane(planes, fields, i, p)?, Origin::Data), + }, + Arg::Const(c) => (Val::Scalar(c), Origin::Const), + }; + match origin { + Origin::Const => {} + Origin::Data => all_const = false, + Origin::Fold(k) => { + all_const = false; + tainted = tainted.or(Some(k)); + } + } + stack.push(v); + } + if tainted.is_some() && FOLD_OPCODES.contains(&step.opcode) { + return Err(ExecRefusal::FoldResultFeedsFold { effect: i }); + } + if LANE_COMPARES.contains(&step.opcode) { + check_compare(&stack, i)?; + } + let before = dialect.programs_run(); + dialect + .call(step.opcode, [0, 0, 0], &mut stack) + .map_err(|error| ExecRefusal::Fold { effect: i, error })?; + if stack.len() != 1 { + return Err(ExecRefusal::ResultArity { + effect: i, + left: stack.len(), + }); + } + let out = stack.pop().expect("length checked"); + let origin = if dialect.programs_run() > before { + if tainted.is_some() { + return Err(ExecRefusal::FoldResultFeedsFold { effect: i }); + } + let program = dialect + .last_program() + .ok_or(ExecRefusal::Fold { + effect: i, + error: FoldError::Unimplemented(step.opcode), + })? + .clone(); + let empty = match program.terminal { + Terminal::MaskedMinI32 { .. } => i64::MAX, + Terminal::MaskedMaxI32 { .. } => i64::MIN, + _ => 0, + }; + let slots = program.scratch_slots as usize; + programs.push(ProgramPlan { + cp: CompiledProgram::new(program), + effect: i, + opcode: step.opcode, + slots, + empty, + out: None, + }); + Origin::Fold(programs.len() - 1) + } else if let Some(k) = tainted { + if matches!(out, Val::Scalar(_)) { + return Err(ExecRefusal::ScalarAfterFold { effect: i }); + } + Origin::Fold(k) + } else if all_const { + Origin::Const + } else { + Origin::Data + }; + values.insert(step.target, (out, origin)); + } + let mut template = [(0u8, 0i64); MAX_NATIVE_OUTPUTS]; + let mut intermediates = Vec::new(); + let mut outputs = 0usize; + for (field, (v, origin)) in values { + let Val::Scalar(x) = v else { + intermediates.push(field); + continue; + }; + if outputs < MAX_NATIVE_OUTPUTS { + match origin { + Origin::Fold(k) => { + template[outputs] = (field, 0); + programs[k].out = Some(outputs); + } + Origin::Const => template[outputs] = (field, x), + Origin::Data => return Err(ExecRefusal::DataScalarNotPrepared { field }), + } + } + outputs += 1; + } + if outputs > MAX_NATIVE_OUTPUTS { + return Err(ExecRefusal::TooManyOutputs { outputs }); + } + let tile_words = tile_words_for(planes.n_rows); + let max_slots = programs.iter().map(|p| p.slots).max().unwrap_or(0); + let scratch_words = + scratch_words_for(tile_words, max_slots).ok_or_else(|| ExecRefusal::Fold { + effect: programs.last().map_or(0, |p| p.effect), + error: FoldError::Exec(lance_graph_mask_risc::ExecError::ScratchBufferTooSmall { + need_words: usize::MAX, + have_words: 0, + }), + })?; + Ok(NativePlan { + prepared: plan.clone(), + programs, + shape: shape_of(planes), + template, + outputs, + intermediates, + tile_words, + scratch_words, + }) +} + +/// Execute a native plan, hot: the guards, the route and the permit are +/// checked as [`crate::binding::run`] checks them, the planes' shape is +/// compared with the one the plan was prepared over, and the plan's programs +/// run in step order. Nothing is allocated. +/// +/// # Errors +/// +/// An [`ExecRefusal`]; nothing has executed when a check before the first +/// program fails. +pub fn run_native( + np: &NativePlan, + store: &BindStore, + frames: &FrameStore, + now: (Focus, Scope), + permit: &ExecPermit, + planes: &Planes<'_>, + scratch: &mut NativeScratch, +) -> Result { + let plan = &np.prepared; + plan.guards + .check(frames, now) + .map_err(ExecRefusal::Guards)?; + check_route(store, plan.method.class, plan.route)?; + if !permit.methods.contains(&plan.method) { + return Err(ExecRefusal::NotPermitted); + } + let shape = shape_of(planes); + if shape != np.shape { + return Err(ExecRefusal::PlanesChanged { + recorded: np.shape, + now: shape, + }); + } + let mut values = np.template; + for p in &np.programs { + let fold = |error| ExecRefusal::Fold { + effect: p.effect, + error: FoldError::Exec(error), + }; + let mut s = Scratch::over(&mut scratch.words, np.tile_words, p.slots).map_err(fold)?; + let v = execute_compiled( + &p.cp.compiled(), + planes, + &Foreign::NONE, + &mut s, + Out::None, + 0..planes.n_rows, + ) + .map_err(fold)?; + let x = match v { + Value::Count(n) => i64::try_from(n).unwrap_or(i64::MAX), + Value::SumI64(n) => n, + Value::Bool(b) => i64::from(b), + Value::OptI32(o) => o.map_or(p.empty, i64::from), + _ => { + return Err(ExecRefusal::Fold { + effect: p.effect, + error: FoldError::WrongOperandKind(p.opcode), + }) + } + }; + if let Some(o) = p.out { + values[o].1 = x; + } + } + Ok(NativeReceipt { + plan_id: plan.plan_id, + frame: frames.revisions(), + programs_run: np.programs.len(), + len: np.outputs, + values, + }) +} diff --git a/crates/lance-graph-r2il/src/lib.rs b/crates/lance-graph-r2il/src/lib.rs index 407543f19..501d704e6 100644 --- a/crates/lance-graph-r2il/src/lib.rs +++ b/crates/lance-graph-r2il/src/lib.rs @@ -102,6 +102,7 @@ //! so the arity table stays in `ogar_r2il` alone. pub mod binding; +pub mod binding_native; pub mod procs; use std::cell::Cell; diff --git a/crates/lance-graph-r2il/tests/integration/binding_native.rs b/crates/lance-graph-r2il/tests/integration/binding_native.rs new file mode 100644 index 000000000..8d59396eb --- /dev/null +++ b/crates/lance-graph-r2il/tests/integration/binding_native.rs @@ -0,0 +1,828 @@ +//! The prepared native form of an R1 plan (`binding_native`). +//! +//! Every native answer is compared with what `binding::run` returns for the +//! same plan over the same lanes, and with a scalar oracle. The fixture mirrors +//! `binding.rs`'s (same lab classids, same lens construction), with its own +//! method set. +//! +//! This binary installs a counting global allocator. The count is per thread, +//! so tests running in parallel do not disturb each other. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; +use std::hint::black_box; + +use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +use lance_graph_contract::set_rules::{Population, Truth}; +use lance_graph_dt64::dr::{Focus, FrameRevisions, FrameStore, GuardRefusal, Guards, Scope}; +use lance_graph_dt64::{ + AlgebraRef, Arg, BindStore, ClassSchema, Effect, Extent, MethodBinding, MethodId, MethodRef, + RouteVersion, SchemaRev, SigRev, ValueDemand, +}; +use lance_graph_mask_risc::{ExecError, Foreign, LaneRef, Planes}; +use lance_graph_r2il::binding::{ + prepare, run, ExecPermit, ExecRefusal, FieldLanes, Prepared, R2ilLens, Receipt, Resolution, +}; +use lance_graph_r2il::binding_native::{ + prepare_native, run_native, NativePlan, NativeReceipt, NativeScratch, MAX_NATIVE_OUTPUTS, +}; +use lance_graph_r2il::{FoldDialect, FoldError, INT_ADD, INT_S_LESS, POP_COUNT}; +use ogar_loco::{FnIndex, Vocabulary}; +use ogar_r2il::{R2ILVocabulary, ANY, CONCEPT_R2IL_FOLD, MAX, MIN, SUM}; + +// ── counting allocator ───────────────────────────────────────────────────── + +struct Counting; + +thread_local! { + static ALLOCS: Cell = const { Cell::new(0) }; +} + +// SAFETY: forwards every call to `System` unchanged; only counts. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + let _ = ALLOCS.try_with(|c| c.set(c.get() + 1)); + // SAFETY: same contract as the caller's. + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + // SAFETY: same contract as the caller's. + unsafe { System.dealloc(p, l) } + } +} + +#[global_allocator] +static A: Counting = Counting; + +fn allocs() -> usize { + ALLOCS.with(Cell::get) +} + +// ── fixture ──────────────────────────────────────────────────────────────── + +const CLASS: u32 = 0x7F0B_0001; +const CLASS_VIEW: ClassId = 0x7F0B; +const OTHER_CLASS: u32 = 0x7F0C_0001; +const LENS: u32 = (CONCEPT_R2IL_FOLD as u32) << 16; +const REV: SchemaRev = SchemaRev(1); +const R2IL: R2ilLens = R2ilLens { + lens: LENS, + schema_rev: REV, +}; + +/// Two I32 lanes, one U32 lane, then nine computed fields. +const FIELDS: [&str; 12] = [ + "a", "b", "k", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", +]; +const LANES: [Option; 12] = [ + Some(0), + Some(1), + Some(2), + None, + None, + None, + None, + None, + None, + None, + None, + None, +]; + +/// The lens's words, in this order; only the label binds a word to an opcode. +const W_LT: u8 = 0; +const W_SUM: u8 = 1; +const W_POP: u8 = 2; +const W_ADD: u8 = 3; +const W_MIN: u8 = 4; +const W_MAX: u8 = 5; +const W_ANY: u8 = 6; +const WORD_OPS: [FnIndex; 7] = [INT_S_LESS, SUM, POP_COUNT, INT_ADD, MIN, MAX, ANY]; + +fn words() -> Vec<&'static str> { + WORD_OPS + .iter() + .map(|&f| R2ILVocabulary.name(f).expect("OGAR names the opcode")) + .collect() +} + +const FOCUS: Focus = Focus { revision: 1 }; +const SCOPE: Scope = Scope { id: 1 }; +const NOW: (Focus, Scope) = (FOCUS, SCOPE); +const POSITION: u16 = 3; +const REVS: FrameRevisions = FrameRevisions { + source: 1, + relation_map: 1, + coordinates: 1, +}; + +struct Views { + class: Vec, + lens: Vec, +} + +fn refs(labels: &[&str], ns: &str) -> Vec { + labels + .iter() + .map(|l| FieldRef::new(format!("lab:{ns}/{l}"), (*l).to_string())) + .collect() +} + +impl ClassView for Views { + fn fields(&self, class: ClassId) -> &[FieldRef] { + match class { + CLASS_VIEW => &self.class, + c if c == CONCEPT_R2IL_FOLD => &self.lens, + _ => &[], + } + } + fn template(&self, _class: ClassId) -> DisplayTemplate { + DisplayTemplate::Card + } + fn dolce_category_id(&self, _class: ClassId) -> u8 { + 0 + } +} + +fn ogar_arities() -> Vec { + WORD_OPS + .iter() + .map(|&f| R2ILVocabulary.stack_arity(f).expect("OGAR gives an arity")) + .collect() +} + +fn step(target: u8, word: u8, args: Vec) -> Effect { + Effect { + target, + algebra: AlgebraRef { lens: LENS, word }, + args, + } +} + +fn method(id: u32, effects: Vec) -> MethodBinding { + let reads = WideFieldMask::from_positions(&[0, 1]); + MethodBinding { + id: MethodId(id), + sig_rev: SigRev(1), + name: format!("m{id}"), + on: reads.clone(), + when: None, + reads, + effects, + population: Population::Distinct, + truth: Truth::TwoValued, + demand: ValueDemand::Values, + } +} + +fn lt(target: u8, t: i64) -> Effect { + step(target, W_LT, vec![Arg::Field(0), Arg::Const(t)]) +} + +const MASKED_SUM: u32 = 1; // SUM(b) where a < 5 +const SUM_A: u32 = 2; // SUM(a) where a < 5 +const COUNT: u32 = 3; // POP_COUNT(a < 5) +const TWO_FOLD: u32 = 4; // COUNT into 4, then SUM(b) into 6 +const TWO_FOLD_REV: u32 = 5; // SUM(b) into 4, then COUNT into 6 +const MIN_MAX: u32 = 6; // MIN(b) into 4, MAX(b) into 6, each under a < 5 +const EMPTY_MIN: u32 = 7; // MIN(b) under a mask that selects nothing +const ANY_ROW: u32 = 8; // ANY(a < 5) +const ORDER_A: u32 = 9; // a scalar computed from a fold result +const SUB_RIGHT: u32 = 10; // likewise, subtraction +const CONST_AFTER: u32 = 11; // a constant-only scalar after a fold +const FEEDS: u32 = 12; // a compare constant that is a fold result +const FEEDS_OK: u32 = 13; // the same shape with a literal constant +const NINE_OUT: u32 = 14; // nine scalar fields +const EIGHT_OUT: u32 = 15; // eight scalar fields + +fn methods() -> Vec { + let masked_sum = || vec![lt(3, 5), step(4, W_SUM, vec![Arg::Field(3), Arg::Field(1)])]; + let consts = |n: u8| -> Vec { + (0..n) + .map(|i| step(i, W_ADD, vec![Arg::Const(i64::from(i)), Arg::Const(100)])) + .collect() + }; + let with = |mut base: Vec, extra: Vec| { + base.extend(extra); + base + }; + vec![ + method(MASKED_SUM, masked_sum()), + method( + SUM_A, + vec![lt(3, 5), step(4, W_SUM, vec![Arg::Field(3), Arg::Field(0)])], + ), + method(COUNT, vec![lt(3, 5), step(4, W_POP, vec![Arg::Field(3)])]), + method( + TWO_FOLD, + vec![ + lt(3, 5), + step(4, W_POP, vec![Arg::Field(3)]), + lt(5, 5), + step(6, W_SUM, vec![Arg::Field(5), Arg::Field(1)]), + ], + ), + method( + TWO_FOLD_REV, + vec![ + lt(3, 5), + step(4, W_SUM, vec![Arg::Field(3), Arg::Field(1)]), + lt(5, 5), + step(6, W_POP, vec![Arg::Field(5)]), + ], + ), + method( + MIN_MAX, + vec![ + lt(3, 5), + step(4, W_MIN, vec![Arg::Field(3), Arg::Field(1)]), + lt(5, 5), + step(6, W_MAX, vec![Arg::Field(5), Arg::Field(1)]), + ], + ), + method( + EMPTY_MIN, + vec![ + lt(3, -1000), + step(4, W_MIN, vec![Arg::Field(3), Arg::Field(1)]), + ], + ), + method(ANY_ROW, vec![lt(3, 5), step(4, W_ANY, vec![Arg::Field(3)])]), + method( + ORDER_A, + with( + masked_sum(), + vec![ + step(5, W_ADD, vec![Arg::Field(4), Arg::Const(1)]), + step(4, W_ADD, vec![Arg::Field(4), Arg::Field(4)]), + ], + ), + ), + method( + SUB_RIGHT, + with( + masked_sum(), + vec![step(5, W_ADD, vec![Arg::Const(7), Arg::Field(4)])], + ), + ), + method( + CONST_AFTER, + with( + masked_sum(), + vec![step(5, W_ADD, vec![Arg::Const(2), Arg::Const(3)])], + ), + ), + method( + FEEDS, + vec![ + lt(3, 5), + step(4, W_POP, vec![Arg::Field(3)]), + step(5, W_LT, vec![Arg::Field(1), Arg::Field(4)]), + step(6, W_SUM, vec![Arg::Field(5), Arg::Field(0)]), + ], + ), + method( + FEEDS_OK, + vec![ + lt(3, 5), + step(4, W_POP, vec![Arg::Field(3)]), + step(5, W_LT, vec![Arg::Field(1), Arg::Const(5)]), + step(6, W_SUM, vec![Arg::Field(5), Arg::Field(0)]), + ], + ), + method(NINE_OUT, consts(9)), + method(EIGHT_OUT, consts(8)), + ] +} + +fn mref(id: u32) -> MethodRef { + MethodRef { + class: CLASS, + method: MethodId(id), + sig_rev: SigRev(1), + guard: None, + } +} + +fn permit(ids: &[u32]) -> ExecPermit { + ExecPermit { + methods: ids.iter().map(|&i| mref(i)).collect(), + } +} + +/// Lanes of `n` rows: `a = i % 37 - 10`, `b = (7i) % 23`, `k = i % 5`. +struct Data { + a: Vec, + b: Vec, + k: Vec, +} + +impl Data { + fn new(n: usize) -> Self { + Self { + a: (0..n).map(|i| (i % 37) as i32 - 10).collect(), + b: (0..n).map(|i| ((7 * i) % 23) as i32).collect(), + k: (0..n).map(|i| (i % 5) as u32).collect(), + } + } + fn lanes(&self) -> [LaneRef<'_>; 3] { + [ + LaneRef::I32(&self.a), + LaneRef::I32(&self.b), + LaneRef::U32(&self.k), + ] + } + fn selected(&self, t: i32) -> impl Iterator + '_ { + (0..self.a.len()).filter(move |&i| self.a[i] < t) + } + fn sum_b(&self, t: i32) -> i64 { + self.selected(t).map(|i| i64::from(self.b[i])).sum() + } + fn sum_a(&self, t: i32) -> i64 { + self.selected(t).map(|i| i64::from(self.a[i])).sum() + } + fn count(&self, t: i32) -> i64 { + self.selected(t).count() as i64 + } + fn min_b(&self, t: i32) -> Option { + self.selected(t).map(|i| i64::from(self.b[i])).min() + } + fn max_b(&self, t: i32) -> Option { + self.selected(t).map(|i| i64::from(self.b[i])).max() + } +} + +struct Fixture { + views: Views, + store: BindStore, + version: RouteVersion, + frames: FrameStore, +} + +impl Fixture { + fn new() -> Self { + let mut store = BindStore::new(); + store + .declare_algebra(LENS, REV, Extent::DR64_CUBE, ogar_arities()) + .unwrap(); + let schema = ClassSchema { + schema_rev: REV, + field_count: FIELDS.len(), + extent: Extent::DR64_CUBE, + }; + store.register(CLASS, schema, methods()).unwrap(); + store.register(OTHER_CLASS, schema, vec![]).unwrap(); + let version = store.route(POSITION, CLASS).unwrap(); + Self { + views: Views { + class: refs(&FIELDS, "class"), + lens: refs(&words(), "r2il"), + }, + store, + version, + frames: FrameStore::new(REVS), + } + } + + fn prepare(&self, id: u32) -> Prepared { + let r = mref(id); + let g = Guards { + frame: self.frames.seal(), + focus: FOCUS, + scope: SCOPE, + }; + prepare( + &self.store, + &self.views, + R2IL, + &Resolution { + method: &r, + schema_rev: REV, + route: (self.version, POSITION), + guards: &g, + }, + &self.frames, + NOW, + ) + .expect("the fixture's methods prepare") + } + + fn r1( + &self, + plan: &Prepared, + data: &Data, + permit: &ExecPermit, + now: (Focus, Scope), + ) -> Result { + let lanes = data.lanes(); + let planes = Planes { + n_rows: data.a.len(), + masks: &[], + lanes: &lanes, + }; + let foreign = Foreign::NONE; + let mut d = FoldDialect::new(&planes, &foreign); + run( + plan, + &self.store, + &self.frames, + now, + permit, + FieldLanes { lanes: &LANES }, + &mut d, + ) + } + + fn native(&self, plan: &Prepared, data: &Data) -> Result { + let lanes = data.lanes(); + let planes = Planes { + n_rows: data.a.len(), + masks: &[], + lanes: &lanes, + }; + prepare_native( + plan, + &self.store, + &self.frames, + NOW, + FieldLanes { lanes: &LANES }, + &planes, + ) + } + + fn run_native( + &self, + np: &NativePlan, + data: &Data, + permit: &ExecPermit, + now: (Focus, Scope), + scratch: &mut NativeScratch, + ) -> Result { + let lanes = data.lanes(); + let planes = Planes { + n_rows: data.a.len(), + masks: &[], + lanes: &lanes, + }; + run_native(np, &self.store, &self.frames, now, permit, &planes, scratch) + } +} + +const N: usize = 1_000; + +/// Prepare natively and run once, over `data`. +fn native_answer(f: &Fixture, id: u32, data: &Data) -> NativeReceipt { + let plan = f.prepare(id); + let np = f.native(&plan, data).expect("prepare_native"); + let mut scratch = NativeScratch::for_plan(&np); + f.run_native(&np, data, &permit(&[id]), NOW, &mut scratch) + .expect("run_native") +} + +// ── agreement with `run` ─────────────────────────────────────────────────── + +// Fails if `run_native` returns a different (field, value) list, programs_run, +// plan id, frame or intermediate list than `run` for any plan whose folds are +// independent, or if the fixture stops selecting rows (the comparison would +// then hold for an all-zero answer). +#[test] +fn native_agrees_with_run_on_every_runnable_fixture() { + let f = Fixture::new(); + let data = Data::new(N); + assert!(data.sum_b(5) > 0 && data.count(5) > 0 && data.min_b(5) != data.max_b(5)); + let ids = [ + MASKED_SUM, + SUM_A, + COUNT, + TWO_FOLD, + TWO_FOLD_REV, + MIN_MAX, + EMPTY_MIN, + ANY_ROW, + CONST_AFTER, + FEEDS_OK, + EIGHT_OUT, + ]; + for id in ids { + let plan = f.prepare(id); + let want = f.r1(&plan, &data, &permit(&[id]), NOW).expect("run"); + let np = f.native(&plan, &data).expect("prepare_native"); + let mut scratch = NativeScratch::for_plan(&np); + let got = f + .run_native(&np, &data, &permit(&[id]), NOW, &mut scratch) + .expect("run_native"); + assert_eq!(got.values(), want.output.values.as_slice(), "method {id}"); + assert_eq!(got.programs_run, want.programs_run, "method {id}"); + assert_eq!(np.program_count(), want.programs_run, "method {id}"); + assert_eq!(got.plan_id, want.plan_id, "method {id}"); + assert_eq!(got.frame, want.frame, "method {id}"); + assert_eq!(np.intermediates(), want.output.intermediates, "method {id}"); + assert_eq!(np.outputs(), want.output.values.len(), "method {id}"); + } +} + +// Fails if the answers are not the oracle's (a native answer equal to `run`'s +// but both wrong would pass the test above). +#[test] +fn native_answers_match_the_oracle() { + let f = Fixture::new(); + let data = Data::new(N); + let v = |id| native_answer(&f, id, &data).values().to_vec(); + assert_eq!(v(MASKED_SUM), vec![(4, data.sum_b(5))]); + assert_eq!(v(SUM_A), vec![(4, data.sum_a(5))]); + assert_eq!(v(COUNT), vec![(4, data.count(5))]); + assert_eq!(v(ANY_ROW), vec![(4, 1)]); + assert_eq!( + v(MIN_MAX), + vec![(4, data.min_b(5).unwrap()), (6, data.max_b(5).unwrap())] + ); + assert_ne!(data.sum_a(5), data.sum_b(5), "the recipes must differ"); +} + +// Fails if an empty MIN is reported as anything but the dialect's empty value. +#[test] +fn an_empty_min_keeps_the_dialects_empty_value() { + let f = Fixture::new(); + let data = Data::new(N); + assert_eq!(data.count(-1000), 0, "the mask must select nothing"); + assert_eq!( + native_answer(&f, EMPTY_MIN, &data).values(), + &[(4, i64::MAX)] + ); +} + +// Fails if programs do not run in step order or a program's result lands in +// the wrong field: the two plans swap which fold sits where. +#[test] +fn two_folds_run_in_step_order() { + let f = Fixture::new(); + let data = Data::new(N); + let (count, sum) = (data.count(5), data.sum_b(5)); + assert_ne!(count, sum); + let a = native_answer(&f, TWO_FOLD, &data); + let b = native_answer(&f, TWO_FOLD_REV, &data); + assert_eq!(a.values(), &[(4, count), (6, sum)]); + assert_eq!(b.values(), &[(4, sum), (6, count)]); + assert_eq!((a.programs_run, b.programs_run), (2, 2)); + let np = f.native(&f.prepare(TWO_FOLD), &data).unwrap(); + assert_eq!(np.program_effects(), vec![1, 3]); + assert_eq!(np.intermediates(), &[3, 5]); +} + +// Fails if a constant-only scalar is not carried as a fixed value in its +// field's place, or if it displaces the fold result. +#[test] +fn a_constant_only_scalar_is_a_fixed_output() { + let f = Fixture::new(); + let data = Data::new(N); + assert_eq!( + native_answer(&f, CONST_AFTER, &data).values(), + &[(4, data.sum_b(5)), (5, 5)] + ); +} + +// Fails if the hot path replays prepare-time data: the lanes change, the plan +// is the same, and the answer must follow the lanes. +#[test] +fn a_run_reads_the_lanes_it_is_given() { + let f = Fixture::new(); + let first = Data::new(N); + let mut second = Data::new(N); + second.b.iter_mut().for_each(|x| *x += 1); + assert_ne!(first.sum_b(5), second.sum_b(5)); + let plan = f.prepare(MASKED_SUM); + let np = f.native(&plan, &first).unwrap(); + let mut scratch = NativeScratch::for_plan(&np); + let p = permit(&[MASKED_SUM]); + let r = f.run_native(&np, &second, &p, NOW, &mut scratch).unwrap(); + assert_eq!(r.values(), &[(4, second.sum_b(5))]); +} + +// ── named refusals at prepare ────────────────────────────────────────────── + +// Fails unless a scalar computed from a fold result is refused by name, with +// the index of the offending step. Silent twin: CONST_AFTER (a step that does +// not touch the fold result) and plain MASKED_SUM prepare. +#[test] +fn a_scalar_after_a_fold_is_refused() { + let f = Fixture::new(); + let data = Data::new(N); + for id in [ORDER_A, SUB_RIGHT] { + assert!( + matches!( + f.native(&f.prepare(id), &data), + Err(ExecRefusal::ScalarAfterFold { effect: 2 }) + ), + "method {id}" + ); + } + for id in [MASKED_SUM, CONST_AFTER] { + assert!(f.native(&f.prepare(id), &data).is_ok(), "method {id}"); + } +} + +// Fails unless a fold fed by a value computed from an earlier fold is refused +// by name. Silent twin: the same plan with a literal constant prepares and +// matches `run`. +#[test] +fn a_fold_fed_by_a_fold_result_is_refused() { + let f = Fixture::new(); + let data = Data::new(N); + assert!(matches!( + f.native(&f.prepare(FEEDS), &data), + Err(ExecRefusal::FoldResultFeedsFold { effect: 3 }) + )); + let plan = f.prepare(FEEDS_OK); + assert!(f.native(&plan, &data).is_ok()); + // `run` accepts the refused plan: only the native reading cannot. + assert!(f + .r1(&f.prepare(FEEDS), &data, &permit(&[FEEDS]), NOW) + .is_ok()); +} + +// Fails unless nine scalar fields are refused and eight are not. +#[test] +fn too_many_outputs_are_refused() { + let f = Fixture::new(); + let data = Data::new(N); + assert_eq!(MAX_NATIVE_OUTPUTS, 8); + assert!(matches!( + f.native(&f.prepare(NINE_OUT), &data), + Err(ExecRefusal::TooManyOutputs { outputs: 9 }) + )); + let eight = f.native(&f.prepare(EIGHT_OUT), &data).unwrap(); + assert_eq!(eight.outputs(), 8); + assert_eq!(eight.program_count(), 0); +} + +// ── refusals before the first program ────────────────────────────────────── + +// Fails if `run_native` answers a refusal `run` raises before its first step +// with anything other than the same refusal, or checks them in another order. +#[test] +fn run_native_refuses_what_run_refuses_first() { + let mut f = Fixture::new(); + let data = Data::new(N); + let id = MASKED_SUM; + let plan = f.prepare(id); + let np = f.native(&plan, &data).unwrap(); + let mut s = NativeScratch::for_plan(&np); + let p = permit(&[id]); + let none = ExecPermit::default(); + let moved_focus = (Focus { revision: 2 }, SCOPE); + let moved_scope = (FOCUS, Scope { id: 2 }); + for (now, pm) in [ + (NOW, &none), + (moved_focus, &p), + (moved_scope, &p), + (moved_focus, &none), + ] { + let want = f.r1(&plan, &data, pm, now).unwrap_err(); + let got = f.run_native(&np, &data, pm, now, &mut s).unwrap_err(); + assert_eq!(got, want); + } + assert_eq!( + f.run_native(&np, &data, &none, NOW, &mut s), + Err(ExecRefusal::NotPermitted) + ); + assert_eq!( + f.run_native(&np, &data, &p, moved_focus, &mut s), + Err(ExecRefusal::Guards(GuardRefusal::FocusChanged)) + ); + // Silent twin: with everything in place the same call runs. + assert!(f.run_native(&np, &data, &p, NOW, &mut s).is_ok()); + + let revs = FrameRevisions { + coordinates: 2, + ..REVS + }; + f.frames.advance(revs); + let want = f.r1(&plan, &data, &p, NOW).unwrap_err(); + assert!(matches!(want, ExecRefusal::Guards(GuardRefusal::Frame(_)))); + assert_eq!(f.run_native(&np, &data, &p, NOW, &mut s), Err(want)); +} + +// Fails if a route moved after prepare is reused. +#[test] +fn run_native_refuses_a_changed_route() { + let mut f = Fixture::new(); + let data = Data::new(N); + let plan = f.prepare(MASKED_SUM); + let np = f.native(&plan, &data).unwrap(); + let mut s = NativeScratch::for_plan(&np); + f.store.route(POSITION, OTHER_CLASS).unwrap(); + let p = permit(&[MASKED_SUM]); + let want = f.r1(&plan, &data, &p, NOW).unwrap_err(); + assert_eq!( + want, + ExecRefusal::RouteChanged { + position: POSITION, + captured: Some(CLASS), + now: Some(OTHER_CLASS), + class: CLASS, + } + ); + assert_eq!(f.run_native(&np, &data, &p, NOW, &mut s), Err(want)); +} + +// Fails unless a change in the planes' shape (rows, or lane count) is +// refused by name. Silent twin: the same shape with other content runs +// (`a_run_reads_the_lanes_it_is_given`). +#[test] +fn run_native_refuses_changed_planes() { + let f = Fixture::new(); + let data = Data::new(N); + let np = f.native(&f.prepare(MASKED_SUM), &data).unwrap(); + let mut s = NativeScratch::for_plan(&np); + let p = permit(&[MASKED_SUM]); + let shorter = Data::new(N / 2); + assert_eq!( + f.run_native(&np, &shorter, &p, NOW, &mut s), + Err(ExecRefusal::PlanesChanged { + recorded: (N, 3, 0), + now: (N / 2, 3, 0), + }) + ); + let lanes = [LaneRef::I32(&data.a), LaneRef::I32(&data.b)]; + let planes = Planes { + n_rows: N, + masks: &[], + lanes: &lanes, + }; + assert_eq!( + run_native(&np, &f.store, &f.frames, NOW, &p, &planes, &mut s), + Err(ExecRefusal::PlanesChanged { + recorded: (N, 3, 0), + now: (N, 2, 0), + }) + ); +} + +// Fails if a scratch that is too small panics or is accepted instead of being +// reported as the mask-risc scratch refusal at the program's step. +#[test] +fn a_scratch_from_another_plan_is_refused() { + let f = Fixture::new(); + let data = Data::new(N); + let np = f.native(&f.prepare(MASKED_SUM), &data).unwrap(); + let no_programs = f.native(&f.prepare(EIGHT_OUT), &data).unwrap(); + let mut small = NativeScratch::for_plan(&no_programs); + assert!(np.scratch_words() > no_programs.scratch_words()); + let r = f.run_native(&np, &data, &permit(&[MASKED_SUM]), NOW, &mut small); + assert!(matches!( + r, + Err(ExecRefusal::Fold { + effect: 1, + error: FoldError::Exec(ExecError::ScratchBufferTooSmall { .. }) + }) + )); +} + +// ── allocation ───────────────────────────────────────────────────────────── + +// Fails if `run_native` allocates. The twin proves the counter can fire: the +// interpreting `run` allocates on every call. +#[test] +fn run_native_allocates_nothing() { + let f = Fixture::new(); + let data = Data::new(N); + let lanes = data.lanes(); + let planes = Planes { + n_rows: N, + masks: &[], + lanes: &lanes, + }; + for id in [MASKED_SUM, TWO_FOLD, MIN_MAX, CONST_AFTER] { + let plan = f.prepare(id); + let np = f.native(&plan, &data).unwrap(); + let mut scratch = NativeScratch::for_plan(&np); + let p = permit(&[id]); + let want = native_answer(&f, id, &data); + for _ in 0..16 { + black_box( + run_native(&np, &f.store, &f.frames, NOW, &p, &planes, &mut scratch).unwrap(), + ); + } + let before = allocs(); + let mut last = None; + for _ in 0..1_000 { + last = Some(black_box(run_native( + black_box(&np), + &f.store, + &f.frames, + NOW, + &p, + &planes, + &mut scratch, + ))); + } + let spent = allocs() - before; + assert_eq!(spent, 0, "method {id} allocated {spent} times"); + assert_eq!(last.unwrap().unwrap(), want, "method {id}"); + + let before = allocs(); + f.r1(&plan, &data, &p, NOW).unwrap(); + assert!(allocs() > before, "the counter must see `run` allocate"); + } +} diff --git a/crates/lance-graph-r2il/tests/integration/main.rs b/crates/lance-graph-r2il/tests/integration/main.rs index aa1741e0d..cd3fe6c8b 100644 --- a/crates/lance-graph-r2il/tests/integration/main.rs +++ b/crates/lance-graph-r2il/tests/integration/main.rs @@ -2,3 +2,4 @@ //! `.claude/rules/integration-tests.md`). pub mod binding; +pub mod binding_native; diff --git a/crates/math-polyfill-probe/examples/r1_vs_prebound.rs b/crates/math-polyfill-probe/examples/r1_vs_prebound.rs index cceb75b79..49c60175f 100644 --- a/crates/math-polyfill-probe/examples/r1_vs_prebound.rs +++ b/crates/math-polyfill-probe/examples/r1_vs_prebound.rs @@ -14,6 +14,7 @@ //! 5 r2il prepared FoldDialect run once, `last_program` kept, `execute_compiled` //! 6 dialect `SumWhere::eval` (FoldDialect fold) per call //! 7 R1 run `binding::prepare` once, `binding::run` per call +//! 8 R1 native `prepare_native` once, `run_native` per call //! //! `MPF_REPS`, `MPF_ITERS` as in `classid_route`. ns per call, median. @@ -39,6 +40,7 @@ use lance_graph_mask_risc::{words_for, Foreign, LaneRef, Out, Planes, Value}; use lance_graph_r2il::binding::{ prepare, run, ExecPermit, FieldLanes, R2ilLens, Receipt, Resolution, }; +use lance_graph_r2il::binding_native::{prepare_native, run_native, NativeReceipt, NativeScratch}; use lance_graph_r2il::FoldDialect; use math_polyfill_probe::native::{sum_where_oracle, Pred, SumWhere}; use ndarray::simd as nd; @@ -272,6 +274,14 @@ fn answer(r: &Receipt) -> i64 { .expect("R1 receipt carries the SUM field") } +fn answer_native(r: &NativeReceipt) -> i64 { + r.values() + .iter() + .find(|(f, _)| *f == F_S) + .map(|&(_, v)| v) + .expect("native receipt carries the SUM field") +} + fn main() { let reps = env("MPF_REPS", 21); let iters0 = env("MPF_ITERS", 4000); @@ -356,6 +366,7 @@ fn main() { "r2il prepared", "dialect", "R1 run", + "R1 native", ]; let mut time_rows = Vec::new(); let mut alloc_rows = Vec::new(); @@ -420,6 +431,11 @@ fn main() { let mut d7 = FoldDialect::new(&planes, &Foreign::NONE); let fields = FieldLanes { lanes: &LANES }; + // arm 8: R1 native, prepared once outside the timers + let np = prepare_native(&plan, &bstore, &frames, now, fields, &planes) + .expect("R1 prepare_native"); + let mut ns8 = NativeScratch::for_plan(&np); + // oracle check, every arm, before timing assert!(want != 0, "the fixture must select rows"); assert_eq!(probe_direct(&x, t, &mut m), want, "direct nd, n={n}"); @@ -438,6 +454,13 @@ fn main() { assert_eq!(op.eval(&mut d6, &mut stack6), Ok(want), "dialect, n={n}"); let r1 = run(&plan, &bstore, &frames, now, &permit, fields, &mut d7).expect("R1 run"); assert_eq!(answer(&r1), want, "R1 run, n={n}"); + let r8 = run_native(&np, &bstore, &frames, now, &permit, &planes, &mut ns8) + .expect("R1 native run"); + assert_eq!(answer_native(&r8), want, "R1 native, n={n}"); + assert_eq!( + r8.programs_run, r1.programs_run, + "R1 native programs, n={n}" + ); let p5 = d5.programs_run(); let (p6, p7) = (d6.programs_run(), d7.programs_run()); @@ -445,7 +468,7 @@ fn main() { // Every timed call is checked: `ok[i]` counts calls of arm `i` that // returned the oracle's answer, and must equal the call count. An // error or a wrong value is a failed sample, never a skipped one. - let mut ok = [0usize; 7]; + let mut ok = [0usize; 8]; res.push(measure(reps, iters, || { let v = black_box(probe_direct(black_box(&x), black_box(t), &mut m)); ok[0] += usize::from(v == want); @@ -486,6 +509,18 @@ fn main() { let v = black_box(run(&plan, &bstore, &frames, now, &permit, fields, &mut d7)); ok[6] += usize::from(matches!(&v, Ok(r) if answer(r) == want)); })); + res.push(measure(reps, iters, || { + let v = black_box(run_native( + black_box(&np), + &bstore, + &frames, + now, + &permit, + &planes, + &mut ns8, + )); + ok[7] += usize::from(matches!(&v, Ok(r) if answer_native(r) == want)); + })); for (i, (r, &good)) in res.iter().zip(&ok).enumerate() { assert_eq!( good, r.calls, @@ -505,6 +540,13 @@ fn main() { assert_eq!(op.eval(&mut d6, &mut stack6), Ok(want)); let again = run(&plan, &bstore, &frames, now, &permit, fields, &mut d7).expect("R1 run"); assert_eq!(answer(&again), want, "R1 run after timing, n={n}"); + let again8 = run_native(&np, &bstore, &frames, now, &permit, &planes, &mut ns8) + .expect("R1 native run"); + assert_eq!( + answer_native(&again8), + want, + "R1 native after timing, n={n}" + ); time_rows.push((n, res.iter().map(|r| r.ns).collect::>())); alloc_rows.push((n, res.iter().map(|r| r.allocs).collect::>())); From 9e6e848137da6dab37a8a6b9e5df24ec8aad4345 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 18:33:25 +0000 Subject: [PATCH 08/10] board: R1 native successor measured Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- ...2026-10-31-r1-native-prepared-successor.md | 29 +++++++++++++++++++ .claude/board/entries/README.md | 3 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .claude/board/entries/2026-10-31-r1-native-prepared-successor.md diff --git a/.claude/board/entries/2026-10-31-r1-native-prepared-successor.md b/.claude/board/entries/2026-10-31-r1-native-prepared-successor.md new file mode 100644 index 000000000..285c2545d --- /dev/null +++ b/.claude/board/entries/2026-10-31-r1-native-prepared-successor.md @@ -0,0 +1,29 @@ +# R1 successor: native prepared plans over CompiledProgram + +**Date 2026-10-31 · Status MEASURED (lab) · Branch `ccr-85acaff0-oszq40-r1b` (successor to #1892)** + +`binding_native::prepare_native` runs a `binding::Prepared` plan once through `FoldDialect`, keeps each fold's program as a mask-risc `CompiledProgram` in step order, and records the output layout. `run_native` checks guards, route and permit exactly as `run` does, then runs the programs with `execute_compiled` and fills a fixed-size receipt. No interpreter runs, nothing is allocated, and there is no per-run `BTreeMap`. Plans that compiling once would freeze are refused by name: +- `FoldResultFeedsFold`: a fold fed by an earlier fold's result; +- `ScalarAfterFold`: a scalar computed after a fold; +- `DataScalarNotPrepared`: a fold-free scalar read from the planes; +- `TooManyOutputs`; +- `PlanesChanged`. + +`ExecPermit` stays a lab permission, not a production authorization. + +Benchmark (`r1_vs_prebound`, `MPF_REPS=31`, 4 cores). Median ns per call from the clean run of three; the other two were disturbed by VM load, one doubling several arms. Every timed call is checked against the oracle. + +| rows | mask-risc prepared | R2IL prepared | R1 run | R1 native | +|---:|---:|---:|---:|---:| +| 64 | 105.5 | 107.2 | 405.8 | 164.2 | +| 512 | 204.0 | 207.0 | 499.1 | 256.0 | +| 4096 | 1086.9 | 1089.1 | 1435.8 | 1139.7 | +| 65536 | 18670.8 | 18812.1 | 19039.0 | 18726.3 | + +Allocations per call: R1 native 0, R1 run 5. + +R1 native sits about 50–60 ns above prepared mask-risc at every size, so the gap is constant and does not grow with rows. The guard check alone measures about 30 ns (prebound vs prebound+guard), and route lookup and the permit scan are the rest. This split is a working model, not separately timed. + +OPEN: +- plans that compute a scalar from a fold result (`ORDER_A`, `ORDER_B`, `SUB_*`) are refused, not executed natively; +- the permit check is a linear scan. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 8c027cda1..84244c237 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,12 +25,13 @@ 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. -527 entries, 2026-08-06 .. 2026-10-31. +528 entries, 2026-08-06 .. 2026-10-31. | date | entry id | finding | file | |---|---|---|---| | 2026-10-31 | `x2-dt64-admission-policy` | | [2026-10-31-x2-dt64-admission-policy.md](2026-10-31-x2-dt64-admission-policy.md) | | 2026-10-31 | `r1-r2il-binding-reading` | | [2026-10-31-r1-r2il-binding-reading.md](2026-10-31-r1-r2il-binding-reading.md) | +| 2026-10-31 | `r1-native-prepared-successor` | | [2026-10-31-r1-native-prepared-successor.md](2026-10-31-r1-native-prepared-successor.md) | | 2026-10-31 | `D-XLOG-NATIVE-6` | | [2026-10-31-d-xlog-native-6-ndarray-simd-execution.md](2026-10-31-d-xlog-native-6-ndarray-simd-execution.md) | | 2026-10-31 | `D-XLOG-NATIVE-5` | | [2026-10-31-d-xlog-native-5-bindstore-epi5.md](2026-10-31-d-xlog-native-5-bindstore-epi5.md) | | 2026-10-31 | `D-SAG-6-2-JOIN-IDENT` | | [2026-10-31-d-sag-6-2-span-join-identity.md](2026-10-31-d-sag-6-2-span-join-identity.md) | From b390cb713db55f9f32d38f5e825312e4a377b9a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 19:08:10 +0000 Subject: [PATCH 09/10] r2il binding: keep AND operands that are read again; clear pending dialect state per run Two review findings on the binding layer, both reproduced first: - IntAnd's survivor gating rewrites the last predicate in place, so a field that is the right operand of an AND and is read again later changed meaning. m = a<5; t = a<10; u = m AND t; s = POP_COUNT(t) counted a<5. The dialect gains call_shared, which emits a separate And; binding::call_step uses it when an operand field is read by a later step, in run and prepare_native. Stack programs that consume their operands keep the gating. - run left a plan's unconsumed predicates in the caller's dialect, so repeated predicate-only plans grew its ops until a later fold failed with ScratchBufferTooSmall. FoldDialect::discard_pending drops them and advances the epoch; run calls it before and after the steps, on success and error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- crates/lance-graph-r2il/src/binding.rs | 41 +++++++++- crates/lance-graph-r2il/src/binding_native.rs | 5 +- crates/lance-graph-r2il/src/lib.rs | 44 +++++++++++ .../tests/integration/binding.rs | 74 ++++++++++++++++++- .../tests/integration/binding_native.rs | 32 +++++++- 5 files changed, 188 insertions(+), 8 deletions(-) diff --git a/crates/lance-graph-r2il/src/binding.rs b/crates/lance-graph-r2il/src/binding.rs index d666ed47f..e721a7df3 100644 --- a/crates/lance-graph-r2il/src/binding.rs +++ b/crates/lance-graph-r2il/src/binding.rs @@ -325,6 +325,20 @@ pub fn run( if !permit.methods.contains(&plan.method) { return Err(ExecRefusal::NotPermitted); } + dialect.discard_pending(); + let result = run_steps(plan, frames, fields, dialect); + dialect.discard_pending(); + result +} + +/// [`run`]'s body after the checks; [`run`] clears the dialect's pending +/// state on both sides of it, on success and on error. +fn run_steps( + plan: &Prepared, + frames: &FrameStore, + fields: FieldLanes<'_>, + dialect: &mut FoldDialect<'_>, +) -> Result { let programs_before = dialect.programs_run(); let mut values: BTreeMap = BTreeMap::new(); let mut stack: Vec = Vec::new(); @@ -342,8 +356,7 @@ pub fn run( if LANE_COMPARES.contains(&step.opcode) { check_compare(&stack, i)?; } - dialect - .call(step.opcode, [0, 0, 0], &mut stack) + call_step(dialect, &plan.steps, i, &mut stack) .map_err(|error| ExecRefusal::Fold { effect: i, error })?; if stack.len() != 1 { return Err(ExecRefusal::ResultArity { @@ -374,6 +387,30 @@ pub fn run( }) } +/// Call step `i`. A field a later step reads again keeps its value, so a +/// step that takes one as an operand goes through +/// [`FoldDialect::call_shared`], which never gates that operand's predicate +/// in place. +pub(crate) fn call_step( + dialect: &mut FoldDialect<'_>, + steps: &[Step], + i: usize, + stack: &mut Vec, +) -> Result<(), FoldError> { + let step = &steps[i]; + let read_later = step.args.iter().any(|a| match a { + Arg::Field(p) => steps[i + 1..] + .iter() + .any(|s| s.args.contains(&Arg::Field(*p))), + Arg::Const(_) => false, + }); + if read_later { + dialect.call_shared(step.opcode, [0, 0, 0], stack) + } else { + dialect.call(step.opcode, [0, 0, 0], stack) + } +} + /// The route at the captured version, and now, must both name `class`. pub(crate) fn check_route( store: &BindStore, diff --git a/crates/lance-graph-r2il/src/binding_native.rs b/crates/lance-graph-r2il/src/binding_native.rs index 1e414575c..9eba6e344 100644 --- a/crates/lance-graph-r2il/src/binding_native.rs +++ b/crates/lance-graph-r2il/src/binding_native.rs @@ -48,7 +48,7 @@ use lance_graph_mask_risc::{ execute_compiled, scratch_words_for, tile_words_for, CompiledProgram, Foreign, Out, Planes, Scratch, Terminal, Value, }; -use ogar_loco::{Dialect, FnIndex}; +use ogar_loco::FnIndex; use ogar_r2il::{ALL, ANY, KEY_RUNS, MAX, MIN, SUM}; use crate::binding::{ @@ -243,8 +243,7 @@ pub fn prepare_native( check_compare(&stack, i)?; } let before = dialect.programs_run(); - dialect - .call(step.opcode, [0, 0, 0], &mut stack) + crate::binding::call_step(&mut dialect, &plan.steps, i, &mut stack) .map_err(|error| ExecRefusal::Fold { effect: i, error })?; if stack.len() != 1 { return Err(ExecRefusal::ResultArity { diff --git a/crates/lance-graph-r2il/src/lib.rs b/crates/lance-graph-r2il/src/lib.rs index 501d704e6..ea2787152 100644 --- a/crates/lance-graph-r2il/src/lib.rs +++ b/crates/lance-graph-r2il/src/lib.rs @@ -347,6 +347,10 @@ pub struct FoldDialect<'p> { /// Fused mode only: where each pending expression was lowered to, once a /// consumer needed it, so a second consumer reuses the same ops. lowered: Vec>, + /// Set only for the duration of [`Self::call_shared`]: the operands of + /// the call stay readable afterwards, so `IntAnd` must not gate the last + /// predicate in place. + keep_operands: bool, } impl<'p> FoldDialect<'p> { @@ -377,6 +381,7 @@ impl<'p> FoldDialect<'p> { mode: BoolMode::InPlace, exprs: Vec::new(), lowered: Vec::new(), + keep_operands: false, } } @@ -498,6 +503,40 @@ impl<'p> FoldDialect<'p> { self.mode } + /// [`Dialect::call`] for a caller that reads the operands again after + /// the call. `IntAnd` normally gates the last emitted predicate in place + /// under the other operand (survivor gating), which changes what that + /// predicate's slot holds; here it emits a separate `And` instead. A + /// stack program that consumes its operands keeps using `call`. + pub fn call_shared( + &mut self, + f: FnIndex, + v: [u8; 3], + stack: &mut Vec, + ) -> Result<(), FoldError> { + self.keep_operands = true; + let r = self.call(f, v, stack); + self.keep_operands = false; + r + } + + /// Drop the predicates and expressions accumulated since the last fold + /// and advance the epoch, so a slot minted before this call is refused + /// ([`FoldError::StaleSlot`]). A caller that runs independent programs on + /// one dialect calls this at each boundary, so one program's unconsumed + /// predicates neither grow the next program nor take its scratch slots. + pub fn discard_pending(&mut self) { + if self.ops.is_empty() && self.exprs.is_empty() && self.next_slot == 0 { + return; + } + self.ops.clear(); + self.next_slot = 0; + self.free.clear(); + self.epoch += 1; + self.exprs.clear(); + self.lowered.clear(); + } + /// The expression a mask operand stands for, for the fused mode. fn expr_of(&self, v: Val) -> Result { match v { @@ -649,6 +688,11 @@ impl<'p> FoldDialect<'p> { /// was produced by the op emitted LAST and that op is an ungated `Pred`, /// gate it under `a` instead of spending a facade pass on `And`. fn and_peephole(&mut self, a: Operand, b: Operand) -> u16 { + if self.keep_operands { + let dst = self.fresh(); + self.ops.push(MaskOp::And { a, b, dst }); + return dst; + } if let ( Operand::Scratch(bs), Some(MaskOp::Pred { diff --git a/crates/lance-graph-r2il/tests/integration/binding.rs b/crates/lance-graph-r2il/tests/integration/binding.rs index 622a14e8a..619fbb20d 100644 --- a/crates/lance-graph-r2il/tests/integration/binding.rs +++ b/crates/lance-graph-r2il/tests/integration/binding.rs @@ -47,7 +47,7 @@ const LANES: [Option; 8] = [Some(0), Some(1), Some(2), None, None, None, No /// The lens's words, deliberately NOT at their opcode numbers: only the /// label binds a word to an opcode. -const WORDS: [&str; 9] = [ +const WORDS: [&str; 10] = [ "SUM", "IntSLess", "IntSub", @@ -57,6 +57,7 @@ const WORDS: [&str; 9] = [ "IntEqual", "GROUP_SUM", "NotAnOpcode", + "IntAnd", ]; const W_SUM: u8 = 0; const W_LT: u8 = 1; @@ -66,6 +67,7 @@ const W_MUL: u8 = 4; const W_POP: u8 = 5; const W_GSUM: u8 = 7; const W_BAD: u8 = 8; +const W_AND: u8 = 9; const FOCUS: Focus = Focus { revision: 1 }; const SCOPE: Scope = Scope { id: 1 }; @@ -172,6 +174,8 @@ const SINK_WORD: u32 = 10; // GROUP_SUM const GUARDED: u32 = 11; const THREE_VALUED: u32 = 12; const COUNT: u32 = 13; // POP_COUNT of the mask +const SHARED_AND: u32 = 14; // m = a < 5; t = a < 10; u = m AND t; s = POP_COUNT(t) +const PRED_ONLY: u32 = 15; // m = a < 5, consumed by no fold fn methods() -> Vec { let with = |id, extra: Vec| { @@ -249,6 +253,21 @@ fn methods() -> Vec { step(4, W_POP, vec![Arg::Field(3)]), ], ), + method( + SHARED_AND, + &[0], + vec![ + step(3, W_LT, vec![Arg::Field(0), Arg::Const(5)]), + step(5, W_LT, vec![Arg::Field(0), Arg::Const(10)]), + step(6, W_AND, vec![Arg::Field(3), Arg::Field(5)]), + step(4, W_POP, vec![Arg::Field(5)]), + ], + ), + method( + PRED_ONLY, + &[0], + vec![step(3, W_LT, vec![Arg::Field(0), Arg::Const(5)])], + ), ] } @@ -763,3 +782,56 @@ fn work_per_run_does_not_grow_with_rows() { ] ); } + +#[test] +fn an_and_operand_read_again_keeps_its_own_mask() { + // `t` is the right operand of `u = m AND t` and is read again by + // `POP_COUNT(t)`. Gating `t`'s predicate under `m` in place would make the + // count `count_below(5)` instead of `count_below(10)`. + let f = Fixture::new(); + let data = Data::new(64); + assert_ne!( + data.count_below(5), + data.count_below(10), + "the fixture separates the two" + ); + let plan = f.prepare(SHARED_AND).unwrap(); + let r = exec(&f, &plan, &data, &permit(&[SHARED_AND]), NOW).unwrap(); + assert_eq!(values(&r), vec![(4, data.count_below(10))]); +} + +#[test] +fn a_run_leaves_no_pending_predicates_in_the_dialect() { + // A plan whose predicates no fold consumes must not leave their ops in the + // caller's dialect: after more such runs than there are scratch slots, a + // folding plan on the same dialect still runs and counts only its own mask. + let f = Fixture::new(); + let data = Data::new(64); + let pred_only = f.prepare(PRED_ONLY).unwrap(); + let count = f.prepare(COUNT).unwrap(); + let ok = permit(&[PRED_ONLY, COUNT]); + let lanes = [ + LaneRef::I32(&data.a), + LaneRef::I32(&data.b), + LaneRef::U32(&data.k), + ]; + let planes = Planes { + n_rows: data.a.len(), + masks: &[], + lanes: &lanes, + }; + let foreign = Foreign::NONE; + let mut d = FoldDialect::new(&planes, &foreign); + let fl = FieldLanes { lanes: &LANES }; + for _ in 0..=lance_graph_r2il::MAX_SLOTS { + let r = run(&pred_only, &f.store, &f.frames, NOW, &ok, fl, &mut d).unwrap(); + assert_eq!(r.programs_run, 0); + } + let r = run(&count, &f.store, &f.frames, NOW, &ok, fl, &mut d).unwrap(); + assert_eq!(values(&r), vec![(4, data.count_below(5))]); + assert_eq!( + d.last_program().unwrap().ops.len(), + 1, + "only COUNT's own predicate" + ); +} diff --git a/crates/lance-graph-r2il/tests/integration/binding_native.rs b/crates/lance-graph-r2il/tests/integration/binding_native.rs index 8d59396eb..81689fde8 100644 --- a/crates/lance-graph-r2il/tests/integration/binding_native.rs +++ b/crates/lance-graph-r2il/tests/integration/binding_native.rs @@ -27,7 +27,7 @@ use lance_graph_r2il::binding::{ use lance_graph_r2il::binding_native::{ prepare_native, run_native, NativePlan, NativeReceipt, NativeScratch, MAX_NATIVE_OUTPUTS, }; -use lance_graph_r2il::{FoldDialect, FoldError, INT_ADD, INT_S_LESS, POP_COUNT}; +use lance_graph_r2il::{FoldDialect, FoldError, INT_ADD, INT_AND, INT_S_LESS, POP_COUNT}; use ogar_loco::{FnIndex, Vocabulary}; use ogar_r2il::{R2ILVocabulary, ANY, CONCEPT_R2IL_FOLD, MAX, MIN, SUM}; @@ -98,7 +98,8 @@ const W_ADD: u8 = 3; const W_MIN: u8 = 4; const W_MAX: u8 = 5; const W_ANY: u8 = 6; -const WORD_OPS: [FnIndex; 7] = [INT_S_LESS, SUM, POP_COUNT, INT_ADD, MIN, MAX, ANY]; +const W_AND: u8 = 7; +const WORD_OPS: [FnIndex; 8] = [INT_S_LESS, SUM, POP_COUNT, INT_ADD, MIN, MAX, ANY, INT_AND]; fn words() -> Vec<&'static str> { WORD_OPS @@ -195,6 +196,7 @@ const FEEDS: u32 = 12; // a compare constant that is a fold result const FEEDS_OK: u32 = 13; // the same shape with a literal constant const NINE_OUT: u32 = 14; // nine scalar fields const EIGHT_OUT: u32 = 15; // eight scalar fields +const SHARED_AND: u32 = 16; // f3 = a < 5; f5 = a < 10; f6 = f3 AND f5; f4 = POP_COUNT(f5) fn methods() -> Vec { let masked_sum = || vec![lt(3, 5), step(4, W_SUM, vec![Arg::Field(3), Arg::Field(1)])]; @@ -293,6 +295,15 @@ fn methods() -> Vec { ), method(NINE_OUT, consts(9)), method(EIGHT_OUT, consts(8)), + method( + SHARED_AND, + vec![ + lt(3, 5), + lt(5, 10), + step(6, W_AND, vec![Arg::Field(3), Arg::Field(5)]), + step(4, W_POP, vec![Arg::Field(5)]), + ], + ), ] } @@ -826,3 +837,20 @@ fn run_native_allocates_nothing() { assert!(allocs() > before, "the counter must see `run` allocate"); } } + +// Fails if preparing `f6 = f3 AND f5` gates `f5`'s predicate under `f3` in +// place: the later `POP_COUNT(f5)` would then count `a < 5`, not `a < 10`. +#[test] +fn native_keeps_an_and_operand_that_is_read_again() { + let f = Fixture::new(); + let data = Data::new(N); + assert_ne!( + data.count(5), + data.count(10), + "the fixture separates the two" + ); + assert_eq!( + native_answer(&f, SHARED_AND, &data).values(), + &[(4, data.count(10))] + ); +} From 7351bf2a7be9ca8b912f2d71efc0d82ddd202c12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 19:14:00 +0000 Subject: [PATCH 10/10] r2il binding: drop the Dialect import that main's inherent call makes unused Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- crates/lance-graph-r2il/src/binding.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/lance-graph-r2il/src/binding.rs b/crates/lance-graph-r2il/src/binding.rs index e721a7df3..ae37cf49f 100644 --- a/crates/lance-graph-r2il/src/binding.rs +++ b/crates/lance-graph-r2il/src/binding.rs @@ -71,7 +71,7 @@ use lance_graph_dt64::{ AlgebraRef, Arg, BindStore, Effect, MethodRef, Refusal, RouteVersion, SchemaRev, }; use lance_graph_mask_risc::{LaneKind, LaneRef, Planes}; -use ogar_loco::{Dialect, FnIndex, Vocabulary}; +use ogar_loco::{FnIndex, Vocabulary}; use ogar_r2il::{R2ILVocabulary, ALL, ANY, CONCEPT_R2IL_FOLD, KEY_RUNS, MAX, MIN, SUM}; use crate::{