From 3ca08382397e8dcfa50081d6f4120c0c3336bd7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 19:46:14 +0000 Subject: [PATCH 1/2] dt64: lane binding refuses a predicate change at a bound position (S8) bind_lanes records the predicate IRI each bound position resolved to; the hot check compares it after lane_of and refuses with PredicateChanged { class, field } when a view reorder put another predicate at that position. Label-only renames and reorders outside the bound positions still admit. No hashing on the hot path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPJjVvasjwqhjJ6c9p --- .../examples/lane_check_cost.rs | 216 ++++++++++++++++++ crates/lance-graph-dt64/src/lane.rs | 56 +++-- crates/lance-graph-dt64/tests/lane_socket.rs | 126 +++++++++- 3 files changed, 372 insertions(+), 26 deletions(-) create mode 100644 crates/lance-graph-dt64/examples/lane_check_cost.rs diff --git a/crates/lance-graph-dt64/examples/lane_check_cost.rs b/crates/lance-graph-dt64/examples/lane_check_cost.rs new file mode 100644 index 000000000..014dc2a96 --- /dev/null +++ b/crates/lance-graph-dt64/examples/lane_check_cost.rs @@ -0,0 +1,216 @@ +//! Cost of the hot `LaneBinding::check`, per check and per bound lane. +//! +//! Run: `cargo run --release -p lance-graph-dt64 --example lane_check_cost` +//! +//! The "before" figure (no per-position predicate compare) is obtained by +//! applying the disable edit that turns the guard condition in +//! `LaneBinding::check` into `if false {` and re-running. The numbers are a +//! report, not an assertion: the example does not fail on a threshold. + +use std::hint::black_box; +use std::time::Instant; + +use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +use lance_graph_dt64::dr::{Frame, FrameRevisions, FrameStore, Scope}; +use lance_graph_dt64::{ + BindStore, ClassSchema, CoordSpace, Extent, LaneAddr, LaneBinding, LaneRefusal, LaneRequest, + LaneSource, RouteAt, SchemaRev, +}; + +const CLASS: u32 = 0x0C1A_0001; +const VIEW_CLASS: ClassId = 0x0C1A; +const POSITION: u16 = 5; +const FIELDS: usize = 16; +const SPACE: CoordSpace = CoordSpace { + population: 1, + coordinates: 1, +}; +const READER: Scope = Scope { id: 7 }; + +struct View(Vec); + +impl View { + fn sixteen() -> View { + View( + (0..FIELDS) + .map(|i| { + FieldRef::new( + format!("https://example.org/dir/attribute{i:02}"), + format!("Attribute {i:02}"), + ) + }) + .collect(), + ) + } +} + +impl ClassView for View { + fn fields(&self, class: ClassId) -> &[FieldRef] { + if class == VIEW_CLASS { + &self.0 + } else { + &[] + } + } + fn template(&self, _class: ClassId) -> DisplayTemplate { + DisplayTemplate::Detail + } + fn dolce_category_id(&self, _class: ClassId) -> u8 { + 0 + } +} + +/// Positional source: field `n` lives in lane `n`; the basis is ignored. +struct Source { + map: [LaneAddr; FIELDS], + lanes: Vec, + frames: FrameStore, +} + +impl Source { + fn new() -> Source { + let map = core::array::from_fn(|i| LaneAddr { + space: SPACE, + lane: i as u16, + scope: READER, + }); + Source { + map, + lanes: vec![1, 2, 3, 4], + frames: FrameStore::new(FrameRevisions { + source: 1, + relation_map: 1, + coordinates: 1, + }), + } + } +} + +impl LaneSource for Source { + type Lane<'a> = &'a [i32]; + fn lane_of( + &self, + _class: u32, + _rev: SchemaRev, + field: u8, + _basis: &FieldRef, + _frame: &Frame, + ) -> Option { + Some(self.map[usize::from(field)]) + } + fn borrow(&self, _addr: &LaneAddr) -> Option<&[i32]> { + Some(&self.lanes[..]) + } + fn frames(&self) -> &FrameStore { + &self.frames + } +} + +fn bind( + store: &BindStore, + view: &View, + source: &Source, + frame: &Frame, + route: RouteAt, + k: usize, +) -> LaneBinding { + let positions: Vec = (0..k as u8).collect(); + let mask = WideFieldMask::from_positions(&positions); + store + .bind_lanes( + view, + source.frames(), + source, + LaneRequest { + route, + rev: SchemaRev(1), + fields: &mask, + frame, + scope: READER, + selection: SPACE, + }, + ) + .expect("bind") +} + +/// Minimum over 7 repetitions of 2,000,000 calls, in nanoseconds per call. +fn time bool>(mut call: F) -> f64 { + for _ in 0..100_000 { + black_box(call()); + } + let mut best = f64::MAX; + for _ in 0..7 { + let start = Instant::now(); + for _ in 0..2_000_000u32 { + black_box(call()); + } + let ns = start.elapsed().as_nanos() as f64 / 2_000_000.0; + if ns < best { + best = ns; + } + } + best +} + +fn main() { + let view = View::sixteen(); + let mut store = BindStore::new(); + store + .register( + &view, + CLASS, + ClassSchema { + schema_rev: SchemaRev(1), + field_count: FIELDS, + extent: Extent::DR64_CUBE, + }, + Vec::new(), + ) + .expect("register"); + let version = store.route(POSITION, CLASS).expect("route"); + let route = RouteAt { + position: POSITION, + version, + }; + let source = Source::new(); + let frame = source.frames().seal(); + + for k in [1usize, 4, 16] { + let binding = bind(&store, &view, &source, &frame, route, k); + assert!(binding + .check(&store, &view, source.frames(), &source) + .is_ok()); + let ns = time(|| { + black_box(&binding) + .check( + black_box(&store), + black_box(&view), + source.frames(), + black_box(&source), + ) + .is_ok() + }); + println!( + "lane_check k={k:<2} ns_per_check={ns:.2} ns_per_lane={:.2}", + ns / k as f64 + ); + } + + let binding = bind(&store, &view, &source, &frame, route, 1); + let mut swapped = View::sixteen(); + swapped.0.swap(0, 1); + let refused = binding.check(&store, &swapped, source.frames(), &source); + assert!(matches!(refused, Err(LaneRefusal::PredicateChanged { .. }))); + let ns = time(|| { + black_box(&binding) + .check( + black_box(&store), + black_box(&swapped), + source.frames(), + black_box(&source), + ) + .is_err() + }); + println!("lane_check k=1 refused ns_per_check={ns:.2}"); +} diff --git a/crates/lance-graph-dt64/src/lane.rs b/crates/lance-graph-dt64/src/lane.rs index a0aa6e665..4f7cc98ed 100644 --- a/crates/lance-graph-dt64/src/lane.rs +++ b/crates/lance-graph-dt64/src/lane.rs @@ -6,7 +6,7 @@ //! owner says, through [`LaneSource`], which of its own lanes holds a field //! and in whose row order. [`BindStore::bind_lanes`] joins that answer with //! the class route and the source frame into a [`LaneBinding`], which holds -//! addresses only, never a value. +//! addresses and the bound predicate identities, never a value. //! //! The class route and the frame are checked as a pair: the route against //! this store's own route log, the frame against the [`FrameStore`] that @@ -131,8 +131,14 @@ pub enum LaneRefusal { /// The store binds the class over another basis than the one this binding /// was made against. A store never changes a bound class (D5), so this /// fires only when the binding is checked against ANOTHER store; it says - /// nothing about the view, which the hot check does not re-hash. + /// nothing about the view; a view that changed at a bound position is + /// `PredicateChanged`. StoreBasisMismatch { class: u32 }, + /// The view names another predicate at a bound position than the one the + /// binding was made over. Compared per bound field by `predicate_iri`, + /// without hashing; a label-only rename does not fire it, and a change + /// at a position the binding did not bind does not either. + PredicateChanged { class: u32, field: u8 }, /// The source maps the field to another lane than it did at bind. MappingChanged { field: u8 }, /// The source no longer holds the lane. @@ -148,7 +154,8 @@ impl From for LaneRefusal { } /// A validated set of lane addresses for one class under one route and one -/// frame. Holds addresses only. +/// frame. Holds addresses and, per bound field, the predicate identity it was +/// bound over. Never a value. #[derive(Debug, Clone)] pub struct LaneBinding { class: u32, @@ -159,6 +166,10 @@ pub struct LaneBinding { selection: CoordSpace, fields: WideFieldMask, lanes: Vec<(u8, LaneAddr)>, + /// Parallel to `lanes`: the `predicate_iri` each bound field had in the + /// witnessed basis at bind. An identity, never a value. The hot check + /// compares the view's field at each bound position against it. + predicates: Vec>, /// The witness of the basis the class was bound with when this binding /// was made: one `u128`, compared on the hot path without hashing. basis: crate::BasisDigest, @@ -189,6 +200,7 @@ impl BindStore { .bound_basis(class) .ok_or(Refusal::UnknownClass(class))?; let mut lanes = Vec::with_capacity(req.fields.count() as usize); + let mut predicates = Vec::with_capacity(req.fields.count() as usize); for field in 0..=u8::MAX { if !req.fields.has(field) { continue; @@ -201,6 +213,7 @@ impl BindStore { .ok_or(LaneRefusal::Unbound { class, field })?; admit_lane(field, &addr, req.scope, req.selection)?; lanes.push((field, addr)); + predicates.push(Box::::from(field_ref.predicate_iri.as_str())); } Ok(LaneBinding { class, @@ -211,6 +224,7 @@ impl BindStore { selection: req.selection, fields: req.fields.clone(), lanes, + predicates, basis, }) } @@ -296,14 +310,15 @@ impl LaneBinding { /// Hot: the bind's checks again, without allocating and without hashing /// the basis. The frame against its store; the route against the store's /// CURRENT version (a re-route since the bind is refused); the store's - /// basis witness against the one recorded at bind (a `u128` compare); the - /// view's basis at the bound revision by revision and field count only; - /// and every lane against what the source maps now. + /// basis witness against the one recorded at bind (one `u128`); the + /// view's basis at the bound revision by revision and field count; each + /// bound position's `predicate_iri` against the one recorded at bind (a + /// string compare, no hashing); and every lane against what the source + /// maps now. /// - /// NOT checked here: that `view` still has the ordered predicates it had - /// at bind. A same-count reorder in the view after the bind is caught only - /// when the source maps lanes by predicate (`MappingChanged`); a - /// positional source passes it. Re-bind after any basis change. + /// NOT checked here: positions the binding did not bind. A same-count + /// change confined to unbound positions passes this check and is refused + /// by `BindStore::resolve`. pub fn check( &self, store: &BindStore, @@ -314,18 +329,17 @@ impl LaneBinding { source_owns(frames, source)?; frames.check(&self.frame).map_err(LaneRefusal::Frame)?; current_route(store, self.route.position, self.class)?; - // Revision and count only: the ordered predicate witness was checked - // when the binding was made (bind_lanes resolves through the - // witnessed `resolve`), and re-hashing the basis here would put - // schema hashing on the hot path. A basis that changes order after - // the bind is caught below only when the source maps lanes by - // predicate (D-LANESOCK-3, `MappingChanged`); re-bind after any - // basis change. (D-DT64-7 L5d decides whether the R2IL arm re-binds.) + // Revision and count here: the whole-basis witness was checked when + // the binding was made (bind_lanes resolves through the witnessed + // `resolve`), and re-hashing the basis would put schema hashing on + // the hot path. The bound positions are compared below, each against + // the predicate recorded at bind, so a reorder that moves a bound + // field is refused even when the source maps lanes by position. let (fields, bound) = store.resolve_counted(view, self.class, self.rev)?; if bound != self.basis { return Err(LaneRefusal::StoreBasisMismatch { class: self.class }); } - for (field, addr) in &self.lanes { + for ((field, addr), bound_iri) in self.lanes.iter().zip(&self.predicates) { let field = *field; let field_ref = fields .get(usize::from(field)) @@ -343,6 +357,12 @@ impl LaneBinding { }) } } + if field_ref.predicate_iri.as_str() != &**bound_iri { + return Err(LaneRefusal::PredicateChanged { + class: self.class, + field, + }); + } admit_lane(field, addr, self.scope, self.selection)?; } Ok(()) diff --git a/crates/lance-graph-dt64/tests/lane_socket.rs b/crates/lance-graph-dt64/tests/lane_socket.rs index 4a22ad8a0..b2f0c09c2 100644 --- a/crates/lance-graph-dt64/tests/lane_socket.rs +++ b/crates/lance-graph-dt64/tests/lane_socket.rs @@ -408,21 +408,131 @@ fn a_binding_checked_against_another_store_with_another_basis_refuses() { .is_ok()); } -/// FINDING (L5d): the hot check does not re-hash the view, so a same-count -/// reorder of the VIEW after the bind passes it when the source maps lanes by -/// position (this fixture's `Source` does). `resolve` through the same view -/// refuses. Re-bind after a basis change; L5d decides whether R2IL must. +/// Closes the L5a FINDING +/// `finding_the_hot_check_does_not_see_a_view_reorder_under_a_positional_source` +/// (plan gap G7): a same-count reorder of the view after the bind, at a bound +/// position, is refused by the hot check even when the source maps by +/// position. #[test] -fn finding_the_hot_check_does_not_see_a_view_reorder_under_a_positional_source() { +fn a_view_reorder_at_a_bound_position_is_refused_under_a_positional_source() { let w = world(); let mask = WideFieldMask::from_positions(&[0]); let b = bind(&w, &mask).unwrap(); let mut swapped = View::three(); swapped.0.swap(0, 1); assert!(w.store.resolve(&swapped, HYBRID, SchemaRev(1)).is_err()); - assert!(b - .check(&w.store, &swapped, &w.source.frames, &w.source) - .is_ok()); + let refusal = LaneRefusal::PredicateChanged { + class: HYBRID, + field: 0, + }; + assert_eq!( + b.check(&w.store, &swapped, &w.source.frames, &w.source), + Err(refusal.clone()) + ); + w.source.borrows.set(0); + assert_eq!( + b.lane(&w.store, &swapped, &w.source.frames, &w.source, 0), + Err(refusal) + ); + assert_eq!(w.source.borrows.get(), 0, "nothing is borrowed"); +} + +/// An unchanged view admits every bound lane, and the compare is by content, +/// not by address: a separately built view with equal content passes too. +#[test] +fn an_unchanged_view_admits_every_bound_lane() { + let w = world(); + let b = bind(&w, &WideFieldMask::from_positions(&[0, 1])).unwrap(); + assert_eq!( + b.check(&w.store, &w.view, &w.source.frames, &w.source), + Ok(()) + ); + let rebuilt = View::three(); + assert_eq!( + b.check(&w.store, &rebuilt, &w.source.frames, &w.source), + Ok(()) + ); + let n = b + .borrow_all(&w.store, &rebuilt, &w.source.frames, &w.source) + .unwrap() + .count(); + assert_eq!(n, 2); +} + +/// A label is not identity (Decision J item 4): renaming a bound field's label +/// leaves the binding admitted. +#[test] +fn a_label_only_rename_at_a_bound_position_admits() { + let w = world(); + let b = bind(&w, &WideFieldMask::from_positions(&[0])).unwrap(); + let mut renamed = View::three(); + renamed.0[0].label = "Abteilung".to_string(); + assert!(w.store.resolve(&renamed, HYBRID, SchemaRev(1)).is_ok()); + assert_eq!( + b.check(&w.store, &renamed, &w.source.frames, &w.source), + Ok(()) + ); +} + +/// The accepted residual: a reorder that leaves every bound position naming +/// the same predicate is admitted hot. The bound position's meaning is +/// unchanged, so the binding's mapping and interpretation hold; the +/// whole-basis witness is checked at bind and at `resolve` (which refuses +/// this view), never on the hot path. +#[test] +fn a_reorder_outside_the_bound_positions_admits_hot_and_is_refused_cold() { + let w = world(); + let b = bind(&w, &WideFieldMask::from_positions(&[0])).unwrap(); + let mut swapped = View::three(); + swapped.0.swap(1, 2); + assert!(w.store.resolve(&swapped, HYBRID, SchemaRev(1)).is_err()); + assert_eq!( + b.check(&w.store, &swapped, &w.source.frames, &w.source), + Ok(()) + ); +} + +/// The refusal names the first bound position whose predicate changed. +#[test] +fn the_refusal_names_the_first_changed_bound_position() { + let w = world(); + let b = bind(&w, &WideFieldMask::from_positions(&[0, 1])).unwrap(); + + let mut replaced = View::three(); + replaced.0[1] = FieldRef::new("ad:manager", "Manager"); + assert_eq!( + b.check(&w.store, &replaced, &w.source.frames, &w.source), + Err(LaneRefusal::PredicateChanged { + class: HYBRID, + field: 1, + }) + ); + + let mut swapped = View::three(); + swapped.0.swap(0, 1); + assert_eq!( + b.check(&w.store, &swapped, &w.source.frames, &w.source), + Err(LaneRefusal::PredicateChanged { + class: HYBRID, + field: 0, + }) + ); +} + +/// The very view object the binding was made through, mutated in place, is +/// refused: no address-identity shortcut. +#[test] +fn a_view_mutated_in_place_after_the_bind_is_refused() { + let mut w = world(); + let b = bind(&w, &WideFieldMask::from_positions(&[0])).unwrap(); + w.view.0.swap(0, 1); + assert_eq!( + b.check(&w.store, &w.view, &w.source.frames, &w.source), + Err(LaneRefusal::PredicateChanged { + class: HYBRID, + field: 0, + }) + ); } /// `borrow_all` hands out the source's own lanes, in field order, the same From 08eb7c3bc0910163e0d0506b1999209ead4d1913 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 19:46:45 +0000 Subject: [PATCH 2/2] plan: S8 wording follows the shipped predicate comparison Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPJjVvasjwqhjJ6c9p --- .claude/plans/dr-widefieldmask-dt-classview-v1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/plans/dr-widefieldmask-dt-classview-v1.md b/.claude/plans/dr-widefieldmask-dt-classview-v1.md index b3fab8fb4..9f8756ca8 100644 --- a/.claude/plans/dr-widefieldmask-dt-classview-v1.md +++ b/.claude/plans/dr-widefieldmask-dt-classview-v1.md @@ -384,7 +384,7 @@ the question. | S5 | Bind-time admission for the four direct paths, with the executor census test | v2 cut 4; W2, W13 | EXO-253, EXO-245, EXO-246 | red on a planted call site | needs council | | S6 | Behaviour as classes that outlives the process: binding digest recorded with the execution and the DR version; typed lane interpretation from the class | v2 cut 7; W5; G5 | EXO-199, EXO-225, EXO-221, EXO-202, EXO-247 | replay falsifier (row 17) red first | needs council | | S7 | The versioned DR × DT mappings over D-NFR-1 (identities, properties, source positions, class binding, criteria), keyed onto the shipped `MapTable` log (D-DR-VMAP, #1923, `RouteVersion`), no new store instance, handed out by reference; contents stay with the source owner; per-request masks stay outside | §1b; W5 | EXO-253, EXO-220 | D-NFR-1 first: bind, seal, execute, advance one revision, gates: mapping N replayed reproduces the same assignment (identities, properties, source positions) bit for bit; over unchanged contents the result is bit-equal too; over changed contents with unchanged positions the assignment is unchanged and the values follow the source; a mapping whose positions or identities do not match the source refuses; then the same for a mapping kept in the store; authorization checked independently | lab code | -| S8 | Close the reorder gap on the hot check (D-DT64-7 L5d), keyed on revision, not a digest | W2 | EXO-199 | the pinned test turns red-to-green; an unchanged view still admits; at most a few ns per check | dt64 | +| S8 | Close the reorder gap on the hot check (D-DT64-7 L5d), by comparing the predicate IRI recorded at bind time at each bound position (no hashing on the hot path; a revision key alone cannot see a reorder that keeps the field count) | W2 | EXO-199 | the pinned test turns red-to-green; an unchanged view and a label-only rename still admit; check cost measured (whole check, not the increment) | dt64 | Order: S1 (finish) and S2 now. S4 next (a consumer is waiting on EXO-243 and EXO-244). Then D-NFR-1 and S7, S3 and S8 in parallel. S5 and S6 need