diff --git a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md index 4c2be5fbd..699ae5cce 100644 --- a/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md +++ b/.claude/plans/d-dt64-6-routing-bindstore-behavior-v1.md @@ -391,3 +391,19 @@ Disable runs on the `dr` commit, each turning a test red: | `LocalMap` width read as 64 | 1 | | address width read as 64 | 1 | | a `use crate::` in `dr.rs` (the boundary test) | 1 | + +## A.6 (2026-11-07) L5c: method references are issued, stamped and tied to their store + +Ruling: Linear EXO-199. D1: the classid stays the class; no class versioning. D2: a cloned `BindStore` is another store. + +Contract (`lance-graph-dt64`): +- `BindStore` has a private identity (`Arc<()>`). `Clone` is written by hand and mints a fresh identity, so a clone refuses the original's references and the original refuses the clone's. `Default` is still derived (`Arc::default` is fresh). +- At registration the store keeps one binding digest per method: FNV-1a 128 with the basis-witness constants under the tag `dt64.binding.v1`, over the classid, the basis witness, the method id, the signature revision, ON and reads positions, the ordered effects (target, lens, word, the lens's schema revision, the word's arity, tagged arguments), and population, truth and value demand. The source `name` and the guard are not in it; `admit` compares the guard witness structurally. +- `MethodRef` gains one public field, `stamp: Stamp`. `Stamp` has private fields (`issuer: Weak<()>`, `binding: u128`), no public constructor, a read-only `binding()`, and `PartialEq`/`Eq`/`Hash` by issuer pointer and digest. Struct-update forgeries still compile; they can only copy a stamp the store issued, and keep their refusals. +- `MethodRef::of` is removed. `BindStore::method_ref(class, id)` is the only issuer. `BindStore::check_issuer(r)` compares the issuer alone. +- `admit` runs its existing checks first and unchanged, then compares the stamp's digest with the STORED one (`Refusal::BindingMismatch`), then the issuer (`Refusal::ForeignStore`). Only this order lets both fire: the store never rebinds (D5), so with the issuer first a digest mismatch would never be reported. + +Contract (`lance-graph-r2il`): +- `run`, `prepare_native` and `run_native` check that `store` issued the plan's method reference before anything else, refusing with `ExecRefusal::Binding(Refusal::ForeignStore)`. `prepare` gets the same check through `admit`. +- `Prepared`'s fields are `pub(crate)`; outside the crate it is read through `method()`, `lens()`, `route()`, `guards()`, `steps()` and `plan_id()`. A `compile_fail` doctest pins that `steps` cannot be edited outside the crate. +- `plan_id` also hashes the stamp's binding digest. It does not hash the lane kind: lanes are bound per run (`FieldLanes`), never on `Prepared` or a Program (C5), so one plan may still read an I32 lane on one run and a U32 lane on another. That gap stays open. diff --git a/crates/dt64-admission-probe/src/fixture.rs b/crates/dt64-admission-probe/src/fixture.rs index d991edf0e..3c3c586e0 100644 --- a/crates/dt64-admission-probe/src/fixture.rs +++ b/crates/dt64-admission-probe/src/fixture.rs @@ -215,16 +215,18 @@ impl ClassView for FixtureView { } /// The faithful reference to a bound method. +/// The store issues it; the fixture checks that it is the reference it +/// expects (signature revision 1, guarded only for `COUNT_BELOW`) and that +/// the store admits it. pub fn method_ref(store: &BindStore, id: MethodId) -> MethodRef { - let b = store - .admit(&MethodRef { - class: CLASS, - method: id, - sig_rev: SigRev(1), - guard: (id == COUNT_BELOW).then(|| binding(id, "").when.unwrap().witness()), - }) - .expect("bound"); - MethodRef::of(CLASS, b) + let r = store.method_ref(CLASS, id).expect("bound"); + assert_eq!(r.sig_rev, SigRev(1)); + assert_eq!( + r.guard, + (id == COUNT_BELOW).then(|| binding(id, "").when.unwrap().witness()) + ); + store.admit(&r).expect("admitted"); + r } fn attachments() -> Vec { diff --git a/crates/grammar-constraints-probe/examples/when_cost.rs b/crates/grammar-constraints-probe/examples/when_cost.rs index f06b3c687..478b29f03 100644 --- a/crates/grammar-constraints-probe/examples/when_cost.rs +++ b/crates/grammar-constraints-probe/examples/when_cost.rs @@ -8,7 +8,7 @@ use std::time::Instant; use grammar_constraints_probe::fixtures; use grammar_constraints_probe::when::*; use grammar_constraints_probe::{tile, Ids, Vocab}; -use lance_graph_dt64::{BindStore, MethodRef}; +use lance_graph_dt64::BindStore; fn ns(f: impl FnOnce() -> T) -> (T, u128) { let t = Instant::now(); @@ -54,7 +54,9 @@ fn main() { let mut composed = Vec::new(); let mut t_comp = 0; for d in &rules { - let r = MethodRef::of(class, &d.binding(phys, &v, &lex)); + let r = store + .method_ref(class, d.binding(phys, &v, &lex).id) + .unwrap(); let (c, t) = ns(|| compose(&store, &r, d, phys, &v, &lex).unwrap()); t_comp += t; composed.push(c); diff --git a/crates/grammar-constraints-probe/tests/when.rs b/crates/grammar-constraints-probe/tests/when.rs index 4c7a01c77..c818e879f 100644 --- a/crates/grammar-constraints-probe/tests/when.rs +++ b/crates/grammar-constraints-probe/tests/when.rs @@ -12,7 +12,7 @@ use grammar_constraints_probe::{ use lance_graph_contract::set_algebra::{Logic, SetExpr}; use lance_graph_dt64::dr::{Frame, MapRead, MapRefusal, MapTable}; use lance_graph_dt64::{ - AlgebraRef, Arg, BindStore, Effect, Extent, Guard, MethodRef, Refusal, SchemaRev, + AlgebraRef, Arg, BindStore, Effect, Extent, Guard, MethodBinding, MethodRef, Refusal, SchemaRev, }; thread_local! { static BYTES: Cell = const { Cell::new(0) }; } @@ -79,7 +79,19 @@ fn class_of(p: Physical) -> u32 { } fn mref(w: &W, class: u32, desc: &RuleDesc, p: Physical) -> MethodRef { - MethodRef::of(class, &desc.binding(p, &w.v, &w.lex)) + stamped(&w.store, class, &desc.binding(p, &w.v, &w.lex)) +} + +/// The reference `binding` describes (its id, signature revision and guard +/// witness), carrying the stamp `store` issues for that method id. The +/// description may differ from what the store bound; `admit` and `compose` +/// then refuse exactly as they did before references were stamped. +fn stamped(store: &BindStore, class: u32, binding: &MethodBinding) -> MethodRef { + MethodRef { + sig_rev: binding.sig_rev, + guard: binding.when.as_ref().map(Guard::witness), + ..store.method_ref(class, binding.id).unwrap() + } } fn all_plane(w: &W) -> Vec { @@ -235,7 +247,7 @@ fn candidates_are_read_only() { store .register(&LabView::new(), CLASS_COADDR, schema(), vec![b.clone()]) .unwrap(); - let r = MethodRef::of(CLASS_COADDR, &b); + let r = stamped(&store, CLASS_COADDR, &b); assert_eq!( compose( &store, @@ -284,7 +296,8 @@ fn n01_remove_head_frame() { &w.lex, ) .unwrap(); - let r = MethodRef::of( + let r = stamped( + &store, CLASS_COADDR, &d.binding(Physical::CoAddressed, &w.v, &w.lex), ); @@ -477,12 +490,17 @@ fn n06b_resealed_same_size_layout() { #[test] fn n07_foreign_class() { let w = world(&A); - let r = mref( - &w, - CLASS_FOREIGN, - &RuleDesc::recipient(), - Physical::CoAddressed, - ); + // The store issues no reference for a class it does not bind, so the + // foreign classid is put on a reference it issued for another class. + let r = MethodRef { + class: CLASS_FOREIGN, + ..mref( + &w, + CLASS_COADDR, + &RuleDesc::recipient(), + Physical::CoAddressed, + ) + }; assert_eq!( compose( &w.store, @@ -528,7 +546,8 @@ fn n08_remove_operand() { &w.lex, ) .unwrap(); - let r = MethodRef::of( + let r = stamped( + &store, CLASS_COADDR, &d.binding(Physical::CoAddressed, &w.v, &w.lex), ); diff --git a/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs b/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs index d00c5e159..e1b096fe1 100644 --- a/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs +++ b/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs @@ -503,7 +503,7 @@ fn dt64_method_pointer_is_not_a_coordinate_or_an_excel_value() { let mut dt = BindStore::new(); dt.register(&view, class, schema, vec![method.clone()]) .unwrap(); - let pointer = MethodRef::of(class, &method); + let pointer = dt.method_ref(class, method.id).unwrap(); assert_eq!(dt.admit(&pointer).unwrap(), &method); let before = dt.route(0, class).unwrap(); assert_eq!(dt.routed_at(before, 0).unwrap(), Some(class)); @@ -1231,7 +1231,10 @@ fn bound_property_lanes_fold_under_the_ruler_selection() { assert!(binding.fields().has(DEPARTMENT) && binding.fields().has(COST_CENTER)); // Operation 1: the admitted method's `reads` names the lane it folds. - let m1 = MethodRef::of(HYBRID_USER, &method(1, DEPARTMENT)); + let m1 = b + .store + .method_ref(HYBRID_USER, method(1, DEPARTMENT).id) + .unwrap(); let admitted = b.store.admit(&m1).unwrap(); assert!(admitted.reads.has(DEPARTMENT) && binding.fields().has(DEPARTMENT)); let sum = lowered(confirmed_hybrid(), Agg::SumI32(Col(0))); @@ -1249,7 +1252,10 @@ fn bound_property_lanes_fold_under_the_ruler_selection() { let got = execute_into(&sum, &inputs, &Foreign::NONE, &mut scratch, Out::None).unwrap(); // Operation 2, same binding: confirmed ∧ cost_center > 150, counted. - let m2 = MethodRef::of(HYBRID_USER, &method(2, COST_CENTER)); + let m2 = b + .store + .method_ref(HYBRID_USER, method(2, COST_CENTER).id) + .unwrap(); assert!(b.store.admit(&m2).unwrap().reads.has(COST_CENTER)); let count = lowered( Filter::and([confirmed_hybrid(), Filter::cmp(Col(0), Cmp::GtI32(150))]), diff --git a/crates/lance-graph-dt64/src/lib.rs b/crates/lance-graph-dt64/src/lib.rs index 9562eaa7a..2f13bdaf7 100644 --- a/crates/lance-graph-dt64/src/lib.rs +++ b/crates/lance-graph-dt64/src/lib.rs @@ -86,6 +86,8 @@ #![forbid(unsafe_code)] use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Weak}; use lance_graph_contract::class_view::{is_behaviour_lens, ClassId, ClassView, WideFieldMask}; use lance_graph_contract::ogar_codebook::classid_concept; @@ -270,6 +272,11 @@ struct ClassEntry { /// The witness of the ordered field basis the methods were bound against /// (Decision J): read from the source at registration, never a copy. basis: BasisDigest, + /// Each method's binding digest ([`binding_digest`]), in the order of + /// `methods`. Computed once at registration; [`BindStore::admit`] compares + /// a reference's stamp against this stored value, never against one the + /// reference carries in. + bindings: Vec, } /// The witness of one ordered field basis (D-DT64-7 Decision J, O10's @@ -330,26 +337,129 @@ pub enum BasisSource { } /// A reference a router (DR #4) carries to a method: the class, the method -/// identity, the signature revision it was made against, and the witness of -/// the guard it kept. +/// identity, the signature revision it was made against, the witness of the +/// guard it kept, and the [`Stamp`] of the store that issued it. +/// +/// Only [`BindStore::method_ref`] issues one. The stamp's fields are private, +/// so a reference built elsewhere can only copy the stamp of a reference that +/// store issued; [`BindStore::admit`] still checks every other field against +/// the binding. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct MethodRef { pub class: u32, pub method: MethodId, pub sig_rev: SigRev, pub guard: Option, + pub stamp: Stamp, } -impl MethodRef { - /// The faithful reference to `binding` in `class`. - pub fn of(class: u32, binding: &MethodBinding) -> MethodRef { - MethodRef { - class, - method: binding.id, - sig_rev: binding.sig_rev, - guard: binding.when.as_ref().map(Guard::witness), +/// Which store issued a [`MethodRef`] and which binding it was issued for. +/// +/// `issuer` points at the issuing [`BindStore`]'s identity; `binding` is the +/// binding digest the store held for the method when it issued the reference. +/// Neither can be set outside this crate: +/// +/// ```compile_fail +/// let _ = lance_graph_dt64::Stamp { issuer: std::sync::Weak::new(), binding: 0 }; +/// ``` +#[derive(Debug, Clone)] +pub struct Stamp { + issuer: Weak<()>, + binding: u128, +} + +impl Stamp { + /// The binding digest the issuing store held for the method. Read-only: + /// a consumer may hash it into its own identities (R2IL's `plan_id`), but + /// [`BindStore::admit`] never trusts it over the stored one. + pub fn binding(&self) -> u128 { + self.binding + } +} + +impl PartialEq for Stamp { + fn eq(&self, other: &Stamp) -> bool { + Weak::ptr_eq(&self.issuer, &other.issuer) && self.binding == other.binding + } +} + +impl Eq for Stamp {} + +impl Hash for Stamp { + fn hash(&self, state: &mut H) { + Weak::as_ptr(&self.issuer).hash(state); + self.binding.hash(state); + } +} + +/// The digest of one method's binding in its class: the classid, the class's +/// basis witness, the method identity and signature revision, ON and reads, +/// the ordered effects with their tagged arguments, each effect's lens +/// revision and word arity, and the population, truth and value demand. +/// +/// FNV-1a 128 with [`BasisDigest`]'s constants under the domain tag +/// `dt64.binding.v1`; every variable-length part is length-prefixed. The +/// source `name` is not part of it (it is never identity), and neither is the +/// guard: [`BindStore::admit`] compares the guard witness structurally. Like +/// the basis witness, it detects a different binding; it does not +/// authenticate one. +fn binding_digest( + class: u32, + basis: BasisDigest, + m: &MethodBinding, + algebra: &BTreeMap, +) -> u128 { + let eat = BasisDigest::eat; + let mut h = BasisDigest::OFFSET; + h = eat(h, b"dt64.binding.v1"); + h = eat(h, &class.to_le_bytes()); + h = eat(h, &basis.0.to_le_bytes()); + h = eat(h, &m.id.0.to_le_bytes()); + h = eat(h, &m.sig_rev.0.to_le_bytes()); + for mask in [&m.on, &m.reads] { + let positions: Vec = mask_positions(mask).collect(); + h = eat(h, &(positions.len() as u64).to_le_bytes()); + h = eat(h, &positions); + } + h = eat(h, &(m.effects.len() as u64).to_le_bytes()); + for e in &m.effects { + h = eat(h, &[e.target]); + h = eat(h, &e.algebra.lens.to_le_bytes()); + h = eat(h, &[e.algebra.word]); + // Registration has already refused an effect whose lens or word is + // unbound, so both are present here. + let lens = algebra.get(&e.algebra.lens); + let rev = lens.map_or(u32::MAX, |l| l.schema.schema_rev.0); + let arity = lens + .and_then(|l| l.arity.get(usize::from(e.algebra.word))) + .copied() + .unwrap_or(u8::MAX); + h = eat(h, &rev.to_le_bytes()); + h = eat(h, &[arity]); + h = eat(h, &(e.args.len() as u64).to_le_bytes()); + for a in &e.args { + match a { + Arg::Field(p) => h = eat(h, &[0, *p]), + Arg::Const(c) => { + h = eat(h, &[1]); + h = eat(h, &c.to_le_bytes()); + } + } } } + let population = match m.population { + Population::Distinct => 0u8, + Population::Bag => 1, + }; + let truth = match m.truth { + Truth::TwoValued => 0u8, + Truth::ThreeValued => 1, + }; + let demand = match m.demand { + ValueDemand::MapOnly => 0u8, + ValueDemand::Values => 1, + }; + eat(h, &[population, truth, demand]) } /// Why the store refused. Every arm is a named UNKNOWN or conflict; none is an @@ -449,6 +559,14 @@ pub enum Refusal { GuardAdded { class: u32, method: MethodId }, /// The reference carries another guard than the bound one. GuardMismatch { class: u32, method: MethodId }, + /// The reference's stamp carries another binding digest than the one this + /// store holds for the method: it was issued for other behaviour (for + /// example, by another store that bound other effects under the same + /// classid and method identity). + BindingMismatch { class: u32, method: MethodId }, + /// The reference (or a plan prepared from it) was issued by another + /// store. A clone of a store is another store. + ForeignStore { class: u32 }, /// An effect names a lens that is not bound, or a position outside it. UnknownAlgebra { class: u32, @@ -640,13 +758,31 @@ impl<'s, 'v> Resolved<'s, 'v> { } /// The binding store. Building it takes `&mut self`; every read is `&self`. -#[derive(Debug, Clone, Default)] +/// +/// Each store has its own identity, which the [`Stamp`] of every +/// [`MethodRef`] it issues points at. A clone gets a fresh identity: it +/// refuses the original's references ([`Refusal::ForeignStore`]) and the +/// original refuses the clone's. +#[derive(Debug, Default)] pub struct BindStore { classes: BTreeMap, /// The bound polyfill lenses, by lens classid (D6). algebra: BTreeMap, /// DR's route log. The store binds; DR routes. routes: RouteLog, + /// The store's identity. Never shared: `Clone` mints a new one. + identity: Arc<()>, +} + +impl Clone for BindStore { + fn clone(&self) -> BindStore { + BindStore { + classes: self.classes.clone(), + algebra: self.algebra.clone(), + routes: self.routes.clone(), + identity: Arc::new(()), + } + } } impl BindStore { @@ -749,11 +885,16 @@ impl BindStore { schema_rev: schema.schema_rev, }); } + let bindings = methods + .iter() + .map(|m| binding_digest(class, basis, m, &self.algebra)) + .collect(); let entry = ClassEntry { schema, methods, derived_from, basis, + bindings, }; match self.classes.get(&class) { Some(bound) if *bound == entry => Ok(()), @@ -983,20 +1124,66 @@ impl BindStore { self.resolve(view, class, schema_rev) } + /// Issue the reference to method `method` of `class`: its signature + /// revision, its guard witness, and a [`Stamp`] naming this store and the + /// binding digest it holds for the method. The only way to obtain a + /// stamped reference. + pub fn method_ref(&self, class: u32, method: MethodId) -> Result { + let entry = self + .classes + .get(&class) + .ok_or(Refusal::UnknownClass(class))?; + let i = entry + .methods + .iter() + .position(|m| m.id == method) + .ok_or(Refusal::UnknownMethod { class, method })?; + let binding = &entry.methods[i]; + Ok(MethodRef { + class, + method, + sig_rev: binding.sig_rev, + guard: binding.when.as_ref().map(Guard::witness), + stamp: Stamp { + issuer: Arc::downgrade(&self.identity), + binding: entry.bindings[i], + }, + }) + } + + /// Whether this store issued `r`: [`Refusal::ForeignStore`] otherwise. + /// Only the issuer is compared; [`BindStore::admit`] checks the rest. + pub fn check_issuer(&self, r: &MethodRef) -> Result<(), Refusal> { + if Weak::as_ptr(&r.stamp.issuer) == Arc::as_ptr(&self.identity) { + Ok(()) + } else { + Err(Refusal::ForeignStore { class: r.class }) + } + } + /// Check a router's method reference against the binding and return the /// binding (D-DRDT §5): the method must exist, at the referenced signature - /// revision, with exactly the guard the reference witnesses. + /// revision, with exactly the guard the reference witnesses, and the + /// reference must have been issued by this store for this binding. + /// + /// The order is fixed: the identity, revision and guard checks first, + /// then the stamp's binding digest against the STORED one + /// ([`Refusal::BindingMismatch`]), then the stamp's issuer + /// ([`Refusal::ForeignStore`]). A store never rebinds a method (D5), so a + /// digest mismatch can only come from a reference issued elsewhere; with + /// the issuer checked first it would never be reported. pub fn admit(&self, r: &MethodRef) -> Result<&MethodBinding, Refusal> { let entry = self .classes .get(&r.class) .ok_or(Refusal::UnknownClass(r.class))?; let (class, method) = (r.class, r.method); - let binding = entry + let i = entry .methods .iter() - .find(|m| m.id == method) + .position(|m| m.id == method) .ok_or(Refusal::UnknownMethod { class, method })?; + let binding = &entry.methods[i]; if binding.sig_rev != r.sig_rev { return Err(Refusal::SignatureRevisionMismatch { class, @@ -1006,12 +1193,17 @@ impl BindStore { }); } match (&binding.when, &r.guard) { - (None, None) => Ok(binding), - (Some(_), None) => Err(Refusal::GuardDropped { class, method }), - (None, Some(_)) => Err(Refusal::GuardAdded { class, method }), - (Some(g), Some(w)) if g.witness() == *w => Ok(binding), - (Some(_), Some(_)) => Err(Refusal::GuardMismatch { class, method }), + (None, None) => {} + (Some(_), None) => return Err(Refusal::GuardDropped { class, method }), + (None, Some(_)) => return Err(Refusal::GuardAdded { class, method }), + (Some(g), Some(w)) if g.witness() == *w => {} + (Some(_), Some(_)) => return Err(Refusal::GuardMismatch { class, method }), + } + if entry.bindings[i] != r.stamp.binding { + return Err(Refusal::BindingMismatch { class, method }); } + self.check_issuer(r)?; + Ok(binding) } } diff --git a/crates/lance-graph-dt64/tests/bind_store.rs b/crates/lance-graph-dt64/tests/bind_store.rs index 0b271d415..fd5fa1649 100644 --- a/crates/lance-graph-dt64/tests/bind_store.rs +++ b/crates/lance-graph-dt64/tests/bind_store.rs @@ -318,8 +318,8 @@ fn same_read_write_sets_different_effect_order_stay_distinct_and_a_dropped_guard assert_eq!(r.methods_on(1).count(), 0, "field 1 triggers nothing"); // Each faithful reference admits its own binding with its own order. - let ra = MethodRef::of(ORDER, &a); - let rb = MethodRef::of(ORDER, &b); + let ra = s.method_ref(ORDER, a.id).unwrap(); + let rb = s.method_ref(ORDER, b.id).unwrap(); assert_eq!(s.admit(&ra).unwrap().effects[0].algebra, ASSIGN); assert_eq!(s.admit(&rb).unwrap().effects[0].algebra, ACCUMULATE); @@ -372,10 +372,10 @@ fn a_guard_added_to_an_unguarded_method_refuses() { ..method(30, vec![effect(2, ASSIGN)]) }; reg(&mut s, ORDER, schema(1, 4), vec![free.clone()]).unwrap(); - assert!(s.admit(&MethodRef::of(ORDER, &free)).is_ok()); + assert!(s.admit(&s.method_ref(ORDER, free.id).unwrap()).is_ok()); let added = MethodRef { guard: Some(Guard::new(SetExpr::All, Logic::TwoValued).witness()), - ..MethodRef::of(ORDER, &free) + ..s.method_ref(ORDER, free.id).unwrap() }; assert_eq!( s.admit(&added).unwrap_err(), @@ -478,17 +478,18 @@ fn an_equal_length_reorder_is_refused() { ); } -/// FINDING (L5c, not yet enforced): `admit` still takes a reference that -/// carries no class revision and no basis identity, so a router holding a +/// FINDING (still true after L5c): `admit` never reads the view. Since L5c a +/// reference's stamp carries the binding digest, which covers the basis +/// witness at registration, and `admit` compares it with the digest the store +/// holds; neither is recomputed from the view now. So a router holding a /// `MethodRef` is admitted whatever the view now says. `resolve` refuses the -/// reorder above; `admit` cannot, because it never sees a basis. L5c puts -/// the class revision and the basis witness on `MethodRef`; flip this then. +/// reorder above; callers that execute resolve first (R2IL `prepare` does). #[test] fn finding_admit_does_not_carry_the_basis_identity() { let s = store_with_order(); let at_bind = View::with_fields(VIEW_CLASS, 4); let bound = s.resolve(&at_bind, ORDER, SchemaRev(1)).unwrap(); - let r = MethodRef::of(ORDER, &bound.methods[0]); + let r = s.method_ref(ORDER, bound.methods[0].id).unwrap(); let swapped = view_of(&[BOUND[0], BOUND[2], BOUND[1], BOUND[3]]); assert!(s.resolve(&swapped, ORDER, SchemaRev(1)).is_err()); assert!(s.admit(&r).is_ok(), "admit never reads a basis"); @@ -925,7 +926,7 @@ fn population_truth_and_demand_ride_the_binding() { ..method(40, vec![]) }; reg(&mut s, ORDER, schema(1, 4), vec![count.clone()]).unwrap(); - let got = s.admit(&MethodRef::of(ORDER, &count)).unwrap(); + let got = s.admit(&s.method_ref(ORDER, count.id).unwrap()).unwrap(); assert_eq!(got.population, Population::Bag); assert_eq!(got.truth, Truth::TwoValued); assert_eq!(got.demand, ValueDemand::MapOnly); @@ -1866,3 +1867,140 @@ fn finding_a_stale_first_registration_locks_out_the_correct_basis() { Err(Refusal::BasisConflict { .. }) )); } + +// ── L5c: the issuer stamp and the binding digest ───────────────────────────── + +/// Store A binds method 10 as ASSIGN(2) then ACCUMULATE(3); every helper +/// below builds stores the same way, so two stores differ only where a test +/// says they do. +fn store_with(effects: Vec, name: &str) -> BindStore { + let mut s = catalogue(); + let m = MethodBinding { + name: name.to_string(), + ..method(10, effects) + }; + reg(&mut s, ORDER, schema(1, 4), vec![m]).unwrap(); + s +} + +fn forward() -> Vec { + vec![effect(2, ASSIGN), effect(3, ACCUMULATE)] +} + +/// Positive: the store admits its own references, and two references it +/// issues for one method are equal (one stamp). +#[test] +fn a_same_store_twin_admits() { + let s = store_with(forward(), "m10"); + let r1 = s.method_ref(ORDER, MethodId(10)).unwrap(); + let r2 = s.method_ref(ORDER, MethodId(10)).unwrap(); + assert_eq!(r1, r2, "one store, one method, one stamp"); + assert_eq!(s.admit(&r1).unwrap().effects, forward()); + assert_eq!(s.admit(&r2).unwrap().effects, forward()); +} + +/// n12 (P1-5): store B binds the same class, method, signature revision and +/// guard with the effects reversed. A's reference is refused by B for the +/// binding, which is checked before the issuer. +#[test] +fn a_reference_to_reversed_effects_is_refused_as_another_binding() { + let a = store_with(forward(), "m10"); + let b = store_with(vec![effect(3, ACCUMULATE), effect(2, ASSIGN)], "m10"); + let ra = a.method_ref(ORDER, MethodId(10)).unwrap(); + let rb = b.method_ref(ORDER, MethodId(10)).unwrap(); + // Everything the pre-L5c reference carried is equal. + assert_eq!( + (ra.class, ra.method, ra.sig_rev, &ra.guard), + (rb.class, rb.method, rb.sig_rev, &rb.guard) + ); + assert_eq!( + b.admit(&ra).unwrap_err(), + Refusal::BindingMismatch { + class: ORDER, + method: MethodId(10) + } + ); + assert!(b.admit(&rb).is_ok(), "B admits its own reference"); +} + +/// A store with the same binding is still another store. Renaming the method +/// keeps the digest, so the refusal is the issuer, not the binding. +#[test] +fn a_same_binding_from_another_store_is_refused_as_foreign() { + let a = store_with(forward(), "m10"); + let b = store_with(forward(), "renamed"); + let ra = a.method_ref(ORDER, MethodId(10)).unwrap(); + assert_eq!( + b.admit(&ra).unwrap_err(), + Refusal::ForeignStore { class: ORDER }, + "equal digest (name is not part of it), other issuer" + ); + assert_eq!( + b.check_issuer(&ra), + Err(Refusal::ForeignStore { class: ORDER }) + ); + assert_eq!(a.check_issuer(&ra), Ok(())); + // Fresh stores have distinct identities. + assert_ne!(ra, b.method_ref(ORDER, MethodId(10)).unwrap()); +} + +/// D2: a clone is another store. It refuses the original's references, the +/// original refuses its references, and each admits its own. +#[test] +fn a_cloned_store_refuses_the_originals_reference() { + let a = store_with(forward(), "m10"); + let c = a.clone(); + let ra = a.method_ref(ORDER, MethodId(10)).unwrap(); + let rc = c.method_ref(ORDER, MethodId(10)).unwrap(); + assert_eq!( + c.admit(&ra).unwrap_err(), + Refusal::ForeignStore { class: ORDER } + ); + assert_eq!( + a.admit(&rc).unwrap_err(), + Refusal::ForeignStore { class: ORDER } + ); + assert!(a.admit(&ra).is_ok()); + assert!(c.admit(&rc).is_ok()); +} + +/// A stamp copied onto a reference to another method of the same store is +/// refused: the digest is per method, not per store. +#[test] +fn a_stamp_moved_to_another_method_is_refused() { + let mut s = catalogue(); + let a = method(20, vec![effect(2, ASSIGN)]); + let b = method(21, vec![effect(2, ASSIGN)]); + reg(&mut s, ORDER, schema(1, 4), vec![a, b]).unwrap(); + let ra = s.method_ref(ORDER, MethodId(20)).unwrap(); + let rb = s.method_ref(ORDER, MethodId(21)).unwrap(); + let moved = MethodRef { + method: MethodId(21), + ..ra + }; + assert_eq!( + s.admit(&moved).unwrap_err(), + Refusal::BindingMismatch { + class: ORDER, + method: MethodId(21) + } + ); + assert!(s.admit(&rb).is_ok()); +} + +/// `method_ref` issues only for bound methods. +#[test] +fn method_ref_refuses_what_is_not_bound() { + let s = store_with(forward(), "m10"); + assert_eq!( + s.method_ref(ORDER_V2, MethodId(10)).unwrap_err(), + Refusal::UnknownClass(ORDER_V2) + ); + assert_eq!( + s.method_ref(ORDER, MethodId(11)).unwrap_err(), + Refusal::UnknownMethod { + class: ORDER, + method: MethodId(11) + } + ); +} diff --git a/crates/lance-graph-r2il/src/binding.rs b/crates/lance-graph-r2il/src/binding.rs index 5b095de92..36f76aafc 100644 --- a/crates/lance-graph-r2il/src/binding.rs +++ b/crates/lance-graph-r2il/src/binding.rs @@ -56,8 +56,9 @@ //! 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. +//! [`run`]: that `store` issued the plan's method reference, 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 @@ -149,16 +150,59 @@ pub struct Step { /// 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. +/// +/// Only [`prepare`] builds one, and its fields are read-only outside this +/// crate: `run` executes `steps` as prepared, so they cannot be edited after +/// the checks ran. +/// +/// ```compile_fail +/// fn edit(p: &mut lance_graph_r2il::binding::Prepared) { +/// p.steps.clear(); +/// } +/// ``` #[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, + pub(crate) method: MethodRef, + pub(crate) lens: R2ilLens, + pub(crate) route: (RouteVersion, u16), + pub(crate) guards: Guards, + pub(crate) steps: Vec, + /// FNV-1a over the method reference (including its binding digest), the + /// lens and every step: the plan's identity, carried into each + /// [`Receipt`]. + pub(crate) plan_id: u64, +} + +impl Prepared { + /// The method reference the plan was prepared for. + pub fn method(&self) -> &MethodRef { + &self.method + } + + /// The R2IL lens the plan's words were bound under. + pub fn lens(&self) -> R2ilLens { + self.lens + } + + /// The route the plan was prepared under. + pub fn route(&self) -> (RouteVersion, u16) { + self.route + } + + /// The guards the plan was resolved under. + pub fn guards(&self) -> &Guards { + &self.guards + } + + /// The bound steps, in order. + pub fn steps(&self) -> &[Step] { + &self.steps + } + + /// The plan's identity. + pub fn plan_id(&self) -> u64 { + self.plan_id + } } /// A run's answer: the final scalar of every field the plan writes, @@ -319,9 +363,11 @@ pub fn prepare( }) } -/// 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. +/// Run a prepared plan, hot: the store must be the one that issued the +/// plan's method reference ([`Refusal::ForeignStore`]), 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 /// @@ -336,6 +382,9 @@ pub fn run( fields: FieldLanes<'_>, dialect: &mut FoldDialect<'_>, ) -> Result { + store + .check_issuer(&plan.method) + .map_err(ExecRefusal::Binding)?; plan.guards .check(frames, now) .map_err(ExecRefusal::Guards)?; @@ -454,8 +503,10 @@ pub(crate) fn check_route( } } -/// FNV-1a over the method reference's identity fields, the lens and the -/// steps. Prepared plans refuse guarded bindings, so the guard is absent. +/// FNV-1a over the method reference's identity fields and binding digest, +/// the lens and the steps. Prepared plans refuse guarded bindings, so the +/// guard is absent. The lanes a run reads are not part of it: they are bound +/// per run ([`FieldLanes`]), so one plan may read lanes of different kinds. fn plan_id(method: &MethodRef, lens: R2ilLens, steps: &[Step]) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; let mut eat = |bytes: &[u8]| { @@ -467,6 +518,7 @@ fn plan_id(method: &MethodRef, lens: R2ilLens, steps: &[Step]) -> u64 { eat(&method.class.to_le_bytes()); eat(&method.method.0.to_le_bytes()); eat(&method.sig_rev.0.to_le_bytes()); + eat(&method.stamp.binding().to_le_bytes()); eat(&lens.lens.to_le_bytes()); eat(&lens.schema_rev.0.to_le_bytes()); for s in steps { diff --git a/crates/lance-graph-r2il/src/binding_native.rs b/crates/lance-graph-r2il/src/binding_native.rs index 9eba6e344..46653da38 100644 --- a/crates/lance-graph-r2il/src/binding_native.rs +++ b/crates/lance-graph-r2il/src/binding_native.rs @@ -31,12 +31,13 @@ //! //! # What is checked when //! -//! [`prepare_native`]: the guards and the route, then every step through +//! [`prepare_native`]: that `store` issued the plan's method reference, 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 +//! [`run_native`]: the issuer, 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. @@ -112,7 +113,7 @@ pub struct NativePlan { } impl NativePlan { - /// The plan's identity, the one [`Prepared::plan_id`] carries. + /// The plan's identity, the one [`Prepared::plan_id`] returns. pub fn plan_id(&self) -> u64 { self.prepared.plan_id } @@ -207,6 +208,9 @@ pub fn prepare_native( fields: FieldLanes<'_>, planes: &Planes<'_>, ) -> Result { + store + .check_issuer(&plan.method) + .map_err(ExecRefusal::Binding)?; plan.guards .check(frames, now) .map_err(ExecRefusal::Guards)?; @@ -354,6 +358,9 @@ pub fn run_native( scratch: &mut NativeScratch, ) -> Result { let plan = &np.prepared; + store + .check_issuer(&plan.method) + .map_err(ExecRefusal::Binding)?; plan.guards .check(frames, now) .map_err(ExecRefusal::Guards)?; diff --git a/crates/lance-graph-r2il/tests/integration/binding.rs b/crates/lance-graph-r2il/tests/integration/binding.rs index 4b783a553..090d8f989 100644 --- a/crates/lance-graph-r2il/tests/integration/binding.rs +++ b/crates/lance-graph-r2il/tests/integration/binding.rs @@ -307,13 +307,9 @@ fn guards(frames: &FrameStore) -> Guards { } } -fn mref(id: u32) -> MethodRef { - MethodRef { - class: CLASS, - method: MethodId(id), - sig_rev: SigRev(1), - guard: None, - } +/// The store's own reference to method `id` of [`CLASS`]. +fn mref(s: &BindStore, id: u32) -> MethodRef { + s.method_ref(CLASS, MethodId(id)).unwrap() } /// Lanes of `n` rows: `a = i % 37 - 10`, `b = (7i) % 23`, `k = i % 5`. @@ -363,7 +359,7 @@ impl Fixture { } } fn prepare(&self, id: u32) -> Result { - let r = mref(id); + let r = mref(&self.store, id); let g = guards(&self.frames); prepare( &self.store, @@ -381,9 +377,9 @@ impl Fixture { } } -fn permit(ids: &[u32]) -> ExecPermit { +fn permit(s: &BindStore, ids: &[u32]) -> ExecPermit { ExecPermit { - methods: ids.iter().map(|&i| mref(i)).collect(), + methods: ids.iter().map(|&i| mref(s, i)).collect(), } } @@ -428,13 +424,13 @@ 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 r = exec(&f, &plan, &data, &permit(&f.store, &[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.plan_id, plan.plan_id()); + assert_eq!(r.method, mref(&f.store, ORDER_A)); assert_eq!(r.frame, REVS); } @@ -449,7 +445,7 @@ fn changing_the_recipe_changes_the_answer() { &f, &f.prepare(SUM_A).unwrap(), &data, - &permit(&[SUM_A]), + &permit(&f.store, &[SUM_A]), NOW, ) .unwrap(); @@ -458,14 +454,14 @@ fn changing_the_recipe_changes_the_answer() { &f, &f.prepare(COUNT).unwrap(), &data, - &permit(&[COUNT]), + &permit(&f.store, &[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 + f.prepare(SUM_A).unwrap().plan_id(), + f.prepare(ORDER_A).unwrap().plan_id() ); } @@ -478,7 +474,7 @@ fn reordering_effects_changes_the_answer_as_the_oracle_predicts() { &f, &f.prepare(ORDER_A).unwrap(), &data, - &permit(&[ORDER_A]), + &permit(&f.store, &[ORDER_A]), NOW, ) .unwrap(); @@ -486,7 +482,7 @@ fn reordering_effects_changes_the_answer_as_the_oracle_predicts() { &f, &f.prepare(ORDER_B).unwrap(), &data, - &permit(&[ORDER_B]), + &permit(&f.store, &[ORDER_B]), NOW, ) .unwrap(); @@ -496,7 +492,7 @@ fn reordering_effects_changes_the_answer_as_the_oracle_predicts() { &f, &f.prepare(SUB_RIGHT).unwrap(), &data, - &permit(&[SUB_RIGHT]), + &permit(&f.store, &[SUB_RIGHT]), NOW, ) .unwrap(); @@ -504,7 +500,7 @@ fn reordering_effects_changes_the_answer_as_the_oracle_predicts() { &f, &f.prepare(SUB_LEFT).unwrap(), &data, - &permit(&[SUB_LEFT]), + &permit(&f.store, &[SUB_LEFT]), NOW, ) .unwrap(); @@ -518,9 +514,9 @@ fn a_word_binds_by_its_label_not_its_number() { // 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); + 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] @@ -529,7 +525,7 @@ fn a_prepared_plan_runs_without_the_class_view() { 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()); + assert!(exec(&f, &plan, &data, &permit(&f.store, &[ORDER_A]), NOW).is_ok()); } #[test] @@ -537,13 +533,13 @@ 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!(f.store.admit(&mref(&f.store, 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), + exec(&f, &plan, &data, &permit(&f.store, &[ORDER_B]), NOW), Err(ExecRefusal::NotPermitted) ); } @@ -553,7 +549,7 @@ 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]); + let p = permit(&f.store, &[ORDER_A]); assert_eq!( exec(&f, &plan, &data, &p, (Focus { revision: 2 }, SCOPE)), Err(ExecRefusal::Guards(GuardRefusal::FocusChanged)) @@ -581,7 +577,7 @@ fn a_changed_route_is_never_reused() { let plan = f.prepare(ORDER_A).unwrap(); f.store.route(POSITION, OTHER_CLASS).unwrap(); assert_eq!( - exec(&f, &plan, &data, &permit(&[ORDER_A]), NOW), + exec(&f, &plan, &data, &permit(&f.store, &[ORDER_A]), NOW), Err(ExecRefusal::RouteChanged { position: POSITION, captured: Some(CLASS), @@ -611,7 +607,7 @@ fn a_wrong_signature_or_dropped_guard_refuses() { ) .map(|_| ()) }; - let mut wrong_sig = mref(ORDER_A); + let mut wrong_sig = mref(&f.store, ORDER_A); wrong_sig.sig_rev = SigRev(9); assert!(matches!( res(&wrong_sig), @@ -620,7 +616,10 @@ fn a_wrong_signature_or_dropped_guard_refuses() { )) )); assert!(matches!( - res(&mref(GUARDED)), + res(&MethodRef { + guard: None, + ..mref(&f.store, GUARDED) + }), Err(ExecRefusal::Binding(Refusal::GuardDropped { .. })) )); } @@ -630,7 +629,7 @@ 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]); + let guarded = mref(&f.store, methods()[10].id.0); assert_eq!(guarded.method, MethodId(GUARDED)); let g = guards(&f.frames); let res = Resolution { @@ -678,7 +677,7 @@ fn a_word_under_another_lens_or_reading_refuses() { let v = store.route(9, class).unwrap(); let frames = FrameStore::new(REVS); let g = guards(&frames); - let r = MethodRef::of(class, &other); + let r = store.method_ref(class, other.id).unwrap(); let res = Resolution { method: &r, schema_rev: REV, @@ -719,7 +718,7 @@ fn an_arity_the_lens_declares_against_ogar_refuses() { let v = store.route(POSITION, CLASS).unwrap(); let frames = FrameStore::new(REVS); let g = guards(&frames); - let r = MethodRef::of(CLASS, &m); + let r = store.method_ref(CLASS, m.id).unwrap(); let res = Resolution { method: &r, schema_rev: REV, @@ -741,7 +740,15 @@ fn an_arity_the_lens_declares_against_ogar_refuses() { 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); + let go = |id| { + exec( + &f, + &f.prepare(id).unwrap(), + &data, + &permit(&f.store, &[id]), + NOW, + ) + }; assert!(matches!( go(WRONG_KIND), Err(ExecRefusal::Fold { @@ -769,7 +776,7 @@ fn operands_that_do_not_translate_faithfully_refuse_at_run() { 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 p = permit(&f.store, &[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. @@ -799,7 +806,7 @@ fn an_and_operand_read_again_keeps_its_own_mask() { "the fixture separates the two" ); let plan = f.prepare(SHARED_AND).unwrap(); - let r = exec(&f, &plan, &data, &permit(&[SHARED_AND]), NOW).unwrap(); + let r = exec(&f, &plan, &data, &permit(&f.store, &[SHARED_AND]), NOW).unwrap(); assert_eq!(values(&r), vec![(4, data.count_below(10))]); } @@ -812,7 +819,7 @@ fn a_run_leaves_no_pending_predicates_in_the_dialect() { 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 ok = permit(&f.store, &[PRED_ONLY, COUNT]); let lanes = [ LaneRef::I32(&data.a), LaneRef::I32(&data.b), @@ -838,3 +845,32 @@ fn a_run_leaves_no_pending_predicates_in_the_dialect() { "only COUNT's own predicate" ); } + +// ── L5c: a plan belongs to the store that issued its method reference ────── + +#[test] +fn a_plan_prepared_or_run_against_another_store_is_refused() { + let mut f = Fixture::new(); + let data = Data::new(N); + let plan = f.prepare(ORDER_A).unwrap(); + let p = permit(&f.store, &[ORDER_A]); + assert!(exec(&f, &plan, &data, &p, NOW).is_ok(), "own store runs"); + // A clone has the same bindings and routes, and another identity (D2). + let clone = f.store.clone(); + let original = std::mem::replace(&mut f.store, clone); + let foreign = ExecRefusal::Binding(Refusal::ForeignStore { class: CLASS }); + assert_eq!(exec(&f, &plan, &data, &p, NOW), Err(foreign.clone())); + // Preparing the original's reference against the clone is refused too. + let r = original.method_ref(CLASS, MethodId(ORDER_A)).unwrap(); + let g = guards(&f.frames); + let res = Resolution { + method: &r, + schema_rev: REV, + route: (f.version, POSITION), + guards: &g, + }; + assert_eq!( + prepare(&f.store, &f.views, R2IL, &res, &f.frames, NOW).map(|_| ()), + Err(foreign) + ); +} diff --git a/crates/lance-graph-r2il/tests/integration/binding_native.rs b/crates/lance-graph-r2il/tests/integration/binding_native.rs index b287017c7..a79846d9e 100644 --- a/crates/lance-graph-r2il/tests/integration/binding_native.rs +++ b/crates/lance-graph-r2il/tests/integration/binding_native.rs @@ -18,7 +18,7 @@ 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, + Refusal, RouteVersion, SchemaRev, SigRev, ValueDemand, }; use lance_graph_mask_risc::{ExecError, Foreign, LaneRef, Planes}; use lance_graph_r2il::binding::{ @@ -308,18 +308,14 @@ fn methods() -> Vec { ] } -fn mref(id: u32) -> MethodRef { - MethodRef { - class: CLASS, - method: MethodId(id), - sig_rev: SigRev(1), - guard: None, - } +/// The store's own reference to method `id` of [`CLASS`]. +fn mref(s: &BindStore, id: u32) -> MethodRef { + s.method_ref(CLASS, MethodId(id)).unwrap() } -fn permit(ids: &[u32]) -> ExecPermit { +fn permit(s: &BindStore, ids: &[u32]) -> ExecPermit { ExecPermit { - methods: ids.iter().map(|&i| mref(i)).collect(), + methods: ids.iter().map(|&i| mref(s, i)).collect(), } } @@ -399,7 +395,7 @@ impl Fixture { } fn prepare(&self, id: u32) -> Prepared { - let r = mref(id); + let r = mref(&self.store, id); let g = Guards { frame: self.frames.seal(), focus: FOCUS, @@ -489,7 +485,7 @@ 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) + f.run_native(&np, data, &permit(&f.store, &[id]), NOW, &mut scratch) .expect("run_native") } @@ -519,11 +515,13 @@ fn native_agrees_with_run_on_every_runnable_fixture() { ]; for id in ids { let plan = f.prepare(id); - let want = f.r1(&plan, &data, &permit(&[id]), NOW).expect("run"); + let want = f + .r1(&plan, &data, &permit(&f.store, &[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) + .run_native(&np, &data, &permit(&f.store, &[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}"); @@ -607,7 +605,7 @@ fn a_run_reads_the_lanes_it_is_given() { 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 p = permit(&f.store, &[MASKED_SUM]); let r = f.run_native(&np, &second, &p, NOW, &mut scratch).unwrap(); assert_eq!(r.values(), &[(4, second.sum_b(5))]); } @@ -650,7 +648,7 @@ fn a_fold_fed_by_a_fold_result_is_refused() { 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) + .r1(&f.prepare(FEEDS), &data, &permit(&f.store, &[FEEDS]), NOW) .is_ok()); } @@ -681,7 +679,7 @@ fn run_native_refuses_what_run_refuses_first() { let plan = f.prepare(id); let np = f.native(&plan, &data).unwrap(); let mut s = NativeScratch::for_plan(&np); - let p = permit(&[id]); + let p = permit(&f.store, &[id]); let none = ExecPermit::default(); let moved_focus = (Focus { revision: 2 }, SCOPE); let moved_scope = (FOCUS, Scope { id: 2 }); @@ -725,7 +723,7 @@ fn run_native_refuses_a_changed_route() { 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 p = permit(&f.store, &[MASKED_SUM]); let want = f.r1(&plan, &data, &p, NOW).unwrap_err(); assert_eq!( want, @@ -748,7 +746,7 @@ fn run_native_refuses_changed_planes() { 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 p = permit(&f.store, &[MASKED_SUM]); let shorter = Data::new(N / 2); assert_eq!( f.run_native(&np, &shorter, &p, NOW, &mut s), @@ -782,7 +780,13 @@ fn a_scratch_from_another_plan_is_refused() { 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); + let r = f.run_native( + &np, + &data, + &permit(&f.store, &[MASKED_SUM]), + NOW, + &mut small, + ); assert!(matches!( r, Err(ExecRefusal::Fold { @@ -810,7 +814,7 @@ fn run_native_allocates_nothing() { let plan = f.prepare(id); let np = f.native(&plan, &data).unwrap(); let mut scratch = NativeScratch::for_plan(&np); - let p = permit(&[id]); + let p = permit(&f.store, &[id]); let want = native_answer(&f, id, &data); for _ in 0..16 { black_box( @@ -856,3 +860,28 @@ fn native_keeps_an_and_operand_that_is_read_again() { &[(4, data.count(10))] ); } + +// ── L5c: a native plan belongs to the store that issued its reference ────── + +#[test] +fn a_native_plan_prepared_or_run_against_another_store_is_refused() { + let mut f = Fixture::new(); + let data = Data::new(N); + let plan = f.prepare(MASKED_SUM); + let p = permit(&f.store, &[MASKED_SUM]); + let np = f.native(&plan, &data).expect("own store prepares"); + let mut scratch = NativeScratch::for_plan(&np); + assert!( + f.run_native(&np, &data, &p, NOW, &mut scratch).is_ok(), + "own store runs" + ); + // A clone has the same bindings and routes, and another identity (D2). + let clone = f.store.clone(); + let _original = std::mem::replace(&mut f.store, clone); + let foreign = Err(ExecRefusal::Binding(Refusal::ForeignStore { class: CLASS })); + assert_eq!(f.native(&plan, &data).map(|_| ()), foreign); + assert_eq!( + f.run_native(&np, &data, &p, NOW, &mut scratch).map(|_| ()), + foreign + ); +} diff --git a/crates/lance-graph-r2il/tests/integration/polyfill.rs b/crates/lance-graph-r2il/tests/integration/polyfill.rs index 82fbcd44f..39b333b64 100644 --- a/crates/lance-graph-r2il/tests/integration/polyfill.rs +++ b/crates/lance-graph-r2il/tests/integration/polyfill.rs @@ -177,13 +177,9 @@ fn method(id: u32, reads: &[u8], effects: Vec) -> MethodBinding { } } -fn mref(id: u32) -> MethodRef { - MethodRef { - class: CLASS, - method: MethodId(id), - sig_rev: SigRev(1), - guard: None, - } +/// The store's own reference to method `id` of [`CLASS`]. +fn mref(s: &BindStore, id: u32) -> MethodRef { + s.method_ref(CLASS, MethodId(id)).unwrap() } fn store(methods: Vec) -> (BindStore, RouteVersion) { @@ -247,7 +243,7 @@ impl Run { } } fn prepare(&self, views: &Views, id: u32) -> Result { - let r = mref(id); + let r = mref(&self.store, id); let g = Guards { frame: self.frames.seal(), focus: FOCUS, @@ -283,7 +279,7 @@ impl Run { &self.frames, NOW, &ExecPermit { - methods: vec![plan.method.clone()], + methods: vec![plan.method().clone()], }, FieldLanes { lanes: &LANES }, &mut d, @@ -339,8 +335,8 @@ fn three_languages_bind_the_same_plan_and_answer() { "<" ); for p in &plans[1..] { - assert_eq!(p.steps, plans[0].steps); - assert_eq!(p.plan_id, plans[0].plan_id); + assert_eq!(p.steps(), plans[0].steps()); + assert_eq!(p.plan_id(), plans[0].plan_id()); } } @@ -376,8 +372,8 @@ fn the_position_binds_where_the_label_binding_would_take_another_opcode() { let run = Run::new(vec![sum_below()]); let views = Views::language("r2il"); let plan = run.prepare(&views, SUM_BELOW).unwrap(); - assert_eq!(plan.steps[1].opcode, POLYFILL[pos("SUM") as usize].opcode); - assert_ne!(plan.steps[1].opcode, POLYFILL[pos("MIN") as usize].opcode); + assert_eq!(plan.steps()[1].opcode, POLYFILL[pos("SUM") as usize].opcode); + assert_ne!(plan.steps()[1].opcode, POLYFILL[pos("MIN") as usize].opcode); } // ---------------------------------------------- Quack names, Quack answers diff --git a/crates/lance-graph-r2il/tests/integration/polyfill_views.rs b/crates/lance-graph-r2il/tests/integration/polyfill_views.rs index 11bc0c94b..d223ea620 100644 --- a/crates/lance-graph-r2il/tests/integration/polyfill_views.rs +++ b/crates/lance-graph-r2il/tests/integration/polyfill_views.rs @@ -7,8 +7,8 @@ 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, + AlgebraRef, Arg, BindStore, ClassSchema, Effect, Extent, MethodBinding, MethodId, SchemaRev, + SigRev, ValueDemand, }; use lance_graph_r2il::binding::{prepare, ExecRefusal, Prepared, R2ilLens, Resolution}; use lance_graph_r2il::polyfill::{ @@ -205,12 +205,7 @@ fn prep(view: &V) -> Result { coordinates: 1, }); let now = (Focus { revision: 1 }, Scope { id: 1 }); - let r = MethodRef { - class: CLASS, - method: MethodId(1), - sig_rev: SigRev(1), - guard: None, - }; + let r = s.method_ref(CLASS, MethodId(1)).unwrap(); let g = Guards { frame: frames.seal(), focus: now.0, @@ -245,8 +240,8 @@ fn the_adapter_binds_the_same_plan_in_every_language() { }) .collect(); for p in &plans[1..] { - assert_eq!(p.steps, plans[0].steps); - assert_eq!(p.plan_id, plans[0].plan_id); + assert_eq!(p.steps(), plans[0].steps()); + assert_eq!(p.plan_id(), plans[0].plan_id()); } } diff --git a/crates/lance-graph-report/src/lane_source/tests.rs b/crates/lance-graph-report/src/lane_source/tests.rs index bc349d37d..eee97812a 100644 --- a/crates/lance-graph-report/src/lane_source/tests.rs +++ b/crates/lance-graph-report/src/lane_source/tests.rs @@ -14,7 +14,7 @@ use lance_graph_contract::set_rules::{Population, Truth}; use lance_graph_dt64::dr::{FrameRefusal, Scope}; use lance_graph_dt64::{ BindStore, ClassSchema, CoordSpace, Extent, LaneBinding, LaneRefusal, LaneRequest, - MethodBinding, MethodId, MethodRef, RouteAt, SchemaRev, SigRev, ValueDemand, + MethodBinding, MethodId, RouteAt, SchemaRev, SigRev, ValueDemand, }; use lance_graph_mask_risc::{execute_into, Foreign, LaneRef, Out, Planes, Scratch, Value}; use lance_graph_quack::{lower, Agg, Cmp, Col, Filter, Query}; @@ -255,8 +255,8 @@ fn bound_lanes_are_the_batch_lanes_and_fold_with_scalar_parity() { ); // Behaviour: DT64 admits each method; its reads lie inside the binding. - let m1 = MethodRef::of(class(), &test_method(1, &[REVENUE, REGION])); - let m2 = MethodRef::of(class(), &test_method(2, &[REGION])); + let m1 = store.method_ref(class(), MethodId(1)).unwrap(); + let m2 = store.method_ref(class(), MethodId(2)).unwrap(); for m in [&m1, &m2] { let admitted = store.admit(m).unwrap(); assert!(admitted.reads.is_subset_of(binding.fields())); diff --git a/crates/math-polyfill-probe/examples/r1_vs_prebound.rs b/crates/math-polyfill-probe/examples/r1_vs_prebound.rs index 18c20cd83..a23f0da7f 100644 --- a/crates/math-polyfill-probe/examples/r1_vs_prebound.rs +++ b/crates/math-polyfill-probe/examples/r1_vs_prebound.rs @@ -29,8 +29,8 @@ 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, + AlgebraRef, Arg, BindStore, ClassSchema, Effect, Extent, MethodBinding, MethodId, SchemaRev, + SigRev, ValueDemand, }; use lance_graph_mask_risc::exec::{execute_compiled, tile_words_for, Scratch}; use lance_graph_mask_risc::ir::{ @@ -318,7 +318,7 @@ fn main() { ) .unwrap(); let route_version = bstore.route(POSITION, CLASS).unwrap(); - let mref = MethodRef::of(CLASS, &method); + let mref = bstore.method_ref(CLASS, method.id).unwrap(); let r1_guards = Guards { frame: frames.seal(), focus, diff --git a/crates/xlog-relcomp-probe/tests/dto_binding.rs b/crates/xlog-relcomp-probe/tests/dto_binding.rs index 1dc8d5e5f..34e3958b5 100644 --- a/crates/xlog-relcomp-probe/tests/dto_binding.rs +++ b/crates/xlog-relcomp-probe/tests/dto_binding.rs @@ -26,8 +26,8 @@ use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; use lance_graph_contract::set_rules::{Population, Truth}; use lance_graph_dt64::dr::{Focus, FrameRefusal, FrameRevisions, FrameStore, Guards, Scope}; use lance_graph_dt64::{ - AlgebraRef, Arg, BindStore, ClassSchema, Effect, Extent, MethodBinding, MethodId, MethodRef, - Refusal, RouteVersion, SchemaRev, SigRev, ValueDemand, view_class, + AlgebraRef, Arg, BindStore, ClassSchema, Effect, Extent, MethodBinding, MethodId, Refusal, + RouteVersion, SchemaRev, SigRev, ValueDemand, view_class, }; use xlog_logic::parse_program; use xlog_relcomp_probe::{AddressMap, SLOTS, facts, register}; @@ -238,9 +238,10 @@ fn bind<'s>( .map_err(BindRefusal::Store)?; assert_eq!(r.class, XLOG); } - let method = store - .admit(&MethodRef::of(XLOG, &xlog_count())) + let r = store + .method_ref(XLOG, xlog_count().id) .map_err(BindRefusal::Store)?; + let method = store.admit(&r).map_err(BindRefusal::Store)?; Ok(Binding { answer_identities: routed .positions