From cfad99817c184b99f9c211df58b0a93cb88672b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:39:49 +0000 Subject: [PATCH 1/8] lab(D-NFR-6): validate once, then run native ops over a lifetime-borrowed view dr-validated-view-probe: a Table owns the data, the FrameStore and the focus map together; every change takes &mut. validate() runs the existing bind_dr64 once and returns a View<'t> whose count/sum/min/ max/count_gt call ndarray::simd directly, with no guard. Compile-fail doctests (pinned E0502/E0597): no advance, refocus or revoke while a view lives, no view outliving its table, no borrow escaping as 'static. Runtime falsifiers: stale, resealed and foreign frames, refocus and revocation after the borrow, scope from the grant, the shared-revocation escape (SharedView checks one epoch per op), and the typed semantic refusals. Records that bind_dr64 checks the frame, not the bytes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/dr-validated-view-probe/Cargo.toml | 20 + crates/dr-validated-view-probe/src/lib.rs | 489 ++++++++++++++++++ .../tests/falsifiers.rs | 240 +++++++++ 3 files changed, 749 insertions(+) create mode 100644 crates/dr-validated-view-probe/Cargo.toml create mode 100644 crates/dr-validated-view-probe/src/lib.rs create mode 100644 crates/dr-validated-view-probe/tests/falsifiers.rs diff --git a/crates/dr-validated-view-probe/Cargo.toml b/crates/dr-validated-view-probe/Cargo.toml new file mode 100644 index 000000000..480c2fe86 --- /dev/null +++ b/crates/dr-validated-view-probe/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dr-validated-view-probe" +version = "0.0.0" +edition = "2024" +publish = false +description = "D-NFR-6 lab probe: validate a DR64 binding once, then run several native ndarray operations over a lifetime-borrowed view; falsifiers for stale frame, focus, scope and semantics; guard cost breakdown. Lab only: no production contract or ABI." + +# Own workspace root, like dr-math-binding-probe, which it path-deps. +[workspace] + +[dependencies] +# The existing validator (bind_dr64, FrameStore, Guards, refusals), reused, +# not reimplemented. +dr-math-binding-probe = { path = "../dr-math-binding-probe" } +lance-graph-contract = { path = "../lance-graph-contract" } +# The native kernels. Same source string as mask-risc's copy. +ndarray = { git = "https://github.com/AdaWorldAPI/ndarray", branch = "master" } + +[patch."https://github.com/AdaWorldAPI/lance-graph"] +lance-graph-contract = { path = "../lance-graph-contract" } diff --git a/crates/dr-validated-view-probe/src/lib.rs b/crates/dr-validated-view-probe/src/lib.rs new file mode 100644 index 000000000..8167cd8b8 --- /dev/null +++ b/crates/dr-validated-view-probe/src/lib.rs @@ -0,0 +1,489 @@ +//! D-NFR-6: validate a DR64 binding once, then run several native operations +//! over a view whose lifetime keeps the binding valid. Lab only. +//! +//! Three responsibilities, kept apart: +//! +//! - **Method resolution.** Which native function implements an operation is +//! fixed per method and resolved once ([`KERNELS`], a `LazyLock`). Nothing +//! here chooses a route per call. +//! - **Data binding.** Whether a frame, focus and scope still hold. This can go +//! stale, so it is validated, by the existing `bind_dr64` and nothing new, +//! when a [`View`] is acquired. +//! - **Execution.** A [`View`]'s operations call `ndarray::simd` directly on +//! borrowed data and validate nothing. +//! +//! What makes skipping validation per operation sound is not a cache but +//! ownership. A [`Table`] owns its data, its [`FrameStore`] and its focus map +//! together. Every change to any of them needs `&mut Table`, and a [`View`] +//! holds `&Table` for its whole life. So while a view exists none of them can +//! change, and that is checked by the compiler, not at run time: +//! +//! ```compile_fail,E0502 +//! use dr_validated_view_probe::fixture::*; +//! let (mut t, g, m, guards) = setup(64); +//! let view = validate_presence(&t, &g, m, &guards).unwrap(); +//! t.advance(revisions(2), data(64, 5)); // F1: the source cannot move under a view +//! drop(view); +//! ``` +//! +//! ```compile_fail,E0502 +//! use dr_validated_view_probe::fixture::*; +//! let (mut t, g, m, guards) = setup(64); +//! let view = validate_presence(&t, &g, m, &guards).unwrap(); +//! t.refocus([0; 64]); // F2: nor can the focus map +//! drop(view); +//! ``` +//! +//! ```compile_fail,E0502 +//! use dr_validated_view_probe::fixture::*; +//! let (t, mut g, m, guards) = setup(64); +//! let view = validate_presence(&t, &g, m, &guards).unwrap(); +//! g.revoke(); // F3, owned grants: nor can the scope be revoked +//! drop(view); +//! ``` +//! +//! ```compile_fail,E0597 +//! use dr_validated_view_probe::fixture::*; +//! let leaked; +//! { +//! let (t, g, m, guards) = setup(64); +//! leaked = validate_presence(&t, &g, m, &guards).unwrap(); +//! } // F4: a view cannot outlive the table it was validated against +//! let _ = leaked.count(); +//! ``` +//! +//! ```compile_fail,E0597 +//! use dr_validated_view_probe::fixture::*; +//! let (t, g, m, guards) = setup(64); +//! let view = validate_presence(&t, &g, m, &guards).unwrap(); +//! let rows: &'static [u64; 64] = view.rows(); // F7: a borrow cannot escape as 'static +//! ``` +//! +//! What ownership does NOT cover, and where the runtime check therefore stays: +//! +//! - **Revocation owned elsewhere.** If scope revocation is owned by another +//! component (an ACL service, an `Arc` shared across threads), the view's +//! borrow cannot stop it. [`SharedGrants`] models that: revocation is an +//! atomic, and a [`SharedView`] reads one epoch per operation instead of +//! re-running the whole guard. +//! - **Data outside the table.** `bind_dr64` accepts any borrowed operand +//! with a valid frame. Its frame seal identifies a revision issue, not the +//! bytes; coupling bytes to the frame is what [`Table`] adds. Data handed in +//! from outside a `Table` keeps the per-use check. +#![forbid(unsafe_code)] + +use std::sync::LazyLock; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub use dr_math_binding_probe::{Focus, Frame}; +use dr_math_binding_probe::{ + FrameRevisions, FrameStore, Guards, MethodSignature, Operand, Refusal, Scope, bind_dr64, +}; + +/// Rows covered by one DR64 register: 64 words of 64 bits. +pub const MAX_ROWS: usize = 64 * 64; + +/// The data a frame refers to: a DR64 selection register and the `i32` value +/// lane it selects from. Owned by a [`Table`] and changed only through +/// [`Table::advance`]. +#[derive(Clone, Debug)] +pub struct Data { + pub rows: [u64; 64], + pub values: Vec, +} + +/// Owner of the data, the frame store and the local-to-global focus map. Each +/// change goes through `&mut self`, so it cannot happen while a [`View`] +/// borrows the table. +#[derive(Debug)] +pub struct Table { + store: FrameStore, + data: Data, + focus_revision: u64, + local_to_global: [u32; 64], +} + +impl Table { + /// # Panics + /// If `data.values` is longer than [`MAX_ROWS`]. + pub fn new( + revisions: FrameRevisions, + data: Data, + focus_revision: u64, + local_to_global: [u32; 64], + ) -> Self { + assert!( + data.values.len() <= MAX_ROWS, + "more rows than one DR64 register" + ); + Self { + store: FrameStore::new(revisions), + data, + focus_revision, + local_to_global, + } + } + + pub fn seal(&self) -> Frame { + self.store.seal() + } + + pub fn focus(&self) -> Focus { + Focus { + revision: self.focus_revision, + } + } + + pub fn store(&self) -> &FrameStore { + &self.store + } + + pub fn local_to_global(&self) -> &[u32; 64] { + &self.local_to_global + } + + /// Replace the data and its revisions together, so new bytes never sit + /// under an old frame. + /// + /// # Panics + /// If `data.values` is longer than [`MAX_ROWS`]. + pub fn advance(&mut self, revisions: FrameRevisions, data: Data) { + assert!( + data.values.len() <= MAX_ROWS, + "more rows than one DR64 register" + ); + self.store.advance(revisions); + self.data = data; + } + + /// Replace the focus map. The table issues the next revision itself: + /// focus identity is a revision number, so a caller-chosen number could + /// change the map while keeping the revision, and nothing downstream + /// would see it. Even an identical map gets a new revision. + pub fn refocus(&mut self, local_to_global: [u32; 64]) { + self.focus_revision += 1; + self.local_to_global = local_to_global; + } +} + +/// Scope grants owned by the caller: revoking needs `&mut self`, so it cannot +/// happen while a [`View`] borrows them. +#[derive(Debug)] +pub struct Grants { + scope: Option, +} + +impl Grants { + pub fn new(scope: Scope) -> Self { + Self { scope: Some(scope) } + } + + pub fn current(&self) -> Option { + self.scope + } + + pub fn revoke(&mut self) { + self.scope = None; + } +} + +/// Scope grants owned elsewhere and revocable through `&self`, as when they +/// are shared through an `Arc`. A borrow cannot prevent revocation here; see +/// [`SharedView`]. +#[derive(Debug)] +pub struct SharedGrants { + scope: Scope, + /// Even: granted. Odd: revoked. Bumped by every revocation. + epoch: AtomicU64, +} + +impl SharedGrants { + pub fn new(scope: Scope) -> Self { + Self { + scope, + epoch: AtomicU64::new(0), + } + } + + pub fn revoke(&self) { + self.epoch.fetch_or(1, Ordering::Release); + } + + fn current(&self) -> Option<(Scope, u64)> { + let e = self.epoch.load(Ordering::Acquire); + (e & 1 == 0).then_some((self.scope, e)) + } +} + +/// Why a view was not issued or an operation did not answer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ViewRefusal { + /// The existing validator's refusal, unchanged. + Bind(Refusal), + /// The grants no longer carry a scope. + ScopeRevoked, +} + +impl From for ViewRefusal { + fn from(r: Refusal) -> Self { + ViewRefusal::Bind(r) + } +} + +// ── method resolution, once ──────────────────────────────────────────────── + +/// The native kernels a view runs, resolved once. A copied `fn` pointer +/// captures nothing, so it cannot hold a view's borrow past a call. +#[derive(Clone, Copy)] +pub struct Kernels { + pub count: fn(&[u64]) -> u64, + pub sum: fn(&[i32], &[u64]) -> i64, + pub min: fn(&[i32], &[u64]) -> Option, + pub max: fn(&[i32], &[u64]) -> Option, + pub gt: fn(&[i32], i32, &mut [u64]), + pub and: fn(&[u64], &[u64], &mut [u64]), +} + +pub static KERNELS: LazyLock = LazyLock::new(|| Kernels { + count: ndarray::simd::popcount_batch_u64, + sum: ndarray::simd::masked_sum_i32, + min: ndarray::simd::masked_min_i32, + max: ndarray::simd::masked_max_i32, + gt: ndarray::simd::gt_i32_to_mask, + and: ndarray::simd::mask_and, +}); + +// ── acquisition: the one validation ─────────────────────────────────────── + +/// Validate once and return a view. This is the only check: the existing +/// `bind_dr64` (method, axes, semantics, validity, frame, focus, scope), +/// with the focus taken from the table and the scope from the grants rather +/// than from the caller. +pub fn validate<'t>( + table: &'t Table, + grants: &'t Grants, + declared: MethodSignature, + requested: MethodSignature, + bound_at: &Guards, +) -> Result, ViewRefusal> { + let scope = grants.current().ok_or(ViewRefusal::ScopeRevoked)?; + let rows = bind_rows(table, declared, requested, bound_at, scope)?; + Ok(View { + rows, + values: &table.data.values, + kernels: *KERNELS, + _borrow: std::marker::PhantomData, + }) +} + +/// [`validate`] against grants another owner can revoke. The view records the +/// grant epoch and checks it on every operation. +pub fn validate_shared<'t>( + table: &'t Table, + grants: &'t SharedGrants, + declared: MethodSignature, + requested: MethodSignature, + bound_at: &Guards, +) -> Result, ViewRefusal> { + let (scope, epoch) = grants.current().ok_or(ViewRefusal::ScopeRevoked)?; + let rows = bind_rows(table, declared, requested, bound_at, scope)?; + Ok(SharedView { + view: View { + rows, + values: &table.data.values, + kernels: *KERNELS, + _borrow: std::marker::PhantomData, + }, + grants, + epoch, + }) +} + +fn bind_rows<'t>( + table: &'t Table, + declared: MethodSignature, + requested: MethodSignature, + bound_at: &Guards, + scope: Scope, +) -> Result<&'t [u64; 64], ViewRefusal> { + let bound = bind_dr64( + &table.store, + declared, + requested, + bound_at, + (table.focus(), scope), + Operand { + rows: &table.data.rows, + validity: None, + }, + )?; + // The view passes no validity plane, so `bind_dr64` refuses every + // three-valued method (`ThreeValuedNeedsValidity`) and every Bag + // (`BagNeedsMultiplicity`): only two-valued Distinct reaches here. + Ok(bound.rows) +} + +// ── execution: no validation ─────────────────────────────────────────────── + +/// A validated, lifetime-borrowed binding. Its operations call the resolved +/// native kernels directly and check nothing; the borrow of the table and the +/// grants is what keeps the validation true. +#[derive(Clone, Copy)] +pub struct View<'t> { + rows: &'t [u64; 64], + values: &'t [i32], + kernels: Kernels, + _borrow: std::marker::PhantomData<&'t ()>, +} + +impl<'t> View<'t> { + pub fn rows(&self) -> &'t [u64; 64] { + self.rows + } + + pub fn values(&self) -> &'t [i32] { + self.values + } + + fn words(&self) -> &'t [u64] { + &self.rows[..self.values.len().div_ceil(64)] + } + + /// `COUNT(*)` of the selection. + pub fn count(&self) -> u64 { + (self.kernels.count)(self.words()) + } + + /// `SUM(v)` over the selection. + pub fn sum(&self) -> i64 { + (self.kernels.sum)(self.values, self.words()) + } + + /// `MIN(v)` over the selection; `None` for an empty selection. + pub fn min(&self) -> Option { + (self.kernels.min)(self.values, self.words()) + } + + /// `MAX(v)` over the selection; `None` for an empty selection. + pub fn max(&self) -> Option { + (self.kernels.max)(self.values, self.words()) + } + + /// `COUNT(*) WHERE v > t` within the selection: one mask comparison, one + /// mask AND, one count, into the caller's scratch (two masks of + /// `words` words). No allocation. + /// + /// # Panics + /// If `scratch` holds fewer than two masks' worth of words. + pub fn count_gt(&self, t: i32, scratch: &mut [u64]) -> u64 { + let w = self.words().len(); + let (cmp, rest) = scratch.split_at_mut(w); + let both = &mut rest[..w]; + (self.kernels.gt)(self.values, t, cmp); + (self.kernels.and)(cmp, self.words(), both); + (self.kernels.count)(both) + } +} + +/// A view whose scope can be revoked by another owner. Each operation loads +/// the grant epoch once and refuses if it moved; frame and focus are still +/// held by the table borrow and are not rechecked. +pub struct SharedView<'t> { + view: View<'t>, + grants: &'t SharedGrants, + epoch: u64, +} + +impl<'t> SharedView<'t> { + #[inline] + fn live(&self) -> Result<&View<'t>, ViewRefusal> { + if self.grants.epoch.load(Ordering::Acquire) == self.epoch { + Ok(&self.view) + } else { + Err(ViewRefusal::ScopeRevoked) + } + } + + pub fn count(&self) -> Result { + Ok(self.live()?.count()) + } + + pub fn sum(&self) -> Result { + Ok(self.live()?.sum()) + } + + pub fn min(&self) -> Result, ViewRefusal> { + Ok(self.live()?.min()) + } + + pub fn max(&self) -> Result, ViewRefusal> { + Ok(self.live()?.max()) + } + + pub fn count_gt(&self, t: i32, scratch: &mut [u64]) -> Result { + Ok(self.live()?.count_gt(t, scratch)) + } +} + +/// Shared fixtures for the tests, the example and the compile-fail doctests. +pub mod fixture { + use super::*; + use dr_math_binding_probe::{Axis, AxisKind}; + use lance_graph_contract::class_view::ClassId; + + pub fn revisions(source: u64) -> FrameRevisions { + FrameRevisions { + source, + relation_map: 1, + coordinates: 1, + } + } + + /// `n` rows; the selection keeps rows `r % 3 != 1`; values are signed. + pub fn data(n: usize, salt: i32) -> Data { + let mut rows = [0u64; 64]; + for r in (0..n).filter(|r| r % 3 != 1) { + rows[r / 64] |= 1 << (r % 64); + } + Data { + rows, + values: (0..n) + .map(|i| ((i as i32 * 37 + salt) % 201) - 100) + .collect(), + } + } + + pub fn method(ordinal: u16) -> MethodSignature { + let axis = |kind| Axis { + class: 7 as ClassId, + kind, + }; + MethodSignature::presence( + 7, + ordinal, + axis(AxisKind::Identity), + axis(AxisKind::Property), + ) + } + + /// A table over `n` rows, grants for scope 3, the method, and the guards + /// it was bound under (frame sealed now, focus 7, scope 3). + pub fn setup(n: usize) -> (Table, Grants, MethodSignature, Guards) { + let t = Table::new(revisions(1), data(n, 0), 7, [0; 64]); + let guards = Guards { + frame: t.seal(), + focus: t.focus(), + scope: Scope { id: 3 }, + }; + (t, Grants::new(Scope { id: 3 }), method(1), guards) + } + + pub fn validate_presence<'t>( + t: &'t Table, + g: &'t Grants, + m: MethodSignature, + guards: &Guards, + ) -> Result, ViewRefusal> { + validate(t, g, m, m, guards) + } +} diff --git a/crates/dr-validated-view-probe/tests/falsifiers.rs b/crates/dr-validated-view-probe/tests/falsifiers.rs new file mode 100644 index 000000000..a5c54281b --- /dev/null +++ b/crates/dr-validated-view-probe/tests/falsifiers.rs @@ -0,0 +1,240 @@ +//! D-NFR-6 runtime falsifiers. The compile-time ones (F1, F2, F3 for owned +//! grants, F4 outliving, F7 escaping borrow) are `compile_fail` doctests in +//! `src/lib.rs`; this file covers what happens once the borrow ends, the +//! shared-revocation escape, and the semantic refusals. + +use dr_math_binding_probe::{ + Axis, AxisKind, Guards, MethodSignature, Operand, Population, Refusal, Scope, Truth, bind_dr64, +}; +use dr_validated_view_probe::fixture::{data, method, revisions, setup, validate_presence}; +use dr_validated_view_probe::{SharedGrants, Table, ViewRefusal, validate, validate_shared}; + +/// The answers an independent row loop gives: count, sum, min, max, count(v > t). +fn oracle(rows: &[u64; 64], v: &[i32], t: i32) -> (u64, i64, Option, Option, u64) { + let sel = |r: usize| rows[r / 64] >> (r % 64) & 1 == 1; + let kept: Vec = (0..v.len()).filter(|&r| sel(r)).map(|r| v[r]).collect(); + ( + kept.len() as u64, + kept.iter().map(|&x| i64::from(x)).sum(), + kept.iter().copied().min(), + kept.iter().copied().max(), + kept.iter().filter(|&&x| x > t).count() as u64, + ) +} + +const SIZES: [usize; 8] = [1, 2, 63, 64, 65, 1000, 4095, 4096]; + +/// FAILS IF: a view's five operations disagree with the direct ndarray calls +/// or with the row loop, at any size. +#[test] +fn view_answers_equal_direct_ndarray_and_the_row_loop() { + for n in SIZES { + let (t, g, m, guards) = setup(n); + let view = validate_presence(&t, &g, m, &guards).unwrap(); + let w = n.div_ceil(64); + let (rows, v) = (view.rows(), view.values()); + let mut scratch = vec![0u64; 2 * w]; + for thr in [-101, -3, 0, 50, 100] { + let want = oracle(rows, v, thr); + let got = ( + view.count(), + view.sum(), + view.min(), + view.max(), + view.count_gt(thr, &mut scratch), + ); + assert_eq!(got, want, "n={n} t={thr}"); + let mut cmp = vec![0u64; w]; + let mut both = vec![0u64; w]; + ndarray::simd::gt_i32_to_mask(v, thr, &mut cmp); + ndarray::simd::mask_and(&cmp, &rows[..w], &mut both); + let direct = ( + ndarray::simd::popcount_batch_u64(&rows[..w]), + ndarray::simd::masked_sum_i32(v, &rows[..w]), + ndarray::simd::masked_min_i32(v, &rows[..w]), + ndarray::simd::masked_max_i32(v, &rows[..w]), + ndarray::simd::popcount_batch_u64(&both), + ); + assert_eq!(got, direct, "n={n} t={thr}"); + } + } +} + +/// FAILS IF: the fixture is vacuous: the selection is all or nothing, or the +/// threshold never splits it. +#[test] +fn the_fixture_is_not_vacuous() { + let (t, g, m, guards) = setup(4096); + let view = validate_presence(&t, &g, m, &guards).unwrap(); + let c = view.count(); + assert!(c > 0 && c < 4096); + assert!(view.min() < view.max()); + let mut s = vec![0u64; 128]; + let k = view.count_gt(0, &mut s); + assert!(k > 0 && k < c); +} + +/// FAILS IF: after the borrow ends, a source advance, a refocus or a revoked +/// scope still lets the old guards validate. The silent twin: the same +/// table validates again under freshly taken guards. +#[test] +fn after_the_borrow_ends_every_change_is_refused_at_acquisition() { + let (mut t, mut g, m, guards) = setup(64); + drop(validate_presence(&t, &g, m, &guards).unwrap()); + + t.advance(revisions(2), data(64, 9)); + assert_eq!( + validate_presence(&t, &g, m, &guards).err(), + Some(ViewRefusal::Bind(Refusal::SourceChanged)) + ); + let fresh = Guards { + frame: t.seal(), + ..guards.clone() + }; + let v = validate_presence(&t, &g, m, &fresh).unwrap(); + assert_eq!(v.values(), &data(64, 9).values[..]); + + // A refocus with an identical map still moves the focus revision. + let map = *t.local_to_global(); + t.refocus(map); + assert_eq!( + validate_presence(&t, &g, m, &fresh).err(), + Some(ViewRefusal::Bind(Refusal::FocusChanged)) + ); + let fresh = Guards { + focus: t.focus(), + ..fresh + }; + assert!(validate_presence(&t, &g, m, &fresh).is_ok()); + + g.revoke(); + assert_eq!( + validate_presence(&t, &g, m, &fresh).err(), + Some(ViewRefusal::ScopeRevoked) + ); +} + +/// FAILS IF: re-advancing to the same numbers keeps old frames valid, or a +/// frame from another table with equal numbers is accepted. +#[test] +fn a_resealed_or_foreign_frame_is_refused() { + let (mut t, g, m, guards) = setup(64); + t.advance(revisions(1), data(64, 0)); // same numbers, new issue + assert_eq!( + validate_presence(&t, &g, m, &guards).err(), + Some(ViewRefusal::Bind(Refusal::FrameResealed)) + ); + let other = Table::new(revisions(1), data(64, 0), 7, [0; 64]); + let foreign = Guards { + frame: other.seal(), + ..guards + }; + assert_eq!( + validate_presence(&t, &g, m, &foreign).err(), + Some(ViewRefusal::Bind(Refusal::ForeignFrame)) + ); +} + +/// FAILS IF: scope is treated as a number to compare instead of a grant. +/// Guards for scope 3 under grants for scope 4 are refused by the existing +/// check; a revoked grant is refused before it. +#[test] +fn scope_comes_from_the_grant_not_from_the_caller() { + let (t, _, m, guards) = setup(64); + let other = dr_validated_view_probe::Grants::new(Scope { id: 4 }); + assert_eq!( + validate(&t, &other, m, m, &guards).err(), + Some(ViewRefusal::Bind(Refusal::ScopeChanged)) + ); +} + +/// F5. FAILS IF: a revocation by another owner, during a live view, goes +/// unnoticed. The borrow cannot stop it, so the shared view checks the grant +/// epoch on each operation. Before revocation it answers. +#[test] +fn shared_revocation_during_a_view_is_refused_per_operation() { + let (t, _, m, guards) = setup(256); + let g = SharedGrants::new(Scope { id: 3 }); + let sv = validate_shared(&t, &g, m, m, &guards).unwrap(); + let want = oracle(&data(256, 0).rows, &data(256, 0).values, 0); + assert_eq!(sv.count(), Ok(want.0)); + assert_eq!(sv.sum(), Ok(want.1)); + g.revoke(); // through &self, while `sv` is alive: it compiles + assert_eq!(sv.count(), Err(ViewRefusal::ScopeRevoked)); + assert_eq!(sv.max(), Err(ViewRefusal::ScopeRevoked)); + let mut s = vec![0u64; 8]; + assert_eq!(sv.count_gt(0, &mut s), Err(ViewRefusal::ScopeRevoked)); + assert_eq!( + validate_shared(&t, &g, m, m, &guards).err(), + Some(ViewRefusal::ScopeRevoked) + ); +} + +/// F6. FAILS IF: a view is issued for another method, other axes, other +/// semantics, a three-valued method without validity, or a Bag population. +/// Each existing refusal of `bind_dr64` comes through unchanged. +#[test] +fn semantic_mismatches_keep_their_typed_refusals() { + let (t, g, m, guards) = setup(64); + let refuse = |declared: MethodSignature, requested: MethodSignature| { + validate(&t, &g, declared, requested, &guards).err() + }; + assert_eq!( + refuse(m, method(2)), + Some(ViewRefusal::Bind(Refusal::MethodChanged)) + ); + let other_axis = MethodSignature { + column: Axis { + class: 9, + kind: AxisKind::Community, + }, + ..m + }; + assert_eq!( + refuse(m, other_axis), + Some(ViewRefusal::Bind(Refusal::AxisChanged)) + ); + let three = MethodSignature { + truth: Truth::ThreeValued, + ..m + }; + assert_eq!( + refuse(m, three), + Some(ViewRefusal::Bind(Refusal::SemanticsChanged)) + ); + assert_eq!( + refuse(three, three), + Some(ViewRefusal::Bind(Refusal::ThreeValuedNeedsValidity)) + ); + let bag = MethodSignature { + population: Population::Bag, + ..m + }; + assert_eq!( + refuse(bag, bag), + Some(ViewRefusal::Bind(Refusal::BagNeedsMultiplicity)) + ); +} + +/// Records the audit finding that motivates `Table`: the existing validator +/// checks the frame, not the bytes. Rows that no frame owner issued are +/// accepted under a valid frame. FAILS IF that changes, so the claim in the +/// crate docs is re-examined. +#[test] +fn bind_dr64_checks_the_frame_not_the_data() { + let (t, _, m, guards) = setup(64); + let unrelated = [u64::MAX; 64]; + let bound = bind_dr64( + t.store(), + m, + m, + &guards, + (t.focus(), guards.scope), + Operand { + rows: &unrelated, + validity: None, + }, + ) + .unwrap(); + assert!(std::ptr::eq(bound.rows, &unrelated)); +} From 25a749ae52e4ce459e3eab93f752bbdda9e15cb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:40:01 +0000 Subject: [PATCH 2/8] lab(D-NFR-6): View is Copy; end its borrow with let _ (clippy) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/dr-validated-view-probe/tests/falsifiers.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/dr-validated-view-probe/tests/falsifiers.rs b/crates/dr-validated-view-probe/tests/falsifiers.rs index a5c54281b..3c714b878 100644 --- a/crates/dr-validated-view-probe/tests/falsifiers.rs +++ b/crates/dr-validated-view-probe/tests/falsifiers.rs @@ -80,7 +80,8 @@ fn the_fixture_is_not_vacuous() { #[test] fn after_the_borrow_ends_every_change_is_refused_at_acquisition() { let (mut t, mut g, m, guards) = setup(64); - drop(validate_presence(&t, &g, m, &guards).unwrap()); + // A view is `Copy`; every copy carries the same borrow, which ends here. + let _ = validate_presence(&t, &g, m, &guards).unwrap(); t.advance(revisions(2), data(64, 9)); assert_eq!( From 67d74704c6c81f7462f56ed0517523d6f571d56d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:48:13 +0000 Subject: [PATCH 3/8] lab(D-NFR-6): guard cost split, asm reading, CI, plan and board rows examples/guard_cost.rs: arms A direct, B prebound, C bind_dr64 per op, C' the #1752 route guard per op, D one validate + view ops, D' shared view, E refused re-validation; the guard taken apart; allocations counted (0 everywhere). FrameStore::check is 25.7-28.3 ns of the 27-28 ns guard, and its cost is refcount traffic (six lock RMWs from Arc::downgrade temporaries), not comparisons. View ops equal direct ndarray within noise; View::sum is a tail call with no lock op. Plan d-nfr-6-validate-once-v1.md answers Q1-Q6 with the source audit; proposals (pointer-compare check, BoundDr64 borrowing its frame) are recorded, not implemented. CI workflow runs fmt, clippy, tests and the cost split (reported, not gated). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .claude/board/INTEGRATION_PLANS.md | 4 + .claude/board/STATUS_BOARD.md | 9 + .../2026-10-23-d-nfr-6-validate-once.md | 9 + .claude/board/entries/README.md | 3 +- .claude/plans/d-nfr-6-validate-once-v1.md | 208 ++++++++ .github/workflows/dr-validated-view-probe.yml | 68 +++ .../examples/guard_cost.rs | 498 ++++++++++++++++++ 7 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 .claude/board/entries/2026-10-23-d-nfr-6-validate-once.md create mode 100644 .claude/plans/d-nfr-6-validate-once-v1.md create mode 100644 .github/workflows/dr-validated-view-probe.yml create mode 100644 crates/dr-validated-view-probe/examples/guard_cost.rs diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index 233d3810c..69aaf62d2 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -1,3 +1,7 @@ +## 2026-10-23 — d-nfr-6-validate-once-v1 — validate a binding once, then execute without repeated guards → `.claude/plans/d-nfr-6-validate-once-v1.md` + +**Status:** MEASURED, lab only (`crates/dr-validated-view-probe`). The per-call 27–28 ns guard is `FrameStore::check`'s refcount traffic; a view validated once by the existing `bind_dr64`, and kept valid by borrowing the table that owns data, frame and focus, runs native operations at direct cost. Externally owned revocation keeps a one-load check per operation. Proposals D-NFR-6a need the probe owner's review. + ## 2026-10-23 — d-onnx-relalg-cap-v1 — ONNX as an import membrane over the existing substrate → `.claude/plans/d-onnx-relalg-cap-v1.md` **Status:** PROPOSAL, documentation only (S0). S1..S5 PLANNED, not authorized. The original DR64³ plan was not found; the DR64³ section is a new, labelled proposal. relalg takes only the Boolean/compare/reduce fragment; the home for value maths is OPEN (OP-ONNX-1), and so is the importer dependency (OP-ONNX-2). Entry: `entries/2026-10-23-d-onnx-relalg-cap-s0.md`. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 2a9fe79e0..b206d9d5e 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -1,3 +1,12 @@ +## D-NFR-6 — validate once, execute without repeated guards (2026-10-23) + +Lab only: `crates/dr-validated-view-probe`, plan `.claude/plans/d-nfr-6-validate-once-v1.md`. Parent D-NFR (#1753); guard figure from D-MPF-4 (#1752). + +| D-id | scope | status | gate / falsifier | +|---|---|---|---| +| **D-NFR-6** | one `bind_dr64` validation authorizes many native operations over a lifetime-borrowed view; guard cost split; shared-revocation escape | MEASURED (`entries/2026-10-23-d-nfr-6-validate-once.md`) | 5 compile_fail doctests (E0502/E0597, codes pinned); 8 runtime falsifiers; 4 disable runs, each red on one test; asm read | +| **D-NFR-6a** | `FrameStore::check` by pointer comparison; `BoundDr64` borrows its frame | PROPOSAL (owner coordination) | same refusals, fewer `lock` RMWs, measured | + ## D-ONNX-RELALG-CAP — ONNX as import membrane (2026-10-23) Plan `d-onnx-relalg-cap-v1.md`, PROPOSAL. Only S0 is in a PR. diff --git a/.claude/board/entries/2026-10-23-d-nfr-6-validate-once.md b/.claude/board/entries/2026-10-23-d-nfr-6-validate-once.md new file mode 100644 index 000000000..684765263 --- /dev/null +++ b/.claude/board/entries/2026-10-23-d-nfr-6-validate-once.md @@ -0,0 +1,9 @@ +# D-NFR-6 — validate once, execute without repeated guards + +**Date:** 2026-10-23 · **Status:** MEASURED, lab only · **Plan:** `.claude/plans/d-nfr-6-validate-once-v1.md` · **Crate:** `crates/dr-validated-view-probe` + +- **Q1, the guard:** the 27–28 ns per-call guard (#1752 route) is `FrameStore::check` (25.7–28.3 ns; 96 instructions; six `lock` RMWs) plus two `u64` compares (~1.3 ns). The cost is the temporary `Weak`s from `Arc::downgrade`; the pointer comparisons cost ~1 ns each. `bind_dr64` adds a `Frame` clone (two more `lock inc`): 59–67 ns total. +- **Q2/Q5, validate once:** a `Table` owns data, frame store and focus map; every change takes `&mut`. `validate` runs the existing `bind_dr64` once and returns a `View<'t>`. While a view lives, advance, refocus and revoke are compile errors (E0502), and a view cannot outlive its table or leak a `'static` borrow (E0597). After the borrow, stale guards are refused at re-acquisition. +- **Q6, the cost:** view operations equal direct `ndarray::simd` within noise, at 0 allocations. `View::sum` is a tail call with no `lock` op. One validation is ~65 ns per view. Per-operation alternatives: +25–40 ns (route guard), +50–75 ns (`bind_dr64`). +- **Q3, what stays at run time:** revocation owned by another component (one epoch load per operation, `SharedView`); data not owned with its frame (`bind_dr64` checks the frame, not the bytes); a focus whose revision is chosen by the caller (fixed by an owner-issued revision). +- **Proposed, not implemented:** `FrameStore::check` by `as_ptr` comparison (no refcount traffic); `BoundDr64` borrowing its frame. Estimated validation ~10 ns, not measured. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 08cd9ef8d..b2b9a6b74 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ 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. -454 entries, 2026-08-06 .. 2026-10-23. +455 entries, 2026-08-06 .. 2026-10-23. | date | entry id | finding | file | |---|---|---|---| @@ -38,6 +38,7 @@ exactly one of them, never both. | 2026-10-23 | `D-TGA-5D` | | [2026-10-23-d-tga-5d-protected-readers-preregistration.md](2026-10-23-d-tga-5d-protected-readers-preregistration.md) | | 2026-10-23 | `D-TGA-5` | | [2026-10-23-d-tga-5-squash-gate-preregistration.md](2026-10-23-d-tga-5-squash-gate-preregistration.md) | | 2026-10-23 | `D-ONNX-RELALG-CAP` | S0 result: ONNX as import membrane, DR64³ plan not found | [2026-10-23-d-onnx-relalg-cap-s0.md](2026-10-23-d-onnx-relalg-cap-s0.md) | +| 2026-10-23 | `D-NFR-6` | | [2026-10-23-d-nfr-6-validate-once.md](2026-10-23-d-nfr-6-validate-once.md) | | 2026-10-23 | `coresearch-thought-git-markov` | | [2026-10-23-coresearch-thought-git-markov.md](2026-10-23-coresearch-thought-git-markov.md) | | 2026-10-23 | `coresearch-thought-git-markov-cc2` | | [2026-10-23-coresearch-thought-git-markov-cc2.md](2026-10-23-coresearch-thought-git-markov-cc2.md) | | 2026-10-23 | `coresearch-julia-egraphs` | | [2026-10-23-coresearch-julia-egraphs.md](2026-10-23-coresearch-julia-egraphs.md) | diff --git a/.claude/plans/d-nfr-6-validate-once-v1.md b/.claude/plans/d-nfr-6-validate-once-v1.md new file mode 100644 index 000000000..7b1100287 --- /dev/null +++ b/.claude/plans/d-nfr-6-validate-once-v1.md @@ -0,0 +1,208 @@ +# D-NFR-6 — validate a binding once, then execute without repeated guards + +**Status:** MEASURED, lab only (`crates/dr-validated-view-probe`). No production contract, ABI or other crate is changed. Parent: D-NFR (`d-native-repr-contract-v1.md`, #1753); the per-call guard figure comes from D-MPF-4 (#1752 §10). + +**Question.** The #1752 ClassId route runs a frame/focus/scope guard on every call, and that guard measured 27–28 ns. Is that guard needed on every operation, or can ownership and borrowing guarantee the same thing at binding time? + +**Answer in one line.** For data owned together with its frame and focus, and for grants owned by the caller, one validation authorizes any number of operations. After it, each operation costs what the direct `ndarray::simd` call costs. Two cases still need a runtime check: + +- revocation owned by another component costs one plain load per operation; +- data the frame does not own keeps the per-use check. + +## 1. Three responsibilities + +| responsibility | can go stale? | where it is decided | mechanism | +|---|---|---|---| +| method resolution: which native function | no | once per process | `LazyLock`; a copied `fn` pointer captures nothing | +| data binding: frame, focus, scope | yes | when a view is acquired | the existing `bind_dr64`, unchanged, run once in `validate` | +| execution | no | never | `View` methods call `ndarray::simd` on borrowed data | + +## 2. Source audit (read in source at `origin/main`) + +`dr-math-binding-probe/src/lib.rs`: + +- `FrameStore { identity: Arc<()>, current: Arc }`. Changes go through `advance(&mut self)` only; there is no interior mutability. +- `seal()` returns `Frame { store: Weak<()>, seal: Weak, at }`. +- `check()` compares `Weak::ptr_eq(&frame.store, &Arc::downgrade(&self.identity))`, and the same for the seal. Each comparison creates a temporary `Weak` and drops it. +- `bind_dr64` runs, in order: method / axis / semantics compares, the validity–truth match, the Bag refusal, `store.check`, then focus and scope equality against caller-supplied `now`. It returns `BoundDr64 { frame: bound_at.frame.clone(), rows: &'a [u64;64], .. }`. + +`dr-math-binding-probe/src/relcomp.rs`: `Chain<'a>` holds `&'a FrameStore` and checks the frame once in `Chain::new`. Its `compile_fail` doctest shows `advance` does not compile while a chain lives. `Chain` is therefore already the validate-once pattern, but for the frame only. + +`lance-graph-quack/src/bind.rs`: a bound `Query` is an owned numeric value. It carries table, column and plane indices, no lifetime and no frame. That is the D-NFR-1 gap: a bound query does not know its frame. + +The seven audit questions, each answered with its evidence: + +1. **Can the data change through another path while borrowed?** For `bind_dr64`, yes. Its `rows` are any `&[u64;64]` the caller passes, unrelated to the frame (test `bind_dr64_checks_the_frame_not_the_data`). The bytes themselves cannot change during a shared borrow: `[u64; 64]` and `Vec` have no interior mutability. The gap is coupling, not mutation. In the probe, a `Table` owns the data and changes it only in `advance(&mut self)`, together with the revisions. +2. **Can focus change while borrowed?** `Focus` is a `Copy` revision number supplied by the caller. Nothing owns it, and a map could change while its number stays the same. In the probe, `Table::refocus(&mut self)` issues the next revision itself, so even an identical map gets a new revision (disable run D1). +3. **Can scope change independently?** `Scope { id }` equality says nothing about authorization. In the probe, the scope comes from the grant owner (`Grants`), never from the caller (disable run D3). When revocation is owned elsewhere (`SharedGrants`: `revoke(&self)` through an atomic), the borrow cannot stop it; see §4, F5. +4. **Are source and relation-map revisions coupled to the data?** Not by `bind_dr64`. Yes by `Table::advance`. +5. **Can a cloned frame outlive validity?** Yes, and it is harmless: a clone is a pair of `Weak`s. After `advance` the seal `Arc` is replaced, so `check` refuses (`SourceChanged`, or `FrameResealed` at equal numbers). A frame from another store is refused as `ForeignFrame`. +6. **Does a seal protect data identity or only revision equality?** It protects the identity of a revision issue: `Arc` pointer identity, so re-sealing equal numbers is a new frame. It does not protect the bytes; see item 1. +7. **Can a prepared callable keep references past the borrow?** No. `Kernels` holds `fn` pointers, which capture nothing, and data reaches them only as arguments. A view cannot outlive its table (E0597) or hand out a `'static` borrow (E0597). + +## 3. Design (lab) + +`Table` owns `FrameStore`, `Data { rows: [u64;64], values: Vec }` and the focus map. Every change needs `&mut Table`. + +`validate(&'t Table, &'t Grants, declared, requested, &Guards) -> View<'t>` runs `bind_dr64` once, taking the focus from the table and the scope from the grants. + +`View<'t>` offers `count`, `sum`, `min`, `max` and `count_gt` (Greater → AND → popcount, written into caller scratch). Each calls a `KERNELS` function pointer directly. + +`validate_shared(&'t Table, &'t SharedGrants, ..) -> SharedView<'t>` is the same, plus one `Acquire` load of the grant epoch per operation. + +## 4. Falsifiers + +**Compile time.** These are `compile_fail` doctests with pinned error codes. Each snippet was also compiled on its own and fails with exactly the pinned code: + +| id | attempt | result | +|---|---|---| +| F1 | `table.advance(..)` while a view lives | E0502 | +| F2 | `table.refocus(..)` while a view lives | E0502 | +| F3 (owned grants) | `grants.revoke()` while a view lives | E0502 | +| F4 (outlive) | keep a view after its table is dropped | E0597 | +| F7 | `let r: &'static [u64;64] = view.rows()` | E0597 | + +**Run time** (`tests/falsifiers.rs`, 8 tests): + +- **E arm:** after the borrow ends, the old guards are refused at re-acquisition: + - an `advance` → `SourceChanged`; + - an identical-map `refocus` → `FocusChanged`; + - a revocation → `ScopeRevoked`. + + Freshly taken guards validate again. +- Equal numbers re-sealed → `FrameResealed`; another table's frame → `ForeignFrame`. +- **F3, scope as grant:** guards for scope 3 under grants for scope 4 → `ScopeChanged`. +- **F5, shared revocation:** `revoke(&self)` compiles while a `SharedView` lives. The next operation returns `ScopeRevoked`, and operations before it answer. **The borrow cannot enforce this, so the check stays: one epoch load per operation.** +- **F6, semantics:** the typed refusals come through unchanged: + - another method → `MethodChanged`; + - other axes → `AxisChanged`; + - other truth → `SemanticsChanged`; + - three-valued without validity → `ThreeValuedNeedsValidity`; + - Bag → `BagNeedsMultiplicity`. +- **Results:** all five operations equal both the direct `ndarray::simd` calls and an independent row loop, at 1, 2, 63, 64, 65, 1000, 4095 and 4096 rows and five thresholds. An anti-vacuity test pins a partial selection. + +**Disable runs** (committed first; each red on exactly one test, then restored): + +| disable | red test | +|---|---| +| D1 `refocus` keeps the revision | `after_the_borrow_ends_every_change_is_refused_at_acquisition` | +| D2 `SharedView` never reads the epoch | `shared_revocation_during_a_view_is_refused_per_operation` | +| D3 owned grants ignored | `after_the_borrow_ends_every_change_is_refused_at_acquisition` | +| D4 focus taken from the caller's guards instead of the table | `after_the_borrow_ends_every_change_is_refused_at_acquisition` | + +**Removed as unreachable:** a post-bind "two-valued Distinct only" check. The view passes no validity plane, so `bind_dr64` already refuses every three-valued and every Bag method before it. A guard that cannot fire was deleted, not kept. + +## 5. Cost attribution (reported, not gated) + +Setup: `examples/guard_cost.rs`, release, AVX-512 at compile time, load 0.1–0.7. ns per call, median of 21; three runs; ranges are across the runs. Allocations per call: 0 in every arm. + +**The guard, taken apart** (64 rows; independent of data size): + +| piece | ns | +|---|---:| +| method + axis + semantics compares | 3.4–4.1 | +| validity–truth match | 0.9–1.0 | +| focus + scope equality | 1.2–1.3 | +| `FrameStore::check` (real) | 25.7–28.3 | +| `Frame::clone` + drop (real; `bind_dr64` clones the frame) | 23.0–26.2 | +| one `Arc::downgrade` + drop of the `Weak` (proxy) | 21.0–22.5 | +| one `Weak::ptr_eq` (proxy) | 1.0–1.2 | +| one `Weak::as_ptr == Arc::as_ptr` (proxy, no refcount) | 1.0–1.8 | +| `bind_dr64` total | 59–67 | +| `validate` total (`bind_dr64` + grant read) | 62.5–65.4 | +| `LazyLock` first initialisation (cold, single sample) | 651–1016 | +| first `validate` (cold, single sample) | 253–362 | + +**Assembly** (`--emit=asm`, each function read on its own): + +| function | instructions | `lock` RMWs | notes | +|---|---:|---:|---| +| `FrameStore::check` | 96 | 6 | 2 × `lock cmpxchg` (`Arc::downgrade`), 4 × `lock dec` (the temporaries' drops) | +| `bind_dr64` | 108 + `check` | 2 | 2 × `lock inc` (the `Frame` clone) | +| `validate` | 83 + `bind_dr64` | 2 | `lock dec` dropping the `BoundDr64`'s frame; `Once` check for `LazyLock` | +| `View::sum` | 20 | 0 | word count, one bounds check, tail `jmp` through the copied `fn` pointer | +| `SharedView::sum` | 38 | 0 | one plain load and compare of the epoch (`Acquire` is a plain `mov` on x86), then the same body | + +So the guard's cost is the reference-count traffic. Its comparisons cost 1–4 ns, and its six atomic RMWs (eight in `bind_dr64`) are the rest. + +**Per operation, 64 rows:** + +| op | A direct | B prebound | C `bind_dr64` per op | C′ guard per op (#1752 route) | D view | D′ shared view | +|---|---:|---:|---:|---:|---:|---:| +| count | 2.9–3.5 | 3.8–4.5 | 61–68 | 28–31 | 3.2–3.9 | 4.4–5.5 | +| sum | 28.6–30.9 | 28.6–32.8 | 78–83 | 42–49 | 27.8–39.3 | 28.8–43.8 | +| min | 30.3–49.0 | 31.2–57.5 | 80–114 | 48–89 | 29.9–61.6 | 30.7–60.2 | +| max | 30.7–53.3 | 31.5–36.2 | 79–87 | 48–54 | 30.2–34.2 | 30.5–34.5 | +| count_gt | 33.4–36.1 | 36.6–39.4 | 94–108 | 61–74 | 35.5–48.1 | 40.7–51.3 | + +The wide ranges (min, and one sum and count_gt sample) come from single noisy runs and appear in A and B as well as in D. Where D was above A in a run, the same run usually showed the same spike elsewhere. + +**Five operations, 64 rows:** + +| path | ns | +|---|---:| +| direct, inlined together | 107–119 | +| one `validate` + five view operations | 200–332 (≈ `validate` 65 + the five per-op view costs) | +| `bind_dr64` before each operation | 387–488 | + +**4096 rows:** the kernels cost 1.7–2.0 µs (masked sum/min/max), so all arms are within run-to-run noise except: +- `count`: direct 6.6, view 5.4, `bind_dr64` per op 64; +- `count_gt`: direct 292, view 307, `bind_dr64` per op 523. + +**E (refused re-validation):** 28–32 ns. + +## 6. Answers + +**Q1 — what does the 27–28 ns guard execute?** `FrameStore::check` (25.7–28.3 ns, 96 instructions, six `lock` RMWs) plus two `u64` equality tests for focus and scope (~1.3 ns). Inside `check`, the two `Weak::ptr_eq` comparisons cost about 1 ns each. Almost all the time goes to creating and dropping two temporary `Weak`s, which is atomic weak-count traffic. Comparing pointers does not cost it. + +**Q2 — what does borrowing already guarantee?** +- **In the probe's `Table` design**, while a view lives: + - the data, the frame store and the focus map cannot change (E0502); + - the view cannot outlive the table (E0597); + - no borrow escapes as `'static`; + - the prepared `fn` pointers hold no references. +- **In the existing code**, `Chain<'a>` already holds `&'a FrameStore`, so the frame cannot advance while a chain lives. `BoundDr64`, however, holds a cloned `Frame` rather than a borrow, so the store **can** advance while a `BoundDr64` lives. That is why the #1752 route has to re-check on every call. + +**Q3 — what genuinely needs runtime validation?** +1. **Acquisition, all of `bind_dr64`:** the guards were taken earlier and may be stale. +2. **Revocation owned by another component:** an atomic that a borrow cannot freeze. +3. **Data not owned with its frame:** `bind_dr64` checks the frame, not the bytes. +4. **A focus identified by a caller-chosen number:** no check, at run time or compile time, can see a map change behind an unchanged number. The fix is ownership (an owner-issued revision), not more checking. + +**Q4 — at which boundary?** + +| validation | boundary | +|---|---| +| method resolution | once per process (`LazyLock`) | +| frame, focus, scope, semantics | at view acquisition, and again only after a `&mut` change, which cannot happen while a view lives | +| shared revocation | per operation, one epoch load; per batch if the policy accepts revocation at batch granularity | +| data from outside the frame owner | per use, as today | + +**Q5 — can several operations reuse one validated binding without weakening the protections?** Yes, for table-owned data and caller-owned grants. Stale frame, focus and scope while a view lives are compile errors, not runtime events. After the borrow they are refused at re-acquisition. Semantic mismatches are refused at acquisition with the existing typed refusals. With externally revocable grants the same holds, plus one epoch load per operation. + +**Q6 — what does it cost against direct ndarray?** +- **Per operation:** D view equals A direct within run-to-run noise, at 0 allocations. In the assembly, `View::sum` is a tail call through the resolved `fn` pointer and executes no `lock` instruction. +- **Validation:** ~65 ns once per view; the first, cold one is 253–362 ns. +- **Shared view:** adds the epoch load, about 1–2 ns per operation. +- **The per-call paths it replaces cost:** + - +25–40 ns per operation (C′, the #1752 route guard); + - +50–75 ns per operation (C, `bind_dr64` per op). +- **Comparison points:** the 36.1 ns direct baseline and the ~0.5 ns `LazyLock` effect from #1752 are kept as comparison points, not as guarantees. Here the `fn`-pointer call measured 2.1–2.6 ns against 2.7–2.9 ns direct for a one-word count, i.e. no measurable cost. + +## 7. Acceptance + +- [x] A validated binding executes several operations with no routing and no repeated metadata validation. +- [x] Data, focus and scope remain valid for the whole view lifetime (compile-time for owned state; per-operation epoch for shared revocation). +- [x] Invalid reuse fails to compile, or is refused at acquisition (E arm, F4–F6). +- [x] No guard removed for speed. The one removed check was unreachable. The shared-revocation check is retained. +- [x] No correctness condition rests on an unsupported lifetime assumption. The two that a lifetime cannot carry (foreign data, external revocation) keep their runtime checks. +- [x] Zero allocation on the hot path (counted, all arms). +- [x] Direct and view results identical; also equal to an independent row loop. +- [x] Validate-once benchmarked against per-operation validation (§5). +- [x] Assembly supports the claim (§5). + +## 8. Proposals (not implemented; owner coordination needed) + +1. **`FrameStore::check` without refcount traffic.** Compare `Weak::as_ptr(&frame.store)` with `Arc::as_ptr(&self.identity)`, and the same for the seal, instead of creating temporaries with `Arc::downgrade`. This removes the six `lock` RMWs. The proxy measured ~1–2 ns per comparison against ~21 ns for the downgrade. The semantics are the same: `Weak::ptr_eq` compares `as_ptr`. A dangling `Weak` from a dropped store cannot alias a live `Arc` while the issuing store is alive, because the store holds its `Arc`. This needs its own review. +2. **`BoundDr64` borrows its frame** (`&'a Frame`) instead of cloning it. That removes two `lock inc` and two `lock dec`. With proposal 1, a full validation would fall from ~65 ns to roughly 10 ns (estimate, not measured). +3. **D-NFR-1:** the smallest seam (a Quack-bound query does not know its frame) takes the `View` shape here. The query is executed only through a value that borrows a frame owner, so frame validity rides on the borrow. diff --git a/.github/workflows/dr-validated-view-probe.yml b/.github/workflows/dr-validated-view-probe.yml new file mode 100644 index 000000000..15442ded9 --- /dev/null +++ b/.github/workflows/dr-validated-view-probe.yml @@ -0,0 +1,68 @@ +name: DR Validated View Probe (D-NFR-6) +# Own [workspace] excluded from root tests; a lab gate, not production +# admission. Mirrors egglog-r2il-probe.yml's toolchain setup: the pinned +# toolchain from rust-toolchain.toml, never `stable`. +on: + pull_request: + paths: + - "crates/dr-validated-view-probe/**" + - "crates/dr-math-binding-probe/**" + - "crates/lance-graph-contract/**" + - "crates/lance-graph-act-r/**" + - ".github/workflows/dr-validated-view-probe.yml" + - "rust-toolchain.toml" + push: + branches: [main] + paths: + - "crates/dr-validated-view-probe/**" + - "crates/dr-math-binding-probe/**" + - "crates/lance-graph-contract/**" + - "crates/lance-graph-act-r/**" + - ".github/workflows/dr-validated-view-probe.yml" + - "rust-toolchain.toml" + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + RUST_BACKTRACE: "1" +jobs: + isolated-probe: + name: excluded-crate fmt, clippy and tests + runs-on: ubuntu-24.04 + timeout-minutes: 60 + defaults: + run: + working-directory: lance-graph + steps: + - uses: actions/checkout@v4 + with: + path: lance-graph + persist-credentials: false + - name: Rust toolchain + run: rustup show + - name: Resolve target-cpu=native for this runner + id: cpu + run: | + flags=$(.github/scripts/resolve-native-rustflags.sh) + echo "RUSTFLAGS=$flags" >> "$GITHUB_ENV" + hash=$(printf %s "$flags" | sha256sum | cut -c1-16) + echo "flags=$hash" >> "$GITHUB_OUTPUT" + - uses: Swatinem/rust-cache@v2 + with: + key: cpu-${{ steps.cpu.outputs.flags }} + workspaces: lance-graph/crates/dr-validated-view-probe + - name: fmt + run: cargo fmt --manifest-path crates/dr-validated-view-probe/Cargo.toml --check + - name: clippy + run: cargo clippy --manifest-path crates/dr-validated-view-probe/Cargo.toml --all-targets -- -D warnings + - name: tests + run: cargo test --release --manifest-path crates/dr-validated-view-probe/Cargo.toml + - name: guard cost split (reported, not gated) + run: cargo run --release --example guard_cost --manifest-path crates/dr-validated-view-probe/Cargo.toml diff --git a/crates/dr-validated-view-probe/examples/guard_cost.rs b/crates/dr-validated-view-probe/examples/guard_cost.rs new file mode 100644 index 000000000..a5821fb63 --- /dev/null +++ b/crates/dr-validated-view-probe/examples/guard_cost.rs @@ -0,0 +1,498 @@ +//! D-NFR-6 cost split. Reported, not gated. +//! +//! Arms, on the same table, the same five operations and the same kernels: +//! +//! - **A direct**: the `ndarray::simd` call. +//! - **B prebound**: the `fn` pointer resolved once in `KERNELS`, no guard. +//! - **C bind per op**: `bind_dr64` (the whole existing validator), then the kernel. +//! - **C' guard per op**: what the #1752 ClassId route runs per call: +//! `FrameStore::check` plus focus and scope equality, then the kernel. +//! - **D view**: one `validate`, then the view's operations (no check). +//! - **D' shared view**: as D, plus one grant-epoch load per operation. +//! - **E**: re-validation after a change; it must refuse. +//! +//! Then the guard itself, taken apart. `cold` costs (the first `validate`, +//! the `LazyLock` initialisation) are reported separately and not mixed into +//! warm per-operation figures. Allocations per call are counted by a global +//! allocator wrapper. The `probe_*` functions are out of line for reading the +//! assembly: `cargo rustc --release --example guard_cost -- --emit=asm`. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Weak}; +use std::time::Instant; + +use dr_math_binding_probe::{ + FrameStore, Guards, MethodSignature, Operand, Refusal, Scope, Truth, bind_dr64, +}; +use dr_validated_view_probe::fixture::{data, revisions, setup}; +use dr_validated_view_probe::{ + Focus, KERNELS, SharedGrants, View, ViewRefusal, validate, validate_shared, +}; + +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; + +fn env(name: &str, d: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(d) +} + +/// Median ns per call, and allocations per call. +fn measure(reps: usize, iters: usize, mut f: impl FnMut()) -> (f64, f64) { + for _ in 0..200 { + 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); + (v[v.len() / 2], allocs) +} + +// ── out-of-line shapes for the assembly reading ──────────────────────────── + +#[inline(never)] +pub fn probe_direct_sum(values: &[i32], words: &[u64]) -> i64 { + ndarray::simd::masked_sum_i32(values, words) +} + +#[inline(never)] +pub fn probe_view_sum(view: &View<'_>) -> i64 { + view.sum() +} + +#[inline(never)] +pub fn probe_shared_sum( + view: &dr_validated_view_probe::SharedView<'_>, +) -> Result { + view.sum() +} + +#[inline(never)] +pub fn probe_bind_sum( + store: &FrameStore, + m: MethodSignature, + guards: &Guards, + now: (Focus, Scope), + rows: &[u64; 64], + values: &[i32], +) -> Result { + let b = bind_dr64( + store, + m, + m, + guards, + now, + Operand { + rows, + validity: None, + }, + )?; + Ok(ndarray::simd::masked_sum_i32( + values, + &b.rows[..values.len().div_ceil(64)], + )) +} + +#[inline(never)] +pub fn probe_guard_sum( + store: &FrameStore, + guards: &Guards, + now: (Focus, Scope), + rows: &[u64; 64], + values: &[i32], +) -> Result { + store.check(&guards.frame)?; + if now.0 != guards.focus { + return Err(Refusal::FocusChanged); + } + if now.1 != guards.scope { + return Err(Refusal::ScopeChanged); + } + Ok(ndarray::simd::masked_sum_i32( + values, + &rows[..values.len().div_ceil(64)], + )) +} + +#[inline(never)] +pub fn probe_check(store: &FrameStore, guards: &Guards) -> Result<(), Refusal> { + store.check(&guards.frame) +} + +const OPS: [&str; 5] = ["count", "sum", "min", "max", "count_gt"]; + +fn main() { + let reps = env("NFR6_REPS", 21); + let iters = env("NFR6_ITERS", 20_000); + println!( + "avx512f compile-time: {}\n", + cfg!(target_feature = "avx512f") + ); + + // Cold costs, once each, before anything warms them. + let t0 = Instant::now(); + let k = *KERNELS; + let lazy_cold = t0.elapsed().as_nanos(); + let (t, g, m, guards) = setup(64); + let t0 = Instant::now(); + let v = validate(&t, &g, m, m, &guards).unwrap(); + let validate_cold = t0.elapsed().as_nanos(); + black_box(v.count()); + println!( + "cold: LazyLock init {lazy_cold} ns, first validate {validate_cold} ns (single samples)\n" + ); + + for n in [64usize, 4096] { + let (t, g, m, guards) = setup(n); + let sg = SharedGrants::new(Scope { id: 3 }); + let w = n.div_ceil(64); + let now = (t.focus(), guards.scope); + let d = data(n, 0); + let (rows, values) = (&d.rows, &d.values[..]); + let words = &rows[..w]; + let mut scratch = vec![0u64; 2 * w]; + let mut scratch_c = vec![0u64; 2 * w]; + let mut scratch_c2 = vec![0u64; 2 * w]; + let mut cmp = vec![0u64; w]; + let mut both = vec![0u64; w]; + let view = validate(&t, &g, m, m, &guards).unwrap(); + let shared = validate_shared(&t, &sg, m, m, &guards).unwrap(); + + // Identical answers before timing. + assert_eq!(view.sum(), ndarray::simd::masked_sum_i32(values, words)); + assert_eq!( + probe_bind_sum(t.store(), m, &guards, now, rows, values), + Ok(view.sum()) + ); + assert_eq!( + probe_guard_sum(t.store(), &guards, now, rows, values), + Ok(view.sum()) + ); + assert_eq!(shared.sum(), Ok(view.sum())); + // The out-of-line probes, so they are emitted for the assembly reading. + assert_eq!(probe_direct_sum(values, words), view.sum()); + assert_eq!(probe_view_sum(&view), view.sum()); + assert_eq!(probe_shared_sum(&shared), Ok(view.sum())); + + println!("rows = {n}: ns per operation (allocations per call in brackets)\n"); + println!( + "| op | A direct | B prebound | C bind_dr64 per op | C' guard per op | D view | D' shared view |" + ); + println!("|---|---:|---:|---:|---:|---:|---:|"); + for op in OPS { + let kernel_direct = + |s: &mut [u64]| -> i64 { + match op { + "count" => ndarray::simd::popcount_batch_u64(black_box(words)) as i64, + "sum" => ndarray::simd::masked_sum_i32(black_box(values), words), + "min" => ndarray::simd::masked_min_i32(black_box(values), words) + .unwrap_or(0) as i64, + "max" => ndarray::simd::masked_max_i32(black_box(values), words) + .unwrap_or(0) as i64, + _ => { + let (c, rest) = s.split_at_mut(w); + ndarray::simd::gt_i32_to_mask(black_box(values), 0, c); + ndarray::simd::mask_and(c, words, &mut rest[..w]); + ndarray::simd::popcount_batch_u64(&rest[..w]) as i64 + } + } + }; + let kernel_pre = |s: &mut [u64]| -> i64 { + match op { + "count" => (k.count)(black_box(words)) as i64, + "sum" => (k.sum)(black_box(values), words), + "min" => (k.min)(black_box(values), words).unwrap_or(0) as i64, + "max" => (k.max)(black_box(values), words).unwrap_or(0) as i64, + _ => { + let (c, rest) = s.split_at_mut(w); + (k.gt)(black_box(values), 0, c); + (k.and)(c, words, &mut rest[..w]); + (k.count)(&rest[..w]) as i64 + } + } + }; + let view_op = |v: &View<'_>, s: &mut [u64]| -> i64 { + match op { + "count" => v.count() as i64, + "sum" => v.sum(), + "min" => v.min().unwrap_or(0) as i64, + "max" => v.max().unwrap_or(0) as i64, + _ => v.count_gt(0, s) as i64, + } + }; + let a = measure(reps, iters, || { + black_box(kernel_direct(&mut scratch)); + }); + let b = measure(reps, iters, || { + black_box(kernel_pre(&mut scratch)); + }); + let c = measure(reps, iters, || { + let bound = bind_dr64( + t.store(), + m, + m, + black_box(&guards), + now, + Operand { + rows, + validity: None, + }, + ) + .unwrap(); + black_box(&bound); + black_box(kernel_pre(&mut scratch_c)); + }); + let c2 = measure(reps, iters, || { + t.store().check(black_box(&guards.frame)).unwrap(); + assert!(black_box(now.0) == guards.focus && black_box(now.1) == guards.scope); + black_box(kernel_pre(&mut scratch_c2)); + }); + let dd = measure(reps, iters, || { + black_box(view_op(black_box(&view), &mut scratch)); + }); + let ds = measure(reps, iters, || { + let r = match op { + "count" => shared.count().map(|x| x as i64), + "sum" => shared.sum(), + "min" => shared.min().map(|x| x.unwrap_or(0) as i64), + "max" => shared.max().map(|x| x.unwrap_or(0) as i64), + _ => shared.count_gt(0, &mut scratch).map(|x| x as i64), + }; + black_box(r.unwrap()); + }); + let f = |x: (f64, f64)| format!("{:.1} [{:.0}]", x.0, x.1); + println!( + "| {op} | {} | {} | {} | {} | {} | {} |", + f(a), + f(b), + f(c), + f(c2), + f(dd), + f(ds) + ); + } + + // Five operations under one validation vs five validations. + let five_view = measure(reps, iters / 5, || { + let v = validate(&t, &g, m, m, black_box(&guards)).unwrap(); + black_box(( + v.count(), + v.sum(), + v.min(), + v.max(), + v.count_gt(0, &mut scratch), + )); + }); + let five_bind = measure(reps, iters / 5, || { + let mut out = [0i64; 5]; + for (i, slot) in out.iter_mut().enumerate() { + let bound = bind_dr64( + t.store(), + m, + m, + black_box(&guards), + now, + Operand { + rows, + validity: None, + }, + ) + .unwrap(); + black_box(&bound); + *slot = match i { + 0 => (k.count)(words) as i64, + 1 => (k.sum)(values, words), + 2 => (k.min)(values, words).unwrap_or(0) as i64, + 3 => (k.max)(values, words).unwrap_or(0) as i64, + _ => { + (k.gt)(values, 0, &mut cmp); + (k.and)(&cmp, words, &mut both); + (k.count)(&both) as i64 + } + }; + } + black_box(out); + }); + let five_direct = measure(reps, iters / 5, || { + (k.gt)(values, 0, &mut cmp); + (k.and)(&cmp, words, &mut both); + black_box(( + ndarray::simd::popcount_batch_u64(black_box(words)), + ndarray::simd::masked_sum_i32(values, words), + ndarray::simd::masked_min_i32(values, words), + ndarray::simd::masked_max_i32(values, words), + ndarray::simd::popcount_batch_u64(&both), + )); + }); + println!( + "\nfive ops: direct {:.1} [{:.0}] · one validate + five view ops {:.1} [{:.0}] · bind_dr64 before each {:.1} [{:.0}]\n", + five_direct.0, five_direct.1, five_view.0, five_view.1, five_bind.0, five_bind.1 + ); + } + + // ── the guard, taken apart (rows = 64; independent of data size) ─────── + let (t, g, m, guards) = setup(64); + let now = (t.focus(), guards.scope); + let rows = data(64, 0).rows; + let reps_b = reps; + let it = iters * 5; + let pieces: Vec<(&str, (f64, f64))> = vec![ + ( + "method + axis + semantics compares (3 tuple compares)", + measure(reps_b, it, || { + let (a, b) = (black_box(m), black_box(m)); + black_box( + (a.owner_class, a.method_ordinal) == (b.owner_class, b.method_ordinal) + && (a.row, a.column) == (b.row, b.column) + && (a.truth, a.population) == (b.truth, b.population), + ); + }), + ), + ( + "validity / truth match", + measure(reps_b, it, || { + let v: Option<&[u64; 64]> = black_box(None); + black_box(matches!((black_box(m).truth, v), (Truth::TwoValued, None))); + }), + ), + ( + "focus + scope equality", + measure(reps_b, it, || { + black_box(black_box(now.0) == guards.focus && black_box(now.1) == guards.scope); + }), + ), + ( + "FrameStore::check (real)", + measure(reps_b, it, || { + black_box(probe_check(t.store(), black_box(&guards))).unwrap(); + }), + ), + ( + "Frame::clone + drop (real; bind_dr64 clones the frame)", + measure(reps_b, it, || { + black_box(black_box(&guards.frame).clone()); + }), + ), + ("Arc::downgrade + drop of the Weak (proxy, x1)", { + let arc = Arc::new(0u64); + measure(reps_b, it, || { + black_box(Arc::downgrade(black_box(&arc))); + }) + }), + ("Weak::ptr_eq (proxy, x1)", { + let arc = Arc::new(0u64); + let (w1, w2) = (Arc::downgrade(&arc), Arc::downgrade(&arc)); + measure(reps_b, it, || { + black_box(Weak::ptr_eq(black_box(&w1), black_box(&w2))); + }) + }), + ("Weak::as_ptr == Arc::as_ptr (proxy, x1; no refcount)", { + let arc = Arc::new(0u64); + let w1 = Arc::downgrade(&arc); + measure(reps_b, it, || { + black_box(std::ptr::eq( + Weak::as_ptr(black_box(&w1)), + Arc::as_ptr(black_box(&arc)), + )); + }) + }), + ( + "bind_dr64 total (real)", + measure(reps_b, it, || { + black_box( + bind_dr64( + t.store(), + m, + m, + black_box(&guards), + now, + Operand { + rows: &rows, + validity: None, + }, + ) + .unwrap(), + ); + }), + ), + ( + "validate total (real; bind_dr64 + grant read)", + measure(reps_b, it, || { + black_box(validate(&t, &g, m, m, black_box(&guards)).unwrap()); + }), + ), + ("grant epoch load (D' per op)", { + let sg = SharedGrants::new(Scope { id: 3 }); + let sv = validate_shared(&t, &sg, m, m, &guards).unwrap(); + measure(reps_b, it, || { + black_box(black_box(&sv).count().unwrap()); + }) + }), + ("count via fn pointer, rows = 64 (B)", { + let w = &rows[..1]; + measure(reps_b, it, || { + black_box((KERNELS.count)(black_box(w))); + }) + }), + ("count direct, rows = 64 (A)", { + let w = &rows[..1]; + measure(reps_b, it, || { + black_box(ndarray::simd::popcount_batch_u64(black_box(w))); + }) + }), + ]; + println!("guard pieces, ns per call [allocations]:\n"); + println!("| piece | ns | allocs |"); + println!("|---|---:|---:|"); + for (name, (ns, al)) in &pieces { + println!("| {name} | {ns:.2} | {al:.0} |"); + } + + // ── E: a change after the borrow is refused at acquisition ───────────── + let (mut t, g, m, guards) = setup(64); + t.advance(revisions(2), data(64, 1)); + let e = measure(reps, iters, || { + black_box(validate(&t, &g, m, m, black_box(&guards)).err()); + }); + assert_eq!( + validate(&t, &g, m, m, &guards).err(), + Some(ViewRefusal::Bind(Refusal::SourceChanged)) + ); + println!( + "\nE: re-validation after advance is refused (SourceChanged), {:.1} ns [{:.0}]", + e.0, e.1 + ); + println!("\nns per call, median of NFR6_REPS. Reported, not gated."); +} From b4b7ee4ae927ba08d910c304390416d62e4b1c03 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:39:18 +0000 Subject: [PATCH 4/8] lab(D-NFR-6): rework onto the SoaEnvelope borrow; drop Table and the frame guard The previous probe built a Table with its own FrameStore beside the SoA and re-checked a frame on each access. That re-derived ownership the contract already has: a NodeRowPacket is a &self borrow of the owner's rows, the owner's &mut cannot start while it is live, and the packet is not Clone. soa-view-probe: FacetFold on NodeRowPacket, one ndarray kernel per method (eq_u32_strided_to_mask, ternary_match_strided16_to_mask, masked_strided_group_sum over 12 x u8) at the 512-byte row and 16-byte facet stride. compile_fail examples pin E0502/E0597/E0599; runtime tests check packet == direct ndarray == row loop at 8 sizes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- ...ated-view-probe.yml => soa-view-probe.yml} | 26 +- crates/dr-validated-view-probe/Cargo.toml | 20 - .../examples/guard_cost.rs | 498 ------------------ crates/dr-validated-view-probe/src/lib.rs | 489 ----------------- .../tests/falsifiers.rs | 241 --------- crates/soa-view-probe/Cargo.toml | 17 + crates/soa-view-probe/examples/view_cost.rs | 170 ++++++ crates/soa-view-probe/src/lib.rs | 241 +++++++++ crates/soa-view-probe/tests/falsifiers.rs | 141 +++++ 9 files changed, 580 insertions(+), 1263 deletions(-) rename .github/workflows/{dr-validated-view-probe.yml => soa-view-probe.yml} (62%) delete mode 100644 crates/dr-validated-view-probe/Cargo.toml delete mode 100644 crates/dr-validated-view-probe/examples/guard_cost.rs delete mode 100644 crates/dr-validated-view-probe/src/lib.rs delete mode 100644 crates/dr-validated-view-probe/tests/falsifiers.rs create mode 100644 crates/soa-view-probe/Cargo.toml create mode 100644 crates/soa-view-probe/examples/view_cost.rs create mode 100644 crates/soa-view-probe/src/lib.rs create mode 100644 crates/soa-view-probe/tests/falsifiers.rs diff --git a/.github/workflows/dr-validated-view-probe.yml b/.github/workflows/soa-view-probe.yml similarity index 62% rename from .github/workflows/dr-validated-view-probe.yml rename to .github/workflows/soa-view-probe.yml index 15442ded9..2b8cc0173 100644 --- a/.github/workflows/dr-validated-view-probe.yml +++ b/.github/workflows/soa-view-probe.yml @@ -1,24 +1,20 @@ -name: DR Validated View Probe (D-NFR-6) +name: SoA View Probe (D-NFR-6) # Own [workspace] excluded from root tests; a lab gate, not production # admission. Mirrors egglog-r2il-probe.yml's toolchain setup: the pinned # toolchain from rust-toolchain.toml, never `stable`. on: pull_request: paths: - - "crates/dr-validated-view-probe/**" - - "crates/dr-math-binding-probe/**" + - "crates/soa-view-probe/**" - "crates/lance-graph-contract/**" - - "crates/lance-graph-act-r/**" - - ".github/workflows/dr-validated-view-probe.yml" + - ".github/workflows/soa-view-probe.yml" - "rust-toolchain.toml" push: branches: [main] paths: - - "crates/dr-validated-view-probe/**" - - "crates/dr-math-binding-probe/**" + - "crates/soa-view-probe/**" - "crates/lance-graph-contract/**" - - "crates/lance-graph-act-r/**" - - ".github/workflows/dr-validated-view-probe.yml" + - ".github/workflows/soa-view-probe.yml" - "rust-toolchain.toml" workflow_dispatch: permissions: @@ -57,12 +53,12 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: cpu-${{ steps.cpu.outputs.flags }} - workspaces: lance-graph/crates/dr-validated-view-probe + workspaces: lance-graph/crates/soa-view-probe - name: fmt - run: cargo fmt --manifest-path crates/dr-validated-view-probe/Cargo.toml --check + run: cargo fmt --manifest-path crates/soa-view-probe/Cargo.toml --check - name: clippy - run: cargo clippy --manifest-path crates/dr-validated-view-probe/Cargo.toml --all-targets -- -D warnings + run: cargo clippy --manifest-path crates/soa-view-probe/Cargo.toml --all-targets -- -D warnings - name: tests - run: cargo test --release --manifest-path crates/dr-validated-view-probe/Cargo.toml - - name: guard cost split (reported, not gated) - run: cargo run --release --example guard_cost --manifest-path crates/dr-validated-view-probe/Cargo.toml + run: cargo test --release --manifest-path crates/soa-view-probe/Cargo.toml + - name: packet vs direct cost (reported, not gated) + run: cargo run --release --example view_cost --manifest-path crates/soa-view-probe/Cargo.toml diff --git a/crates/dr-validated-view-probe/Cargo.toml b/crates/dr-validated-view-probe/Cargo.toml deleted file mode 100644 index 480c2fe86..000000000 --- a/crates/dr-validated-view-probe/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "dr-validated-view-probe" -version = "0.0.0" -edition = "2024" -publish = false -description = "D-NFR-6 lab probe: validate a DR64 binding once, then run several native ndarray operations over a lifetime-borrowed view; falsifiers for stale frame, focus, scope and semantics; guard cost breakdown. Lab only: no production contract or ABI." - -# Own workspace root, like dr-math-binding-probe, which it path-deps. -[workspace] - -[dependencies] -# The existing validator (bind_dr64, FrameStore, Guards, refusals), reused, -# not reimplemented. -dr-math-binding-probe = { path = "../dr-math-binding-probe" } -lance-graph-contract = { path = "../lance-graph-contract" } -# The native kernels. Same source string as mask-risc's copy. -ndarray = { git = "https://github.com/AdaWorldAPI/ndarray", branch = "master" } - -[patch."https://github.com/AdaWorldAPI/lance-graph"] -lance-graph-contract = { path = "../lance-graph-contract" } diff --git a/crates/dr-validated-view-probe/examples/guard_cost.rs b/crates/dr-validated-view-probe/examples/guard_cost.rs deleted file mode 100644 index a5821fb63..000000000 --- a/crates/dr-validated-view-probe/examples/guard_cost.rs +++ /dev/null @@ -1,498 +0,0 @@ -//! D-NFR-6 cost split. Reported, not gated. -//! -//! Arms, on the same table, the same five operations and the same kernels: -//! -//! - **A direct**: the `ndarray::simd` call. -//! - **B prebound**: the `fn` pointer resolved once in `KERNELS`, no guard. -//! - **C bind per op**: `bind_dr64` (the whole existing validator), then the kernel. -//! - **C' guard per op**: what the #1752 ClassId route runs per call: -//! `FrameStore::check` plus focus and scope equality, then the kernel. -//! - **D view**: one `validate`, then the view's operations (no check). -//! - **D' shared view**: as D, plus one grant-epoch load per operation. -//! - **E**: re-validation after a change; it must refuse. -//! -//! Then the guard itself, taken apart. `cold` costs (the first `validate`, -//! the `LazyLock` initialisation) are reported separately and not mixed into -//! warm per-operation figures. Allocations per call are counted by a global -//! allocator wrapper. The `probe_*` functions are out of line for reading the -//! assembly: `cargo rustc --release --example guard_cost -- --emit=asm`. - -use std::alloc::{GlobalAlloc, Layout, System}; -use std::hint::black_box; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Weak}; -use std::time::Instant; - -use dr_math_binding_probe::{ - FrameStore, Guards, MethodSignature, Operand, Refusal, Scope, Truth, bind_dr64, -}; -use dr_validated_view_probe::fixture::{data, revisions, setup}; -use dr_validated_view_probe::{ - Focus, KERNELS, SharedGrants, View, ViewRefusal, validate, validate_shared, -}; - -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; - -fn env(name: &str, d: usize) -> usize { - std::env::var(name) - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(d) -} - -/// Median ns per call, and allocations per call. -fn measure(reps: usize, iters: usize, mut f: impl FnMut()) -> (f64, f64) { - for _ in 0..200 { - 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); - (v[v.len() / 2], allocs) -} - -// ── out-of-line shapes for the assembly reading ──────────────────────────── - -#[inline(never)] -pub fn probe_direct_sum(values: &[i32], words: &[u64]) -> i64 { - ndarray::simd::masked_sum_i32(values, words) -} - -#[inline(never)] -pub fn probe_view_sum(view: &View<'_>) -> i64 { - view.sum() -} - -#[inline(never)] -pub fn probe_shared_sum( - view: &dr_validated_view_probe::SharedView<'_>, -) -> Result { - view.sum() -} - -#[inline(never)] -pub fn probe_bind_sum( - store: &FrameStore, - m: MethodSignature, - guards: &Guards, - now: (Focus, Scope), - rows: &[u64; 64], - values: &[i32], -) -> Result { - let b = bind_dr64( - store, - m, - m, - guards, - now, - Operand { - rows, - validity: None, - }, - )?; - Ok(ndarray::simd::masked_sum_i32( - values, - &b.rows[..values.len().div_ceil(64)], - )) -} - -#[inline(never)] -pub fn probe_guard_sum( - store: &FrameStore, - guards: &Guards, - now: (Focus, Scope), - rows: &[u64; 64], - values: &[i32], -) -> Result { - store.check(&guards.frame)?; - if now.0 != guards.focus { - return Err(Refusal::FocusChanged); - } - if now.1 != guards.scope { - return Err(Refusal::ScopeChanged); - } - Ok(ndarray::simd::masked_sum_i32( - values, - &rows[..values.len().div_ceil(64)], - )) -} - -#[inline(never)] -pub fn probe_check(store: &FrameStore, guards: &Guards) -> Result<(), Refusal> { - store.check(&guards.frame) -} - -const OPS: [&str; 5] = ["count", "sum", "min", "max", "count_gt"]; - -fn main() { - let reps = env("NFR6_REPS", 21); - let iters = env("NFR6_ITERS", 20_000); - println!( - "avx512f compile-time: {}\n", - cfg!(target_feature = "avx512f") - ); - - // Cold costs, once each, before anything warms them. - let t0 = Instant::now(); - let k = *KERNELS; - let lazy_cold = t0.elapsed().as_nanos(); - let (t, g, m, guards) = setup(64); - let t0 = Instant::now(); - let v = validate(&t, &g, m, m, &guards).unwrap(); - let validate_cold = t0.elapsed().as_nanos(); - black_box(v.count()); - println!( - "cold: LazyLock init {lazy_cold} ns, first validate {validate_cold} ns (single samples)\n" - ); - - for n in [64usize, 4096] { - let (t, g, m, guards) = setup(n); - let sg = SharedGrants::new(Scope { id: 3 }); - let w = n.div_ceil(64); - let now = (t.focus(), guards.scope); - let d = data(n, 0); - let (rows, values) = (&d.rows, &d.values[..]); - let words = &rows[..w]; - let mut scratch = vec![0u64; 2 * w]; - let mut scratch_c = vec![0u64; 2 * w]; - let mut scratch_c2 = vec![0u64; 2 * w]; - let mut cmp = vec![0u64; w]; - let mut both = vec![0u64; w]; - let view = validate(&t, &g, m, m, &guards).unwrap(); - let shared = validate_shared(&t, &sg, m, m, &guards).unwrap(); - - // Identical answers before timing. - assert_eq!(view.sum(), ndarray::simd::masked_sum_i32(values, words)); - assert_eq!( - probe_bind_sum(t.store(), m, &guards, now, rows, values), - Ok(view.sum()) - ); - assert_eq!( - probe_guard_sum(t.store(), &guards, now, rows, values), - Ok(view.sum()) - ); - assert_eq!(shared.sum(), Ok(view.sum())); - // The out-of-line probes, so they are emitted for the assembly reading. - assert_eq!(probe_direct_sum(values, words), view.sum()); - assert_eq!(probe_view_sum(&view), view.sum()); - assert_eq!(probe_shared_sum(&shared), Ok(view.sum())); - - println!("rows = {n}: ns per operation (allocations per call in brackets)\n"); - println!( - "| op | A direct | B prebound | C bind_dr64 per op | C' guard per op | D view | D' shared view |" - ); - println!("|---|---:|---:|---:|---:|---:|---:|"); - for op in OPS { - let kernel_direct = - |s: &mut [u64]| -> i64 { - match op { - "count" => ndarray::simd::popcount_batch_u64(black_box(words)) as i64, - "sum" => ndarray::simd::masked_sum_i32(black_box(values), words), - "min" => ndarray::simd::masked_min_i32(black_box(values), words) - .unwrap_or(0) as i64, - "max" => ndarray::simd::masked_max_i32(black_box(values), words) - .unwrap_or(0) as i64, - _ => { - let (c, rest) = s.split_at_mut(w); - ndarray::simd::gt_i32_to_mask(black_box(values), 0, c); - ndarray::simd::mask_and(c, words, &mut rest[..w]); - ndarray::simd::popcount_batch_u64(&rest[..w]) as i64 - } - } - }; - let kernel_pre = |s: &mut [u64]| -> i64 { - match op { - "count" => (k.count)(black_box(words)) as i64, - "sum" => (k.sum)(black_box(values), words), - "min" => (k.min)(black_box(values), words).unwrap_or(0) as i64, - "max" => (k.max)(black_box(values), words).unwrap_or(0) as i64, - _ => { - let (c, rest) = s.split_at_mut(w); - (k.gt)(black_box(values), 0, c); - (k.and)(c, words, &mut rest[..w]); - (k.count)(&rest[..w]) as i64 - } - } - }; - let view_op = |v: &View<'_>, s: &mut [u64]| -> i64 { - match op { - "count" => v.count() as i64, - "sum" => v.sum(), - "min" => v.min().unwrap_or(0) as i64, - "max" => v.max().unwrap_or(0) as i64, - _ => v.count_gt(0, s) as i64, - } - }; - let a = measure(reps, iters, || { - black_box(kernel_direct(&mut scratch)); - }); - let b = measure(reps, iters, || { - black_box(kernel_pre(&mut scratch)); - }); - let c = measure(reps, iters, || { - let bound = bind_dr64( - t.store(), - m, - m, - black_box(&guards), - now, - Operand { - rows, - validity: None, - }, - ) - .unwrap(); - black_box(&bound); - black_box(kernel_pre(&mut scratch_c)); - }); - let c2 = measure(reps, iters, || { - t.store().check(black_box(&guards.frame)).unwrap(); - assert!(black_box(now.0) == guards.focus && black_box(now.1) == guards.scope); - black_box(kernel_pre(&mut scratch_c2)); - }); - let dd = measure(reps, iters, || { - black_box(view_op(black_box(&view), &mut scratch)); - }); - let ds = measure(reps, iters, || { - let r = match op { - "count" => shared.count().map(|x| x as i64), - "sum" => shared.sum(), - "min" => shared.min().map(|x| x.unwrap_or(0) as i64), - "max" => shared.max().map(|x| x.unwrap_or(0) as i64), - _ => shared.count_gt(0, &mut scratch).map(|x| x as i64), - }; - black_box(r.unwrap()); - }); - let f = |x: (f64, f64)| format!("{:.1} [{:.0}]", x.0, x.1); - println!( - "| {op} | {} | {} | {} | {} | {} | {} |", - f(a), - f(b), - f(c), - f(c2), - f(dd), - f(ds) - ); - } - - // Five operations under one validation vs five validations. - let five_view = measure(reps, iters / 5, || { - let v = validate(&t, &g, m, m, black_box(&guards)).unwrap(); - black_box(( - v.count(), - v.sum(), - v.min(), - v.max(), - v.count_gt(0, &mut scratch), - )); - }); - let five_bind = measure(reps, iters / 5, || { - let mut out = [0i64; 5]; - for (i, slot) in out.iter_mut().enumerate() { - let bound = bind_dr64( - t.store(), - m, - m, - black_box(&guards), - now, - Operand { - rows, - validity: None, - }, - ) - .unwrap(); - black_box(&bound); - *slot = match i { - 0 => (k.count)(words) as i64, - 1 => (k.sum)(values, words), - 2 => (k.min)(values, words).unwrap_or(0) as i64, - 3 => (k.max)(values, words).unwrap_or(0) as i64, - _ => { - (k.gt)(values, 0, &mut cmp); - (k.and)(&cmp, words, &mut both); - (k.count)(&both) as i64 - } - }; - } - black_box(out); - }); - let five_direct = measure(reps, iters / 5, || { - (k.gt)(values, 0, &mut cmp); - (k.and)(&cmp, words, &mut both); - black_box(( - ndarray::simd::popcount_batch_u64(black_box(words)), - ndarray::simd::masked_sum_i32(values, words), - ndarray::simd::masked_min_i32(values, words), - ndarray::simd::masked_max_i32(values, words), - ndarray::simd::popcount_batch_u64(&both), - )); - }); - println!( - "\nfive ops: direct {:.1} [{:.0}] · one validate + five view ops {:.1} [{:.0}] · bind_dr64 before each {:.1} [{:.0}]\n", - five_direct.0, five_direct.1, five_view.0, five_view.1, five_bind.0, five_bind.1 - ); - } - - // ── the guard, taken apart (rows = 64; independent of data size) ─────── - let (t, g, m, guards) = setup(64); - let now = (t.focus(), guards.scope); - let rows = data(64, 0).rows; - let reps_b = reps; - let it = iters * 5; - let pieces: Vec<(&str, (f64, f64))> = vec![ - ( - "method + axis + semantics compares (3 tuple compares)", - measure(reps_b, it, || { - let (a, b) = (black_box(m), black_box(m)); - black_box( - (a.owner_class, a.method_ordinal) == (b.owner_class, b.method_ordinal) - && (a.row, a.column) == (b.row, b.column) - && (a.truth, a.population) == (b.truth, b.population), - ); - }), - ), - ( - "validity / truth match", - measure(reps_b, it, || { - let v: Option<&[u64; 64]> = black_box(None); - black_box(matches!((black_box(m).truth, v), (Truth::TwoValued, None))); - }), - ), - ( - "focus + scope equality", - measure(reps_b, it, || { - black_box(black_box(now.0) == guards.focus && black_box(now.1) == guards.scope); - }), - ), - ( - "FrameStore::check (real)", - measure(reps_b, it, || { - black_box(probe_check(t.store(), black_box(&guards))).unwrap(); - }), - ), - ( - "Frame::clone + drop (real; bind_dr64 clones the frame)", - measure(reps_b, it, || { - black_box(black_box(&guards.frame).clone()); - }), - ), - ("Arc::downgrade + drop of the Weak (proxy, x1)", { - let arc = Arc::new(0u64); - measure(reps_b, it, || { - black_box(Arc::downgrade(black_box(&arc))); - }) - }), - ("Weak::ptr_eq (proxy, x1)", { - let arc = Arc::new(0u64); - let (w1, w2) = (Arc::downgrade(&arc), Arc::downgrade(&arc)); - measure(reps_b, it, || { - black_box(Weak::ptr_eq(black_box(&w1), black_box(&w2))); - }) - }), - ("Weak::as_ptr == Arc::as_ptr (proxy, x1; no refcount)", { - let arc = Arc::new(0u64); - let w1 = Arc::downgrade(&arc); - measure(reps_b, it, || { - black_box(std::ptr::eq( - Weak::as_ptr(black_box(&w1)), - Arc::as_ptr(black_box(&arc)), - )); - }) - }), - ( - "bind_dr64 total (real)", - measure(reps_b, it, || { - black_box( - bind_dr64( - t.store(), - m, - m, - black_box(&guards), - now, - Operand { - rows: &rows, - validity: None, - }, - ) - .unwrap(), - ); - }), - ), - ( - "validate total (real; bind_dr64 + grant read)", - measure(reps_b, it, || { - black_box(validate(&t, &g, m, m, black_box(&guards)).unwrap()); - }), - ), - ("grant epoch load (D' per op)", { - let sg = SharedGrants::new(Scope { id: 3 }); - let sv = validate_shared(&t, &sg, m, m, &guards).unwrap(); - measure(reps_b, it, || { - black_box(black_box(&sv).count().unwrap()); - }) - }), - ("count via fn pointer, rows = 64 (B)", { - let w = &rows[..1]; - measure(reps_b, it, || { - black_box((KERNELS.count)(black_box(w))); - }) - }), - ("count direct, rows = 64 (A)", { - let w = &rows[..1]; - measure(reps_b, it, || { - black_box(ndarray::simd::popcount_batch_u64(black_box(w))); - }) - }), - ]; - println!("guard pieces, ns per call [allocations]:\n"); - println!("| piece | ns | allocs |"); - println!("|---|---:|---:|"); - for (name, (ns, al)) in &pieces { - println!("| {name} | {ns:.2} | {al:.0} |"); - } - - // ── E: a change after the borrow is refused at acquisition ───────────── - let (mut t, g, m, guards) = setup(64); - t.advance(revisions(2), data(64, 1)); - let e = measure(reps, iters, || { - black_box(validate(&t, &g, m, m, black_box(&guards)).err()); - }); - assert_eq!( - validate(&t, &g, m, m, &guards).err(), - Some(ViewRefusal::Bind(Refusal::SourceChanged)) - ); - println!( - "\nE: re-validation after advance is refused (SourceChanged), {:.1} ns [{:.0}]", - e.0, e.1 - ); - println!("\nns per call, median of NFR6_REPS. Reported, not gated."); -} diff --git a/crates/dr-validated-view-probe/src/lib.rs b/crates/dr-validated-view-probe/src/lib.rs deleted file mode 100644 index 8167cd8b8..000000000 --- a/crates/dr-validated-view-probe/src/lib.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! D-NFR-6: validate a DR64 binding once, then run several native operations -//! over a view whose lifetime keeps the binding valid. Lab only. -//! -//! Three responsibilities, kept apart: -//! -//! - **Method resolution.** Which native function implements an operation is -//! fixed per method and resolved once ([`KERNELS`], a `LazyLock`). Nothing -//! here chooses a route per call. -//! - **Data binding.** Whether a frame, focus and scope still hold. This can go -//! stale, so it is validated, by the existing `bind_dr64` and nothing new, -//! when a [`View`] is acquired. -//! - **Execution.** A [`View`]'s operations call `ndarray::simd` directly on -//! borrowed data and validate nothing. -//! -//! What makes skipping validation per operation sound is not a cache but -//! ownership. A [`Table`] owns its data, its [`FrameStore`] and its focus map -//! together. Every change to any of them needs `&mut Table`, and a [`View`] -//! holds `&Table` for its whole life. So while a view exists none of them can -//! change, and that is checked by the compiler, not at run time: -//! -//! ```compile_fail,E0502 -//! use dr_validated_view_probe::fixture::*; -//! let (mut t, g, m, guards) = setup(64); -//! let view = validate_presence(&t, &g, m, &guards).unwrap(); -//! t.advance(revisions(2), data(64, 5)); // F1: the source cannot move under a view -//! drop(view); -//! ``` -//! -//! ```compile_fail,E0502 -//! use dr_validated_view_probe::fixture::*; -//! let (mut t, g, m, guards) = setup(64); -//! let view = validate_presence(&t, &g, m, &guards).unwrap(); -//! t.refocus([0; 64]); // F2: nor can the focus map -//! drop(view); -//! ``` -//! -//! ```compile_fail,E0502 -//! use dr_validated_view_probe::fixture::*; -//! let (t, mut g, m, guards) = setup(64); -//! let view = validate_presence(&t, &g, m, &guards).unwrap(); -//! g.revoke(); // F3, owned grants: nor can the scope be revoked -//! drop(view); -//! ``` -//! -//! ```compile_fail,E0597 -//! use dr_validated_view_probe::fixture::*; -//! let leaked; -//! { -//! let (t, g, m, guards) = setup(64); -//! leaked = validate_presence(&t, &g, m, &guards).unwrap(); -//! } // F4: a view cannot outlive the table it was validated against -//! let _ = leaked.count(); -//! ``` -//! -//! ```compile_fail,E0597 -//! use dr_validated_view_probe::fixture::*; -//! let (t, g, m, guards) = setup(64); -//! let view = validate_presence(&t, &g, m, &guards).unwrap(); -//! let rows: &'static [u64; 64] = view.rows(); // F7: a borrow cannot escape as 'static -//! ``` -//! -//! What ownership does NOT cover, and where the runtime check therefore stays: -//! -//! - **Revocation owned elsewhere.** If scope revocation is owned by another -//! component (an ACL service, an `Arc` shared across threads), the view's -//! borrow cannot stop it. [`SharedGrants`] models that: revocation is an -//! atomic, and a [`SharedView`] reads one epoch per operation instead of -//! re-running the whole guard. -//! - **Data outside the table.** `bind_dr64` accepts any borrowed operand -//! with a valid frame. Its frame seal identifies a revision issue, not the -//! bytes; coupling bytes to the frame is what [`Table`] adds. Data handed in -//! from outside a `Table` keeps the per-use check. -#![forbid(unsafe_code)] - -use std::sync::LazyLock; -use std::sync::atomic::{AtomicU64, Ordering}; - -pub use dr_math_binding_probe::{Focus, Frame}; -use dr_math_binding_probe::{ - FrameRevisions, FrameStore, Guards, MethodSignature, Operand, Refusal, Scope, bind_dr64, -}; - -/// Rows covered by one DR64 register: 64 words of 64 bits. -pub const MAX_ROWS: usize = 64 * 64; - -/// The data a frame refers to: a DR64 selection register and the `i32` value -/// lane it selects from. Owned by a [`Table`] and changed only through -/// [`Table::advance`]. -#[derive(Clone, Debug)] -pub struct Data { - pub rows: [u64; 64], - pub values: Vec, -} - -/// Owner of the data, the frame store and the local-to-global focus map. Each -/// change goes through `&mut self`, so it cannot happen while a [`View`] -/// borrows the table. -#[derive(Debug)] -pub struct Table { - store: FrameStore, - data: Data, - focus_revision: u64, - local_to_global: [u32; 64], -} - -impl Table { - /// # Panics - /// If `data.values` is longer than [`MAX_ROWS`]. - pub fn new( - revisions: FrameRevisions, - data: Data, - focus_revision: u64, - local_to_global: [u32; 64], - ) -> Self { - assert!( - data.values.len() <= MAX_ROWS, - "more rows than one DR64 register" - ); - Self { - store: FrameStore::new(revisions), - data, - focus_revision, - local_to_global, - } - } - - pub fn seal(&self) -> Frame { - self.store.seal() - } - - pub fn focus(&self) -> Focus { - Focus { - revision: self.focus_revision, - } - } - - pub fn store(&self) -> &FrameStore { - &self.store - } - - pub fn local_to_global(&self) -> &[u32; 64] { - &self.local_to_global - } - - /// Replace the data and its revisions together, so new bytes never sit - /// under an old frame. - /// - /// # Panics - /// If `data.values` is longer than [`MAX_ROWS`]. - pub fn advance(&mut self, revisions: FrameRevisions, data: Data) { - assert!( - data.values.len() <= MAX_ROWS, - "more rows than one DR64 register" - ); - self.store.advance(revisions); - self.data = data; - } - - /// Replace the focus map. The table issues the next revision itself: - /// focus identity is a revision number, so a caller-chosen number could - /// change the map while keeping the revision, and nothing downstream - /// would see it. Even an identical map gets a new revision. - pub fn refocus(&mut self, local_to_global: [u32; 64]) { - self.focus_revision += 1; - self.local_to_global = local_to_global; - } -} - -/// Scope grants owned by the caller: revoking needs `&mut self`, so it cannot -/// happen while a [`View`] borrows them. -#[derive(Debug)] -pub struct Grants { - scope: Option, -} - -impl Grants { - pub fn new(scope: Scope) -> Self { - Self { scope: Some(scope) } - } - - pub fn current(&self) -> Option { - self.scope - } - - pub fn revoke(&mut self) { - self.scope = None; - } -} - -/// Scope grants owned elsewhere and revocable through `&self`, as when they -/// are shared through an `Arc`. A borrow cannot prevent revocation here; see -/// [`SharedView`]. -#[derive(Debug)] -pub struct SharedGrants { - scope: Scope, - /// Even: granted. Odd: revoked. Bumped by every revocation. - epoch: AtomicU64, -} - -impl SharedGrants { - pub fn new(scope: Scope) -> Self { - Self { - scope, - epoch: AtomicU64::new(0), - } - } - - pub fn revoke(&self) { - self.epoch.fetch_or(1, Ordering::Release); - } - - fn current(&self) -> Option<(Scope, u64)> { - let e = self.epoch.load(Ordering::Acquire); - (e & 1 == 0).then_some((self.scope, e)) - } -} - -/// Why a view was not issued or an operation did not answer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ViewRefusal { - /// The existing validator's refusal, unchanged. - Bind(Refusal), - /// The grants no longer carry a scope. - ScopeRevoked, -} - -impl From for ViewRefusal { - fn from(r: Refusal) -> Self { - ViewRefusal::Bind(r) - } -} - -// ── method resolution, once ──────────────────────────────────────────────── - -/// The native kernels a view runs, resolved once. A copied `fn` pointer -/// captures nothing, so it cannot hold a view's borrow past a call. -#[derive(Clone, Copy)] -pub struct Kernels { - pub count: fn(&[u64]) -> u64, - pub sum: fn(&[i32], &[u64]) -> i64, - pub min: fn(&[i32], &[u64]) -> Option, - pub max: fn(&[i32], &[u64]) -> Option, - pub gt: fn(&[i32], i32, &mut [u64]), - pub and: fn(&[u64], &[u64], &mut [u64]), -} - -pub static KERNELS: LazyLock = LazyLock::new(|| Kernels { - count: ndarray::simd::popcount_batch_u64, - sum: ndarray::simd::masked_sum_i32, - min: ndarray::simd::masked_min_i32, - max: ndarray::simd::masked_max_i32, - gt: ndarray::simd::gt_i32_to_mask, - and: ndarray::simd::mask_and, -}); - -// ── acquisition: the one validation ─────────────────────────────────────── - -/// Validate once and return a view. This is the only check: the existing -/// `bind_dr64` (method, axes, semantics, validity, frame, focus, scope), -/// with the focus taken from the table and the scope from the grants rather -/// than from the caller. -pub fn validate<'t>( - table: &'t Table, - grants: &'t Grants, - declared: MethodSignature, - requested: MethodSignature, - bound_at: &Guards, -) -> Result, ViewRefusal> { - let scope = grants.current().ok_or(ViewRefusal::ScopeRevoked)?; - let rows = bind_rows(table, declared, requested, bound_at, scope)?; - Ok(View { - rows, - values: &table.data.values, - kernels: *KERNELS, - _borrow: std::marker::PhantomData, - }) -} - -/// [`validate`] against grants another owner can revoke. The view records the -/// grant epoch and checks it on every operation. -pub fn validate_shared<'t>( - table: &'t Table, - grants: &'t SharedGrants, - declared: MethodSignature, - requested: MethodSignature, - bound_at: &Guards, -) -> Result, ViewRefusal> { - let (scope, epoch) = grants.current().ok_or(ViewRefusal::ScopeRevoked)?; - let rows = bind_rows(table, declared, requested, bound_at, scope)?; - Ok(SharedView { - view: View { - rows, - values: &table.data.values, - kernels: *KERNELS, - _borrow: std::marker::PhantomData, - }, - grants, - epoch, - }) -} - -fn bind_rows<'t>( - table: &'t Table, - declared: MethodSignature, - requested: MethodSignature, - bound_at: &Guards, - scope: Scope, -) -> Result<&'t [u64; 64], ViewRefusal> { - let bound = bind_dr64( - &table.store, - declared, - requested, - bound_at, - (table.focus(), scope), - Operand { - rows: &table.data.rows, - validity: None, - }, - )?; - // The view passes no validity plane, so `bind_dr64` refuses every - // three-valued method (`ThreeValuedNeedsValidity`) and every Bag - // (`BagNeedsMultiplicity`): only two-valued Distinct reaches here. - Ok(bound.rows) -} - -// ── execution: no validation ─────────────────────────────────────────────── - -/// A validated, lifetime-borrowed binding. Its operations call the resolved -/// native kernels directly and check nothing; the borrow of the table and the -/// grants is what keeps the validation true. -#[derive(Clone, Copy)] -pub struct View<'t> { - rows: &'t [u64; 64], - values: &'t [i32], - kernels: Kernels, - _borrow: std::marker::PhantomData<&'t ()>, -} - -impl<'t> View<'t> { - pub fn rows(&self) -> &'t [u64; 64] { - self.rows - } - - pub fn values(&self) -> &'t [i32] { - self.values - } - - fn words(&self) -> &'t [u64] { - &self.rows[..self.values.len().div_ceil(64)] - } - - /// `COUNT(*)` of the selection. - pub fn count(&self) -> u64 { - (self.kernels.count)(self.words()) - } - - /// `SUM(v)` over the selection. - pub fn sum(&self) -> i64 { - (self.kernels.sum)(self.values, self.words()) - } - - /// `MIN(v)` over the selection; `None` for an empty selection. - pub fn min(&self) -> Option { - (self.kernels.min)(self.values, self.words()) - } - - /// `MAX(v)` over the selection; `None` for an empty selection. - pub fn max(&self) -> Option { - (self.kernels.max)(self.values, self.words()) - } - - /// `COUNT(*) WHERE v > t` within the selection: one mask comparison, one - /// mask AND, one count, into the caller's scratch (two masks of - /// `words` words). No allocation. - /// - /// # Panics - /// If `scratch` holds fewer than two masks' worth of words. - pub fn count_gt(&self, t: i32, scratch: &mut [u64]) -> u64 { - let w = self.words().len(); - let (cmp, rest) = scratch.split_at_mut(w); - let both = &mut rest[..w]; - (self.kernels.gt)(self.values, t, cmp); - (self.kernels.and)(cmp, self.words(), both); - (self.kernels.count)(both) - } -} - -/// A view whose scope can be revoked by another owner. Each operation loads -/// the grant epoch once and refuses if it moved; frame and focus are still -/// held by the table borrow and are not rechecked. -pub struct SharedView<'t> { - view: View<'t>, - grants: &'t SharedGrants, - epoch: u64, -} - -impl<'t> SharedView<'t> { - #[inline] - fn live(&self) -> Result<&View<'t>, ViewRefusal> { - if self.grants.epoch.load(Ordering::Acquire) == self.epoch { - Ok(&self.view) - } else { - Err(ViewRefusal::ScopeRevoked) - } - } - - pub fn count(&self) -> Result { - Ok(self.live()?.count()) - } - - pub fn sum(&self) -> Result { - Ok(self.live()?.sum()) - } - - pub fn min(&self) -> Result, ViewRefusal> { - Ok(self.live()?.min()) - } - - pub fn max(&self) -> Result, ViewRefusal> { - Ok(self.live()?.max()) - } - - pub fn count_gt(&self, t: i32, scratch: &mut [u64]) -> Result { - Ok(self.live()?.count_gt(t, scratch)) - } -} - -/// Shared fixtures for the tests, the example and the compile-fail doctests. -pub mod fixture { - use super::*; - use dr_math_binding_probe::{Axis, AxisKind}; - use lance_graph_contract::class_view::ClassId; - - pub fn revisions(source: u64) -> FrameRevisions { - FrameRevisions { - source, - relation_map: 1, - coordinates: 1, - } - } - - /// `n` rows; the selection keeps rows `r % 3 != 1`; values are signed. - pub fn data(n: usize, salt: i32) -> Data { - let mut rows = [0u64; 64]; - for r in (0..n).filter(|r| r % 3 != 1) { - rows[r / 64] |= 1 << (r % 64); - } - Data { - rows, - values: (0..n) - .map(|i| ((i as i32 * 37 + salt) % 201) - 100) - .collect(), - } - } - - pub fn method(ordinal: u16) -> MethodSignature { - let axis = |kind| Axis { - class: 7 as ClassId, - kind, - }; - MethodSignature::presence( - 7, - ordinal, - axis(AxisKind::Identity), - axis(AxisKind::Property), - ) - } - - /// A table over `n` rows, grants for scope 3, the method, and the guards - /// it was bound under (frame sealed now, focus 7, scope 3). - pub fn setup(n: usize) -> (Table, Grants, MethodSignature, Guards) { - let t = Table::new(revisions(1), data(n, 0), 7, [0; 64]); - let guards = Guards { - frame: t.seal(), - focus: t.focus(), - scope: Scope { id: 3 }, - }; - (t, Grants::new(Scope { id: 3 }), method(1), guards) - } - - pub fn validate_presence<'t>( - t: &'t Table, - g: &'t Grants, - m: MethodSignature, - guards: &Guards, - ) -> Result, ViewRefusal> { - validate(t, g, m, m, guards) - } -} diff --git a/crates/dr-validated-view-probe/tests/falsifiers.rs b/crates/dr-validated-view-probe/tests/falsifiers.rs deleted file mode 100644 index 3c714b878..000000000 --- a/crates/dr-validated-view-probe/tests/falsifiers.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! D-NFR-6 runtime falsifiers. The compile-time ones (F1, F2, F3 for owned -//! grants, F4 outliving, F7 escaping borrow) are `compile_fail` doctests in -//! `src/lib.rs`; this file covers what happens once the borrow ends, the -//! shared-revocation escape, and the semantic refusals. - -use dr_math_binding_probe::{ - Axis, AxisKind, Guards, MethodSignature, Operand, Population, Refusal, Scope, Truth, bind_dr64, -}; -use dr_validated_view_probe::fixture::{data, method, revisions, setup, validate_presence}; -use dr_validated_view_probe::{SharedGrants, Table, ViewRefusal, validate, validate_shared}; - -/// The answers an independent row loop gives: count, sum, min, max, count(v > t). -fn oracle(rows: &[u64; 64], v: &[i32], t: i32) -> (u64, i64, Option, Option, u64) { - let sel = |r: usize| rows[r / 64] >> (r % 64) & 1 == 1; - let kept: Vec = (0..v.len()).filter(|&r| sel(r)).map(|r| v[r]).collect(); - ( - kept.len() as u64, - kept.iter().map(|&x| i64::from(x)).sum(), - kept.iter().copied().min(), - kept.iter().copied().max(), - kept.iter().filter(|&&x| x > t).count() as u64, - ) -} - -const SIZES: [usize; 8] = [1, 2, 63, 64, 65, 1000, 4095, 4096]; - -/// FAILS IF: a view's five operations disagree with the direct ndarray calls -/// or with the row loop, at any size. -#[test] -fn view_answers_equal_direct_ndarray_and_the_row_loop() { - for n in SIZES { - let (t, g, m, guards) = setup(n); - let view = validate_presence(&t, &g, m, &guards).unwrap(); - let w = n.div_ceil(64); - let (rows, v) = (view.rows(), view.values()); - let mut scratch = vec![0u64; 2 * w]; - for thr in [-101, -3, 0, 50, 100] { - let want = oracle(rows, v, thr); - let got = ( - view.count(), - view.sum(), - view.min(), - view.max(), - view.count_gt(thr, &mut scratch), - ); - assert_eq!(got, want, "n={n} t={thr}"); - let mut cmp = vec![0u64; w]; - let mut both = vec![0u64; w]; - ndarray::simd::gt_i32_to_mask(v, thr, &mut cmp); - ndarray::simd::mask_and(&cmp, &rows[..w], &mut both); - let direct = ( - ndarray::simd::popcount_batch_u64(&rows[..w]), - ndarray::simd::masked_sum_i32(v, &rows[..w]), - ndarray::simd::masked_min_i32(v, &rows[..w]), - ndarray::simd::masked_max_i32(v, &rows[..w]), - ndarray::simd::popcount_batch_u64(&both), - ); - assert_eq!(got, direct, "n={n} t={thr}"); - } - } -} - -/// FAILS IF: the fixture is vacuous: the selection is all or nothing, or the -/// threshold never splits it. -#[test] -fn the_fixture_is_not_vacuous() { - let (t, g, m, guards) = setup(4096); - let view = validate_presence(&t, &g, m, &guards).unwrap(); - let c = view.count(); - assert!(c > 0 && c < 4096); - assert!(view.min() < view.max()); - let mut s = vec![0u64; 128]; - let k = view.count_gt(0, &mut s); - assert!(k > 0 && k < c); -} - -/// FAILS IF: after the borrow ends, a source advance, a refocus or a revoked -/// scope still lets the old guards validate. The silent twin: the same -/// table validates again under freshly taken guards. -#[test] -fn after_the_borrow_ends_every_change_is_refused_at_acquisition() { - let (mut t, mut g, m, guards) = setup(64); - // A view is `Copy`; every copy carries the same borrow, which ends here. - let _ = validate_presence(&t, &g, m, &guards).unwrap(); - - t.advance(revisions(2), data(64, 9)); - assert_eq!( - validate_presence(&t, &g, m, &guards).err(), - Some(ViewRefusal::Bind(Refusal::SourceChanged)) - ); - let fresh = Guards { - frame: t.seal(), - ..guards.clone() - }; - let v = validate_presence(&t, &g, m, &fresh).unwrap(); - assert_eq!(v.values(), &data(64, 9).values[..]); - - // A refocus with an identical map still moves the focus revision. - let map = *t.local_to_global(); - t.refocus(map); - assert_eq!( - validate_presence(&t, &g, m, &fresh).err(), - Some(ViewRefusal::Bind(Refusal::FocusChanged)) - ); - let fresh = Guards { - focus: t.focus(), - ..fresh - }; - assert!(validate_presence(&t, &g, m, &fresh).is_ok()); - - g.revoke(); - assert_eq!( - validate_presence(&t, &g, m, &fresh).err(), - Some(ViewRefusal::ScopeRevoked) - ); -} - -/// FAILS IF: re-advancing to the same numbers keeps old frames valid, or a -/// frame from another table with equal numbers is accepted. -#[test] -fn a_resealed_or_foreign_frame_is_refused() { - let (mut t, g, m, guards) = setup(64); - t.advance(revisions(1), data(64, 0)); // same numbers, new issue - assert_eq!( - validate_presence(&t, &g, m, &guards).err(), - Some(ViewRefusal::Bind(Refusal::FrameResealed)) - ); - let other = Table::new(revisions(1), data(64, 0), 7, [0; 64]); - let foreign = Guards { - frame: other.seal(), - ..guards - }; - assert_eq!( - validate_presence(&t, &g, m, &foreign).err(), - Some(ViewRefusal::Bind(Refusal::ForeignFrame)) - ); -} - -/// FAILS IF: scope is treated as a number to compare instead of a grant. -/// Guards for scope 3 under grants for scope 4 are refused by the existing -/// check; a revoked grant is refused before it. -#[test] -fn scope_comes_from_the_grant_not_from_the_caller() { - let (t, _, m, guards) = setup(64); - let other = dr_validated_view_probe::Grants::new(Scope { id: 4 }); - assert_eq!( - validate(&t, &other, m, m, &guards).err(), - Some(ViewRefusal::Bind(Refusal::ScopeChanged)) - ); -} - -/// F5. FAILS IF: a revocation by another owner, during a live view, goes -/// unnoticed. The borrow cannot stop it, so the shared view checks the grant -/// epoch on each operation. Before revocation it answers. -#[test] -fn shared_revocation_during_a_view_is_refused_per_operation() { - let (t, _, m, guards) = setup(256); - let g = SharedGrants::new(Scope { id: 3 }); - let sv = validate_shared(&t, &g, m, m, &guards).unwrap(); - let want = oracle(&data(256, 0).rows, &data(256, 0).values, 0); - assert_eq!(sv.count(), Ok(want.0)); - assert_eq!(sv.sum(), Ok(want.1)); - g.revoke(); // through &self, while `sv` is alive: it compiles - assert_eq!(sv.count(), Err(ViewRefusal::ScopeRevoked)); - assert_eq!(sv.max(), Err(ViewRefusal::ScopeRevoked)); - let mut s = vec![0u64; 8]; - assert_eq!(sv.count_gt(0, &mut s), Err(ViewRefusal::ScopeRevoked)); - assert_eq!( - validate_shared(&t, &g, m, m, &guards).err(), - Some(ViewRefusal::ScopeRevoked) - ); -} - -/// F6. FAILS IF: a view is issued for another method, other axes, other -/// semantics, a three-valued method without validity, or a Bag population. -/// Each existing refusal of `bind_dr64` comes through unchanged. -#[test] -fn semantic_mismatches_keep_their_typed_refusals() { - let (t, g, m, guards) = setup(64); - let refuse = |declared: MethodSignature, requested: MethodSignature| { - validate(&t, &g, declared, requested, &guards).err() - }; - assert_eq!( - refuse(m, method(2)), - Some(ViewRefusal::Bind(Refusal::MethodChanged)) - ); - let other_axis = MethodSignature { - column: Axis { - class: 9, - kind: AxisKind::Community, - }, - ..m - }; - assert_eq!( - refuse(m, other_axis), - Some(ViewRefusal::Bind(Refusal::AxisChanged)) - ); - let three = MethodSignature { - truth: Truth::ThreeValued, - ..m - }; - assert_eq!( - refuse(m, three), - Some(ViewRefusal::Bind(Refusal::SemanticsChanged)) - ); - assert_eq!( - refuse(three, three), - Some(ViewRefusal::Bind(Refusal::ThreeValuedNeedsValidity)) - ); - let bag = MethodSignature { - population: Population::Bag, - ..m - }; - assert_eq!( - refuse(bag, bag), - Some(ViewRefusal::Bind(Refusal::BagNeedsMultiplicity)) - ); -} - -/// Records the audit finding that motivates `Table`: the existing validator -/// checks the frame, not the bytes. Rows that no frame owner issued are -/// accepted under a valid frame. FAILS IF that changes, so the claim in the -/// crate docs is re-examined. -#[test] -fn bind_dr64_checks_the_frame_not_the_data() { - let (t, _, m, guards) = setup(64); - let unrelated = [u64::MAX; 64]; - let bound = bind_dr64( - t.store(), - m, - m, - &guards, - (t.focus(), guards.scope), - Operand { - rows: &unrelated, - validity: None, - }, - ) - .unwrap(); - assert!(std::ptr::eq(bound.rows, &unrelated)); -} diff --git a/crates/soa-view-probe/Cargo.toml b/crates/soa-view-probe/Cargo.toml new file mode 100644 index 000000000..db105fe4f --- /dev/null +++ b/crates/soa-view-probe/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "soa-view-probe" +version = "0.0.0" +edition = "2024" +publish = false +description = "D-NFR-6 lab probe: native ndarray operations over a SoaEnvelope borrow (NodeRowPacket, 512-byte rows, 16-byte facets). Mutation safety is the borrow; no runtime guard. Lab only: no production contract or ABI." + +# Own workspace root: a lab crate excluded from the root workspace. +[workspace] + +[dependencies] +lance-graph-contract = { path = "../lance-graph-contract" } +# The native kernels. Same source string as mask-risc's copy. +ndarray = { git = "https://github.com/AdaWorldAPI/ndarray", branch = "master" } + +[patch."https://github.com/AdaWorldAPI/lance-graph"] +lance-graph-contract = { path = "../lance-graph-contract" } diff --git a/crates/soa-view-probe/examples/view_cost.rs b/crates/soa-view-probe/examples/view_cost.rs new file mode 100644 index 000000000..29ee2ab0e --- /dev/null +++ b/crates/soa-view-probe/examples/view_cost.rs @@ -0,0 +1,170 @@ +//! D-NFR-6: does an operation through a borrowed `NodeRowPacket` cost +//! anything beyond the ndarray kernel it names? Reported, not gated. +//! +//! Arm A: the ndarray kernel on the raw LE bytes. Arm B: the same kernel +//! through `FacetFold` on the packet. Both write into caller scratch, so +//! neither should allocate; a counting allocator checks that. +//! +//! `cargo run --release --example view_cost` +//! Assembly: `cargo rustc --release --example view_cost -- --emit=asm`, then +//! compare the `probe_a_*` / `probe_b_*` bodies. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use lance_graph_contract::NodeRowPacket; +use lance_graph_contract::canonical_node::NODE_ROW_STRIDE; +use lance_graph_contract::soa_envelope::SoaEnvelope; +use soa_view_probe::fixture::{CLASS_A, rows}; +use soa_view_probe::{FacetFold, FacetSlot}; + +struct Counting; +static ALLOCS: AtomicUsize = AtomicUsize::new(0); +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + ALLOCS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + unsafe { System.dealloc(p, l) } + } +} +#[global_allocator] +static GLOBAL: Counting = Counting; + +const AT: usize = 32; // value facet 0 + +#[inline(never)] +fn probe_a_count(bytes: &[u8], n: usize, sel: &mut [u64]) -> u64 { + ndarray::simd::eq_u32_strided_to_mask(bytes, AT, NODE_ROW_STRIDE, n, CLASS_A, sel); + ndarray::simd::popcount_batch_u64(sel) +} + +#[inline(never)] +fn probe_b_count(view: &NodeRowPacket<'_>, sel: &mut [u64]) -> u64 { + view.classid_mask(FacetSlot::value(0).unwrap(), CLASS_A, sel); + ndarray::simd::popcount_batch_u64(sel) +} + +#[inline(never)] +fn probe_a_sum(bytes: &[u8], n: usize, sel: &mut [u64]) -> i64 { + ndarray::simd::eq_u32_strided_to_mask(bytes, AT, NODE_ROW_STRIDE, n, CLASS_A, sel); + ndarray::simd::masked_strided_group_sum(bytes, AT + 4, NODE_ROW_STRIDE, n, 12, 1, sel).unwrap() +} + +#[inline(never)] +fn probe_b_sum(view: &NodeRowPacket<'_>, sel: &mut [u64]) -> i64 { + let slot = FacetSlot::value(0).unwrap(); + view.classid_mask(slot, CLASS_A, sel); + view.payload_byte_sum(slot, sel) +} + +#[inline(never)] +fn probe_a_match(bytes: &[u8], n: usize, p: &[u8; 16], c: &[u8; 16], sel: &mut [u64]) -> u64 { + ndarray::simd::ternary_match_strided16_to_mask(bytes, AT, NODE_ROW_STRIDE, n, p, c, sel); + ndarray::simd::popcount_batch_u64(sel) +} + +#[inline(never)] +fn probe_b_match(view: &NodeRowPacket<'_>, p: &[u8; 16], c: &[u8; 16], sel: &mut [u64]) -> u64 { + view.facet_match_mask(FacetSlot::value(0).unwrap(), p, c, sel); + ndarray::simd::popcount_batch_u64(sel) +} + +fn median_ns(reps: usize, iters: usize, mut f: impl FnMut()) -> f64 { + let mut t: Vec = (0..reps) + .map(|_| { + let s = Instant::now(); + for _ in 0..iters { + f(); + } + s.elapsed().as_nanos() as f64 / iters as f64 + }) + .collect(); + t.sort_by(f64::total_cmp); + t[reps / 2] +} + +fn main() { + let mut pattern = [0u8; 16]; + pattern[..4].copy_from_slice(&CLASS_A.to_le_bytes()); + let mut care = [0u8; 16]; + care[..4].fill(0xFF); + care[4] = 0x80; + + println!("rows op A direct ns B packet ns allocs A/B"); + for n in [64usize, 4096, 65536] { + let owner = rows(n); + let view = NodeRowPacket::new(&owner, 1); + let bytes = view.as_le_bytes(); + let mut sel = vec![0u64; n.div_ceil(64)]; + let iters = (1 << 22) / n.max(64); + + assert_eq!( + probe_a_count(bytes, n, &mut sel), + probe_b_count(&view, &mut sel) + ); + assert_eq!( + probe_a_sum(bytes, n, &mut sel), + probe_b_sum(&view, &mut sel) + ); + assert_eq!( + probe_a_match(bytes, n, &pattern, &care, &mut sel), + probe_b_match(&view, &pattern, &care, &mut sel) + ); + + let row = |name: &str, a: &mut dyn FnMut(), b: &mut dyn FnMut()| { + let a0 = ALLOCS.load(Ordering::Relaxed); + let ta = median_ns(21, iters, &mut *a); + let a1 = ALLOCS.load(Ordering::Relaxed); + let tb = median_ns(21, iters, &mut *b); + let b1 = ALLOCS.load(Ordering::Relaxed); + println!( + "{n:>6} {name:<7} {ta:>12.1} {tb:>13.1} {}/{}", + a1 - a0, + b1 - a1 + ); + }; + let s = std::cell::RefCell::new(&mut sel); + row( + "count", + &mut || { + black_box(probe_a_count(black_box(bytes), n, &mut s.borrow_mut())); + }, + &mut || { + black_box(probe_b_count(black_box(&view), &mut s.borrow_mut())); + }, + ); + row( + "sum", + &mut || { + black_box(probe_a_sum(black_box(bytes), n, &mut s.borrow_mut())); + }, + &mut || { + black_box(probe_b_sum(black_box(&view), &mut s.borrow_mut())); + }, + ); + row( + "match", + &mut || { + black_box(probe_a_match( + black_box(bytes), + n, + &pattern, + &care, + &mut s.borrow_mut(), + )); + }, + &mut || { + black_box(probe_b_match( + black_box(&view), + &pattern, + &care, + &mut s.borrow_mut(), + )); + }, + ); + } +} diff --git a/crates/soa-view-probe/src/lib.rs b/crates/soa-view-probe/src/lib.rs new file mode 100644 index 000000000..6ddcf224a --- /dev/null +++ b/crates/soa-view-probe/src/lib.rs @@ -0,0 +1,241 @@ +//! D-NFR-6 lab probe: native operations over a [`SoaEnvelope`] borrow. +//! +//! **Mutation safety is the borrow; the mathematics is ndarray.** There is no +//! table type, no second ownership mechanism and no runtime guard here. +//! +//! - **Owner:** whoever holds the `Vec` (the mailbox). It mutates +//! through `&mut` and nothing else. +//! - **View:** [`NodeRowPacket`], the contract's existing `&self` borrow of +//! those rows. As [`SoaEnvelope`]'s own docs put it, a view cannot outlive +//! or alias the owner, and the owner's `&mut` cannot begin while any view is +//! live: "Rust's borrow rules ARE the inheritance; nothing is checked at +//! runtime." The packet is deliberately not `Clone`, so it cannot become a +//! second holder either. The `compile_fail` examples below pin all three. +//! - **Operations:** [`FacetFold`], one ndarray kernel per method, over the +//! packet's LE bytes at the 512-byte row stride and the 16-byte facet +//! stride every other SoA in lance-graph uses. Each method body is that one +//! call; there is no per-operation check to pay for. +//! +//! What this crate does not do, on purpose: it does not check frame +//! provenance or external authorization. Those are real questions (who sealed +//! these bytes; may this caller read them), but they belong to whoever hands +//! out the packet, once, and they are not a reason to re-check, per +//! operation, something the borrow checker already guarantees. The earlier +//! version of this crate did exactly that (a `Table` beside the SoA plus a +//! frame guard on each access) and is superseded; see the plan. +//! +//! # The borrow does the work +//! +//! The owner cannot write while a view is live (E0502): +//! +//! ```compile_fail,E0502 +//! use lance_graph_contract::NodeRowPacket; +//! let mut rows = soa_view_probe::fixture::rows(64); +//! let view = NodeRowPacket::new(&rows, 1); +//! rows[0].value[0] = 7; +//! let _ = view.rows().len(); +//! ``` +//! +//! A view cannot outlive its owner (E0597): +//! +//! ```compile_fail,E0597 +//! use lance_graph_contract::NodeRowPacket; +//! let view; +//! { +//! let rows = soa_view_probe::fixture::rows(64); +//! view = NodeRowPacket::new(&rows, 1); +//! } +//! let _ = view.rows().len(); +//! ``` +//! +//! A view cannot be duplicated into a second holder (E0599: no `clone`): +//! +//! ```compile_fail,E0599 +//! use lance_graph_contract::NodeRowPacket; +//! let rows = soa_view_probe::fixture::rows(64); +//! let view = NodeRowPacket::new(&rows, 1); +//! let second = view.clone(); +//! ``` +//! +//! The twin that must compile: once the view is gone the owner writes, and a +//! new view sees the new bytes. +//! +//! ``` +//! use lance_graph_contract::NodeRowPacket; +//! use soa_view_probe::{FacetFold, FacetSlot, fixture}; +//! let mut rows = fixture::rows(64); +//! let slot = FacetSlot::value(0).unwrap(); +//! let mut sel = [0u64; 1]; +//! for class in [fixture::CLASS_A, fixture::CLASS_B] { +//! rows[1].value[..4].copy_from_slice(&class.to_le_bytes()); +//! let view = NodeRowPacket::new(&rows, 2); +//! view.classid_mask(slot, class, &mut sel); +//! assert_eq!(sel[0] >> 1 & 1, 1); +//! } +//! ``` + +use lance_graph_contract::canonical_node::{ + GUIDS_PER_NODE, NODE_ROW_STRIDE, VALUE_SLAB_ROW_OFFSET, +}; +use lance_graph_contract::soa_envelope::SoaEnvelope; +use lance_graph_contract::{NodeRow, NodeRowPacket}; + +/// Bytes per facet: `classid(4) + 12-byte payload`. +pub const FACET_BYTES: usize = 16; + +/// One of the 32 facet slots of a 512-byte row (slot 0 is the key, slot 1 the +/// edge facet, slots 2.. the value slab). Built once; operations then trust +/// it, because a value of this type is in range by construction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FacetSlot(u8); + +impl FacetSlot { + /// The key facet. + pub const KEY: Self = Self(0); + + /// Slot `k` of the row, if it exists. + pub const fn new(k: usize) -> Option { + if k < GUIDS_PER_NODE { + Some(Self(k as u8)) + } else { + None + } + } + + /// Facet `k` of the value slab (bytes `32 + 16k ..`), if it exists. + pub const fn value(k: usize) -> Option { + Self::new(VALUE_SLAB_ROW_OFFSET / FACET_BYTES + k) + } + + /// Byte offset of this facet inside a row. + pub const fn offset(self) -> usize { + self.0 as usize * FACET_BYTES + } +} + +/// Native operations over a borrowed packet. Every method is one ndarray call +/// on [`SoaEnvelope::as_le_bytes`] at the row stride; masks have one bit per +/// row (`n_rows().div_ceil(64)` words). +pub trait FacetFold { + /// Rows whose facet `slot` carries `classid` in its first four bytes. + fn classid_mask(&self, slot: FacetSlot, classid: u32, out: &mut [u64]); + + /// Rows whose 16-byte facet `slot` agrees with `pattern` on every set + /// bit of `care`. + fn facet_match_mask( + &self, + slot: FacetSlot, + pattern: &[u8; 16], + care: &[u8; 16], + out: &mut [u64], + ); + + /// Sum of the twelve payload bytes (each `u8`, never widened) of facet + /// `slot` over the rows selected by `mask`. + fn payload_byte_sum(&self, slot: FacetSlot, mask: &[u64]) -> i64; +} + +impl FacetFold for NodeRowPacket<'_> { + #[inline] + fn classid_mask(&self, slot: FacetSlot, classid: u32, out: &mut [u64]) { + ndarray::simd::eq_u32_strided_to_mask( + self.as_le_bytes(), + slot.offset(), + NODE_ROW_STRIDE, + self.n_rows(), + classid, + out, + ); + } + + #[inline] + fn facet_match_mask( + &self, + slot: FacetSlot, + pattern: &[u8; 16], + care: &[u8; 16], + out: &mut [u64], + ) { + ndarray::simd::ternary_match_strided16_to_mask( + self.as_le_bytes(), + slot.offset(), + NODE_ROW_STRIDE, + self.n_rows(), + pattern, + care, + out, + ); + } + + #[inline] + fn payload_byte_sum(&self, slot: FacetSlot, mask: &[u64]) -> i64 { + // 12 bytes per row, at most 255 each: the i128 accumulator inside the + // kernel cannot leave i64 below ~3e15 rows, so `None` is unreachable + // for any slice that fits in memory. + ndarray::simd::masked_strided_group_sum( + self.as_le_bytes(), + slot.offset() + 4, + NODE_ROW_STRIDE, + self.n_rows(), + 12, + 1, + mask, + ) + .expect("12 u8 per row cannot overflow i64 for an in-memory slice") + } +} + +/// Owner-side write: facet `k` of the value slab of one row. The owner holds +/// `&mut`, so no view of these rows can be live while this runs. +pub fn set_value_facet(row: &mut NodeRow, k: usize, facet: [u8; FACET_BYTES]) { + let at = k * FACET_BYTES; + row.value[at..at + FACET_BYTES].copy_from_slice(&facet); +} + +/// Deterministic rows for tests and the cost example. +pub mod fixture { + use super::{FACET_BYTES, set_value_facet}; + use lance_graph_contract::canonical_node::TailVariant; + use lance_graph_contract::{EdgeBlock, NodeGuid, NodeRow}; + + /// Two classids (canon high: concept, low: app) for the value facet. + pub const CLASS_A: u32 = 0x0B05_0001; + /// The other classid. + pub const CLASS_B: u32 = 0x080B_0001; + + /// `n` rows: key minted V3, value facet 0 alternating between the two + /// classids by a fixed pseudo-random pattern, payload bytes varying. + pub fn rows(n: usize) -> Vec { + let mut s: u64 = 0x9E37_79B9_7F4A_7C15; + (0..n) + .map(|i| { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + let key = NodeGuid::mint_for( + TailVariant::V3, + CLASS_A, + 1, + 2, + 3, + 4, + 5, + (i % 0xFFFF) as u32, + ); + let mut row = NodeRow { + key, + edges: EdgeBlock::from_bytes(&[0; 16]), + value: [0; 480], + }; + let class = if s % 3 == 0 { CLASS_B } else { CLASS_A }; + let mut facet = [0u8; FACET_BYTES]; + facet[..4].copy_from_slice(&class.to_le_bytes()); + for (j, b) in facet[4..].iter_mut().enumerate() { + *b = (s >> (j * 5)) as u8; + } + set_value_facet(&mut row, 0, facet); + row + }) + .collect() + } +} diff --git a/crates/soa-view-probe/tests/falsifiers.rs b/crates/soa-view-probe/tests/falsifiers.rs new file mode 100644 index 000000000..73869a7fd --- /dev/null +++ b/crates/soa-view-probe/tests/falsifiers.rs @@ -0,0 +1,141 @@ +//! D-NFR-6 runtime falsifiers. The compile-time ones (no write during a live +//! view, no view outliving its owner, no second holder) are the +//! `compile_fail` examples in `src/lib.rs`. + +use lance_graph_contract::NodeRowPacket; +use lance_graph_contract::canonical_node::NODE_ROW_STRIDE; +use lance_graph_contract::soa_envelope::SoaEnvelope; +use soa_view_probe::fixture::{CLASS_A, CLASS_B, rows}; +use soa_view_probe::{FacetFold, FacetSlot, set_value_facet}; + +const SIZES: [usize; 8] = [1, 2, 63, 64, 65, 1000, 4095, 4096]; + +fn slot0() -> FacetSlot { + FacetSlot::value(0).unwrap() +} + +fn facet(row: &lance_graph_contract::NodeRow) -> (u32, &[u8]) { + ( + u32::from_le_bytes(row.value[..4].try_into().unwrap()), + &row.value[4..16], + ) +} + +/// Row-loop oracle over the typed rows, not the bytes: count of `class`, +/// count matching (class, first payload byte < 128), payload byte sum. +fn oracle(r: &[lance_graph_contract::NodeRow], class: u32) -> (u64, u64, i64) { + let hit: Vec<_> = r.iter().map(facet).filter(|(c, _)| *c == class).collect(); + ( + hit.len() as u64, + hit.iter().filter(|(_, p)| p[0] < 128).count() as u64, + hit.iter() + .flat_map(|(_, p)| p.iter()) + .map(|&b| i64::from(b)) + .sum(), + ) +} + +/// FAILS IF: an operation through the packet disagrees with the same ndarray +/// call on the raw bytes, or with the row loop, at any size. +#[test] +fn packet_operations_equal_direct_ndarray_and_the_row_loop() { + for n in SIZES { + let owner = rows(n); + let view = NodeRowPacket::new(&owner, 1); + let w = n.div_ceil(64); + for class in [CLASS_A, CLASS_B] { + let mut pattern = [0u8; 16]; + pattern[..4].copy_from_slice(&class.to_le_bytes()); + let mut care = [0u8; 16]; + care[..4].fill(0xFF); + care[4] = 0x80; // first payload byte < 128 + + let (mut sel, mut hit) = (vec![0u64; w], vec![0u64; w]); + view.classid_mask(slot0(), class, &mut sel); + view.facet_match_mask(slot0(), &pattern, &care, &mut hit); + let got = ( + ndarray::simd::popcount_batch_u64(&sel), + ndarray::simd::popcount_batch_u64(&hit), + view.payload_byte_sum(slot0(), &sel), + ); + assert_eq!(got, oracle(&owner, class), "n={n} class={class:#x}"); + + let bytes = view.as_le_bytes(); + let at = slot0().offset(); + let (mut dsel, mut dhit) = (vec![0u64; w], vec![0u64; w]); + ndarray::simd::eq_u32_strided_to_mask(bytes, at, NODE_ROW_STRIDE, n, class, &mut dsel); + ndarray::simd::ternary_match_strided16_to_mask( + bytes, + at, + NODE_ROW_STRIDE, + n, + &pattern, + &care, + &mut dhit, + ); + let dsum = ndarray::simd::masked_strided_group_sum( + bytes, + at + 4, + NODE_ROW_STRIDE, + n, + 12, + 1, + &dsel, + ); + assert_eq!((sel, hit, Some(got.2)), (dsel, dhit, dsum), "n={n}"); + } + } +} + +/// FAILS IF: the fixture is vacuous (one class only, or the payload filter +/// keeps all or none). +#[test] +fn the_fixture_is_not_vacuous() { + let owner = rows(4096); + let (a, a_lo, _) = oracle(&owner, CLASS_A); + let (b, _, _) = oracle(&owner, CLASS_B); + assert_eq!(a + b, 4096); + assert!(b > 1000 && a > 1000, "a={a} b={b}"); + assert!(a_lo > 0 && a_lo < a); +} + +/// FAILS IF: a view taken after the owner wrote reports the old bytes. There +/// is no stale view to refuse: the old one cannot exist across the write. +#[test] +fn a_view_taken_after_a_write_sees_the_write() { + let mut owner = rows(130); + let (a, _, _) = oracle(&owner, CLASS_A); + let mut facet = [0u8; 16]; + facet[..4].copy_from_slice(&CLASS_B.to_le_bytes()); + for row in owner.iter_mut() { + set_value_facet(row, 0, facet); + } + let view = NodeRowPacket::new(&owner, 2); + let mut sel = vec![0u64; 3]; + view.classid_mask(slot0(), CLASS_A, &mut sel); + assert!(a > 0); + assert_eq!(ndarray::simd::popcount_batch_u64(&sel), 0); + view.classid_mask(slot0(), CLASS_B, &mut sel); + assert_eq!(ndarray::simd::popcount_batch_u64(&sel), 130); + assert_eq!(view.payload_byte_sum(slot0(), &sel), 0); +} + +/// FAILS IF: the facet geometry drifts: slot 0 is the key, value facet 0 sits +/// at byte 32, and there are exactly 32 slots of 16 bytes. +#[test] +fn facet_slots_are_the_canonical_16_byte_grid() { + assert_eq!(FacetSlot::KEY.offset(), 0); + assert_eq!(slot0().offset(), 32); + assert!(FacetSlot::new(31).is_some()); + assert!(FacetSlot::new(32).is_none()); + assert!(FacetSlot::value(29).is_some()); + assert!(FacetSlot::value(30).is_none()); + let owner = rows(3); + let view = NodeRowPacket::new(&owner, 7); + assert!(view.verify_layout().is_ok()); + assert_eq!(view.row_stride(), 512); + // Key facet: the minted classid is in key bytes 0..4. + let mut sel = [0u64; 1]; + view.classid_mask(FacetSlot::KEY, CLASS_A, &mut sel); + assert_eq!(sel[0], 0b111); +} From d087613f9960c0c155aba297551f9a9ab4c42c5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:39:44 +0000 Subject: [PATCH 5/8] lab(D-NFR-6): clippy is_multiple_of Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/soa-view-probe/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/soa-view-probe/src/lib.rs b/crates/soa-view-probe/src/lib.rs index 6ddcf224a..01fd44f59 100644 --- a/crates/soa-view-probe/src/lib.rs +++ b/crates/soa-view-probe/src/lib.rs @@ -227,7 +227,11 @@ pub mod fixture { edges: EdgeBlock::from_bytes(&[0; 16]), value: [0; 480], }; - let class = if s % 3 == 0 { CLASS_B } else { CLASS_A }; + let class = if s.is_multiple_of(3) { + CLASS_B + } else { + CLASS_A + }; let mut facet = [0u8; FACET_BYTES]; facet[..4].copy_from_slice(&class.to_le_bytes()); for (j, b) in facet[4..].iter_mut().enumerate() { From cde20eae02c37cc3d60e9332837ca86738df94dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:43:41 +0000 Subject: [PATCH 6/8] lab(D-NFR-6): v2 plan, board and entry; v1 marked superseded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit view_cost now counts allocations around the operation loop only (the harness's own timing Vec had shown as 1/1) and reports µs per call and ns per row. Packet equals direct ndarray within noise at 64/4096/65536 rows, 0 allocations in both; asm shows the same kernel calls. D-NFR-6a (a faster FrameStore::check) is withdrawn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .claude/board/INTEGRATION_PLANS.md | 7 +- .claude/board/STATUS_BOARD.md | 8 +- .../entries/2026-10-23-d-nfr-6-soa-borrow.md | 9 +++ .../2026-10-23-d-nfr-6-validate-once.md | 4 +- .claude/board/entries/README.md | 3 +- .claude/plans/d-nfr-6-soa-borrow-v2.md | 80 +++++++++++++++++++ .claude/plans/d-nfr-6-validate-once-v1.md | 4 +- crates/soa-view-probe/examples/view_cost.rs | 45 ++++++----- 8 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 .claude/board/entries/2026-10-23-d-nfr-6-soa-borrow.md create mode 100644 .claude/plans/d-nfr-6-soa-borrow-v2.md diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index 72f9a3b4c..5513a8800 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -1,6 +1,11 @@ +## 2026-10-23 — d-nfr-6-soa-borrow-v2 — native operations over the SoaEnvelope borrow, no guard → `.claude/plans/d-nfr-6-soa-borrow-v2.md` + +**Status:** MEASURED, lab only (`crates/soa-view-probe`). Supersedes `d-nfr-6-validate-once-v1`: views are `NodeRowPacket` borrows, operations are one ndarray kernel each, mutation is the owner's `&mut`; no `Table`, no frame guard. Packet equals direct ndarray within noise at 0 allocations. D-NFR-6a withdrawn. +**Confidence:** measured on one AVX-512 host; no independent review yet. + ## 2026-10-23 — d-nfr-6-validate-once-v1 — validate a binding once, then execute without repeated guards → `.claude/plans/d-nfr-6-validate-once-v1.md` -**Status:** MEASURED, lab only (`crates/dr-validated-view-probe`). The per-call 27–28 ns guard is `FrameStore::check`'s refcount traffic; a view validated once by the existing `bind_dr64`, and kept valid by borrowing the table that owns data, frame and focus, runs native operations at direct cost. Externally owned revocation keeps a one-load check per operation. Proposals D-NFR-6a need the probe owner's review. +**Status:** SUPERSEDED by d-nfr-6-soa-borrow-v2 (was MEASURED, lab only, `crates/dr-validated-view-probe`, removed). The per-call 27–28 ns guard is `FrameStore::check`'s refcount traffic; a view validated once by the existing `bind_dr64`, and kept valid by borrowing the table that owns data, frame and focus, runs native operations at direct cost. Externally owned revocation keeps a one-load check per operation. Proposals D-NFR-6a need the probe owner's review. ## 2026-10-23 — d-mpf-math-polyfill-v1 — the mathematical polyfill API, Stage 1 → `.claude/plans/d-mpf-math-polyfill-v1.md` **Status:** D-MPF-1 MEASURED, lab only; D-MPF-2.. PROPOSED. The Stage 1 polyfill API is the shipped R2IL procedure layer plus checked scalar arithmetic over mask-risc, with relalg as the rewriting frontend; no new primitive. One expression matches an independent row loop; at 64 K rows it costs under 1 ns/row, at one word ~600 ns per call. Gaps by counterexample: element-wise arithmetic, unsigned compare, floats, one-word fixed cost. Independent of ONNX and of the S0 drafts. Entry `2026-10-23-d-mpf-1-polyfill-probe.md`. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 8b8a57151..9aa5289ff 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -1,11 +1,11 @@ -## D-NFR-6 — validate once, execute without repeated guards (2026-10-23) +## D-NFR-6 — native operations over the SoaEnvelope borrow, no guard (2026-10-23) -Lab only: `crates/dr-validated-view-probe`, plan `.claude/plans/d-nfr-6-validate-once-v1.md`. Parent D-NFR (#1753); guard figure from D-MPF-4 (#1752). +Lab only: `crates/soa-view-probe`, plan `.claude/plans/d-nfr-6-soa-borrow-v2.md` (supersedes `d-nfr-6-validate-once-v1.md`). Parent D-NFR (#1753). | D-id | scope | status | gate / falsifier | |---|---|---|---| -| **D-NFR-6** | one `bind_dr64` validation authorizes many native operations over a lifetime-borrowed view; guard cost split; shared-revocation escape | MEASURED (`entries/2026-10-23-d-nfr-6-validate-once.md`) | 5 compile_fail doctests (E0502/E0597, codes pinned); 8 runtime falsifiers; 4 disable runs, each red on one test; asm read | -| **D-NFR-6a** | `FrameStore::check` by pointer comparison; `BoundDr64` borrows its frame | PROPOSAL (owner coordination) | same refusals, fewer `lock` RMWs, measured | +| **D-NFR-6** | views are `NodeRowPacket` borrows (512-byte rows, 16-byte facets); operations are one ndarray kernel each; mutation through the owner's `&mut`; no `Table`, no frame guard | MEASURED (`entries/2026-10-23-d-nfr-6-soa-borrow.md`) | 3 compile_fail examples (E0502/E0597/E0599); 4 runtime falsifiers; 3 disable runs red; packet = direct within noise, 0 allocations, asm shows only kernel calls | +| ~~**D-NFR-6a**~~ | ~~`FrameStore::check` by pointer comparison~~ | WITHDRAWN (v2: a faster unneeded guard is not the fix) | — | ## D-MPF — mathematical polyfill API, Stage 1 (2026-10-23) diff --git a/.claude/board/entries/2026-10-23-d-nfr-6-soa-borrow.md b/.claude/board/entries/2026-10-23-d-nfr-6-soa-borrow.md new file mode 100644 index 000000000..d1c277717 --- /dev/null +++ b/.claude/board/entries/2026-10-23-d-nfr-6-soa-borrow.md @@ -0,0 +1,9 @@ +# D-NFR-6 v2 — native operations over the SoaEnvelope borrow + +**Date:** 2026-10-23 · **Status:** MEASURED, lab only · **Plan:** `.claude/plans/d-nfr-6-soa-borrow-v2.md` · **Crate:** `crates/soa-view-probe` · supersedes `2026-10-23-d-nfr-6-validate-once.md` + +- **Correction:** v1 put a `Table` with its own `FrameStore` beside the SoA and checked a frame per operation. That re-derived ownership `SoaEnvelope` already carries. Mutation safety is the borrow (`&self` views, the owner's `&mut`); frame provenance and authorization are separate questions and do not justify a per-operation check. +- **Design:** view = `NodeRowPacket` (512-byte rows, not `Clone`); address = `FacetSlot` (one of 32 × 16 bytes, in range by construction); operations = `FacetFold`, one ndarray kernel each (`eq_u32_strided_to_mask`, `ternary_match_strided16_to_mask`, `masked_strided_group_sum` over 12 × `u8`). +- **Pinned:** E0502 (no write during a live view), E0597 (no view outliving the owner), E0599 (no second holder); packet = direct ndarray = row loop at 8 sizes; 3 disable runs red. +- **Cost:** packet equals direct within noise at 64 / 4096 / 65536 rows, 0 allocations in both; asm: same kernel calls, B +4–7 instructions (`n_rows × 512` and register moves), no `lock`/`cpuid`. +- **Withdrawn:** D-NFR-6a (faster `FrameStore::check`). diff --git a/.claude/board/entries/2026-10-23-d-nfr-6-validate-once.md b/.claude/board/entries/2026-10-23-d-nfr-6-validate-once.md index 684765263..42ba3e7ad 100644 --- a/.claude/board/entries/2026-10-23-d-nfr-6-validate-once.md +++ b/.claude/board/entries/2026-10-23-d-nfr-6-validate-once.md @@ -1,6 +1,8 @@ # D-NFR-6 — validate once, execute without repeated guards -**Date:** 2026-10-23 · **Status:** MEASURED, lab only · **Plan:** `.claude/plans/d-nfr-6-validate-once-v1.md` · **Crate:** `crates/dr-validated-view-probe` +> ⊘ Superseded by `2026-10-23-d-nfr-6-soa-borrow.md`: the `Table` + frame guard design is withdrawn. + +**Date:** 2026-10-23 · **Status:** SUPERSEDED (was MEASURED, lab only) · **Plan:** `.claude/plans/d-nfr-6-validate-once-v1.md` · **Crate:** `crates/dr-validated-view-probe` - **Q1, the guard:** the 27–28 ns per-call guard (#1752 route) is `FrameStore::check` (25.7–28.3 ns; 96 instructions; six `lock` RMWs) plus two `u64` compares (~1.3 ns). The cost is the temporary `Weak`s from `Arc::downgrade`; the pointer comparisons cost ~1 ns each. `bind_dr64` adds a `Frame` clone (two more `lock inc`): 59–67 ns total. - **Q2/Q5, validate once:** a `Table` owns data, frame store and focus map; every change takes `&mut`. `validate` runs the existing `bind_dr64` once and returns a `View<'t>`. While a view lives, advance, refocus and revoke are compile errors (E0502), and a view cannot outlive its table or leak a `'static` borrow (E0597). After the borrow, stale guards are refused at re-acquisition. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index ef3fc5068..a00b5a805 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ 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. -459 entries, 2026-08-06 .. 2026-10-23. +460 entries, 2026-08-06 .. 2026-10-23. | date | entry id | finding | file | |---|---|---|---| @@ -40,6 +40,7 @@ exactly one of them, never both. | 2026-10-23 | `D-ONNX-RELALG-CAP` | S0 result: ONNX as import membrane, DR64³ plan not found | [2026-10-23-d-onnx-relalg-cap-s0.md](2026-10-23-d-onnx-relalg-cap-s0.md) | | 2026-10-23 | `E-D-ONNX-RELALG-CAP-S0-1` | | [2026-10-23-d-onnx-relalg-cap-s0-capstone-1750.md](2026-10-23-d-onnx-relalg-cap-s0-capstone-1750.md) | | 2026-10-23 | `D-NFR-6` | | [2026-10-23-d-nfr-6-validate-once.md](2026-10-23-d-nfr-6-validate-once.md) | +| 2026-10-23 | `D-NFR-6` | | [2026-10-23-d-nfr-6-soa-borrow.md](2026-10-23-d-nfr-6-soa-borrow.md) | | 2026-10-23 | `D-MPF-4` | | [2026-10-23-d-mpf-4-classid-route.md](2026-10-23-d-mpf-4-classid-route.md) | | 2026-10-23 | `D-MPF-2` | | [2026-10-23-d-mpf-2-fixed-cost.md](2026-10-23-d-mpf-2-fixed-cost.md) | | 2026-10-23 | `E-D-MPF-1` | | [2026-10-23-d-mpf-1-polyfill-probe.md](2026-10-23-d-mpf-1-polyfill-probe.md) | diff --git a/.claude/plans/d-nfr-6-soa-borrow-v2.md b/.claude/plans/d-nfr-6-soa-borrow-v2.md new file mode 100644 index 000000000..71ee67891 --- /dev/null +++ b/.claude/plans/d-nfr-6-soa-borrow-v2.md @@ -0,0 +1,80 @@ +# D-NFR-6 v2 — native operations over the SoaEnvelope borrow, no guard + +**Status:** MEASURED, lab only (`crates/soa-view-probe`). No production contract, ABI or ClassId/LazyLock change. Supersedes `d-nfr-6-validate-once-v1.md`. + +**Rule:** mutation safety belongs to `&self` / `&mut self`; the mathematics belongs to ndarray. A view of SoA rows is the contract's existing borrow (`NodeRowPacket`), and an operation over it is the ndarray kernel it names. Nothing between them is checked at runtime. + +## 1. What v1 got wrong + +v1 built a `Table` that held its own `FrameStore` beside the SoA, issued frames, and checked a frame on each operation through `bind_dr64` (measured: `FrameStore::check` 25.7–28.3 ns, 96 instructions, six `lock` RMWs; `bind_dr64` 59–67 ns). That re-derived ownership the contract already has. `SoaEnvelope`'s own docs say views are `&self` borrows of the owner, a batch-writer `&mut` cannot begin while a view is live, and "nothing is checked at runtime." The frame check guarded against a mutation the borrow checker already makes impossible. + +Frame provenance (who sealed these bytes) and external authorization (may this caller read them) are real questions. They are answered by whoever hands out the packet, once; they are not a reason for a per-operation check. v1's conflation of the two is withdrawn, and so is its D-NFR-6a proposal (a faster `FrameStore::check`): making an unneeded guard cheaper is not the fix. + +## 2. Design (lab) + +| part | what it is | where | +|---|---|---| +| owner | the `Vec` the mailbox holds; writes through `&mut` (`set_value_facet`) | caller | +| view | `NodeRowPacket<'_>`: `&self` borrow, 512-byte row stride, not `Clone` | `lance-graph-contract` (unchanged) | +| facet address | `FacetSlot`, one of the 32 16-byte slots; built once, in range by construction | `soa-view-probe` | +| operations | `FacetFold` on `NodeRowPacket`: `classid_mask` → `eq_u32_strided_to_mask`; `facet_match_mask` → `ternary_match_strided16_to_mask`; `payload_byte_sum` → `masked_strided_group_sum` over 12 × `u8` (never widened); counts via `popcount_batch_u64` | ndarray | + +No new ownership type, no frame, no epoch, no dispatch. + +## 3. Falsifiers + +| claim | how it is pinned | disable run | +|---|---|---| +| no owner write while a view is live | `compile_fail,E0502` | — (rustdoc checks the code) | +| no view outlives its owner | `compile_fail,E0597` | — | +| no second holder of a view | `compile_fail,E0599` (packet has no `clone`) | — | +| a view taken after a write sees the write | runtime test + the compiling doctest twin | — | +| packet result = direct ndarray = row loop, 8 sizes (1..4096), both classids, mask/match/sum | `packet_operations_equal_direct_ndarray_and_the_row_loop` | classid offset +1 → 3 tests red; payload width 11 → 1 red | +| fixture is not vacuous | `the_fixture_is_not_vacuous` | one class only → red | +| facet geometry (slot 0 key, value facet 0 at byte 32, 32 slots) | `facet_slots_are_the_canonical_16_byte_grid` | covered by the offset disable | + +## 4. Cost (reported, not gated) + +`examples/view_cost.rs`, release, AVX-512 host, load ≈ 0.45. Medians of 21 batches; three runs agree within noise. A = the ndarray kernel on the raw LE bytes; B = the same kernel through `FacetFold` on the packet. Allocations are counted around the operation loop only. + +| rows | op | A µs/call | B µs/call | ns/row (A ≈ B) | allocs | +|---:|---|---:|---:|---:|---| +| 64 | classid mask + count | 0.13 | 0.12 | ~2 | 0 / 0 | +| 64 | mask + payload sum | 0.33 | 0.30 | ~5 | 0 / 0 | +| 64 | ternary match + count | 0.06 | 0.06 | ~0.9 | 0 / 0 | +| 4096 | classid mask + count | 11.1 | 10.5 | ~2.7 | 0 / 0 | +| 4096 | mask + payload sum | 27.0 | 26.8 | ~6.6 | 0 / 0 | +| 4096 | ternary match + count | 10.3 | 10.3 | ~2.5 | 0 / 0 | +| 65536 | classid mask + count | 356 | 357 | ~5.4 | 0 / 0 | +| 65536 | mask + payload sum | 903 | 861 | ~13.5 | 0 / 0 | +| 65536 | ternary match + count | 335 | 336 | ~5.1 | 0 / 0 | + +The per-row cost is the strided read itself (one 64-byte line per 512-byte row; 65536 rows is 32 MB), and it is the same in both arms. + +Assembly (`cargo rustc --release --example view_cost -- --emit=asm`, `probe_a_*` vs `probe_b_*`): each pair calls the same ndarray kernels and nothing else. B is 4–7 instructions longer: one `shlq $9` (the `n_rows × 512` length `as_le_bytes` computes) and register moves for the different argument shape. No `lock`, no `cpuid`, no lazy initialization in any probe. + +## 5. Answers + +**Q1 — what does the old 27–28 ns guard execute?** Unchanged from v1: `FrameStore::check`, dominated by creating and dropping two temporary `Weak`s. v2's answer is that it should not execute at all on this path. + +**Q2 — what does borrowing guarantee?** That no write to the rows happens while any view is live, that no view outlives the rows, and that a view is not duplicated into a second holder (§3, compile errors). + +**Q3 — what needs runtime validation?** Not mutation. Arguments that the type system does not bound are bounded once, by construction (`FacetSlot`), not per call. Frame provenance and authorization are decided where the packet is handed out; they are outside this probe. + +**Q4 — at which boundary?** Where the owner gives out `&rows`. That is a single point, and it is already the borrow. + +**Q5 — can several operations reuse one view?** Yes; any number, with no per-operation cost (§4). + +**Q6 — what does it cost against direct ndarray?** Nothing measurable: equal within run-to-run noise, zero allocations in both arms, same kernel calls in the asm (§4). + +## 6. Acceptance + +- [x] no `Table`, no duplicate ownership, no frame guard +- [x] view = `NodeRowPacket`, 512-byte row stride, 16-byte facets +- [x] every operation is one ndarray kernel +- [x] compile-time refusals pinned with error codes +- [x] packet = direct = row loop; 3 disable runs red +- [x] cost: equal, 0 allocations, asm shows only the kernel calls +- [x] fmt, clippy `-D warnings`, tests (own CI workflow `soa-view-probe.yml`) +- [ ] independent review +- [ ] operator authorization before merge diff --git a/.claude/plans/d-nfr-6-validate-once-v1.md b/.claude/plans/d-nfr-6-validate-once-v1.md index 7b1100287..9b39db60e 100644 --- a/.claude/plans/d-nfr-6-validate-once-v1.md +++ b/.claude/plans/d-nfr-6-validate-once-v1.md @@ -1,6 +1,8 @@ # D-NFR-6 — validate a binding once, then execute without repeated guards -**Status:** MEASURED, lab only (`crates/dr-validated-view-probe`). No production contract, ABI or other crate is changed. Parent: D-NFR (`d-native-repr-contract-v1.md`, #1753); the per-call guard figure comes from D-MPF-4 (#1752 §10). +> ⊘ **SUPERSEDED by `d-nfr-6-soa-borrow-v2.md` (2026-10-23).** The `Table` below kept its own `FrameStore` beside the SoA and checked a frame per access, re-deriving ownership that `SoaEnvelope`'s borrow already gives (views are `&self`, the owner's `&mut` cannot start while one is live). The crate was reworked into `crates/soa-view-probe`; D-NFR-6a is withdrawn. The measurements below remain the record of what the guard cost. + +**Status:** SUPERSEDED; was MEASURED, lab only (`crates/dr-validated-view-probe`, removed). No production contract, ABI or other crate is changed. Parent: D-NFR (`d-native-repr-contract-v1.md`, #1753); the per-call guard figure comes from D-MPF-4 (#1752 §10). **Question.** The #1752 ClassId route runs a frame/focus/scope guard on every call, and that guard measured 27–28 ns. Is that guard needed on every operation, or can ownership and borrowing guarantee the same thing at binding time? diff --git a/crates/soa-view-probe/examples/view_cost.rs b/crates/soa-view-probe/examples/view_cost.rs index 29ee2ab0e..5e1740055 100644 --- a/crates/soa-view-probe/examples/view_cost.rs +++ b/crates/soa-view-probe/examples/view_cost.rs @@ -73,18 +73,24 @@ fn probe_b_match(view: &NodeRowPacket<'_>, p: &[u8; 16], c: &[u8; 16], sel: &mut ndarray::simd::popcount_batch_u64(sel) } -fn median_ns(reps: usize, iters: usize, mut f: impl FnMut()) -> f64 { - let mut t: Vec = (0..reps) - .map(|_| { - let s = Instant::now(); - for _ in 0..iters { - f(); - } - s.elapsed().as_nanos() as f64 / iters as f64 - }) - .collect(); +/// Median ns per call over `reps` timed batches, plus the allocations made +/// by the operation itself (counted around the call loop only). +fn median_ns(reps: usize, iters: usize, mut f: impl FnMut()) -> (f64, usize) { + let mut t = [0f64; 21]; + assert!(reps <= t.len()); + let mut allocs = 0; + for slot in t.iter_mut().take(reps) { + let a0 = ALLOCS.load(Ordering::Relaxed); + let s = Instant::now(); + for _ in 0..iters { + f(); + } + *slot = s.elapsed().as_nanos() as f64 / iters as f64; + allocs += ALLOCS.load(Ordering::Relaxed) - a0; + } + let t = &mut t[..reps]; t.sort_by(f64::total_cmp); - t[reps / 2] + (t[reps / 2], allocs) } fn main() { @@ -94,7 +100,7 @@ fn main() { care[..4].fill(0xFF); care[4] = 0x80; - println!("rows op A direct ns B packet ns allocs A/B"); + println!(" rows op A µs/call B µs/call A ns/row B ns/row allocs A/B"); for n in [64usize, 4096, 65536] { let owner = rows(n); let view = NodeRowPacket::new(&owner, 1); @@ -116,15 +122,14 @@ fn main() { ); let row = |name: &str, a: &mut dyn FnMut(), b: &mut dyn FnMut()| { - let a0 = ALLOCS.load(Ordering::Relaxed); - let ta = median_ns(21, iters, &mut *a); - let a1 = ALLOCS.load(Ordering::Relaxed); - let tb = median_ns(21, iters, &mut *b); - let b1 = ALLOCS.load(Ordering::Relaxed); + let (ta, aa) = median_ns(21, iters, &mut *a); + let (tb, ab) = median_ns(21, iters, &mut *b); println!( - "{n:>6} {name:<7} {ta:>12.1} {tb:>13.1} {}/{}", - a1 - a0, - b1 - a1 + "{n:>6} {name:<7} {:>10.2} {:>10.2} {:>9.2} {:>9.2} {aa}/{ab}", + ta / 1000.0, + tb / 1000.0, + ta / n as f64, + tb / n as f64 ); }; let s = std::cell::RefCell::new(&mut sel); From 497ec394baf845f294469833f0c03d7b5ad18b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:19:27 +0000 Subject: [PATCH 7/8] lab(D-NFR-6): state the ordinary-borrowing assumption Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .claude/plans/d-nfr-6-soa-borrow-v2.md | 2 ++ crates/soa-view-probe/src/lib.rs | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/.claude/plans/d-nfr-6-soa-borrow-v2.md b/.claude/plans/d-nfr-6-soa-borrow-v2.md index 71ee67891..27b6a35f2 100644 --- a/.claude/plans/d-nfr-6-soa-borrow-v2.md +++ b/.claude/plans/d-nfr-6-soa-borrow-v2.md @@ -21,6 +21,8 @@ Frame provenance (who sealed these bytes) and external authorization (may this c No new ownership type, no frame, no epoch, no dispatch. +**Assumption:** all mutation goes through the owner by ordinary borrowing (`&mut`). The guarantee does not extend to interior mutability or an `unsafe` write path beside the owner; `NodeRow`'s fields are plain byte arrays, so neither exists here. + ## 3. Falsifiers | claim | how it is pinned | disable run | diff --git a/crates/soa-view-probe/src/lib.rs b/crates/soa-view-probe/src/lib.rs index 01fd44f59..b6aa87397 100644 --- a/crates/soa-view-probe/src/lib.rs +++ b/crates/soa-view-probe/src/lib.rs @@ -16,6 +16,11 @@ //! stride every other SoA in lance-graph uses. Each method body is that one //! call; there is no per-operation check to pay for. //! +//! **Assumption:** every mutation goes through that same owner by ordinary +//! borrowing. The guarantee does not cover rows reachable through interior +//! mutability (`Cell`, atomics, a lock) or an `unsafe` write path beside the +//! owner; neither exists for `NodeRow`, whose fields are plain byte arrays. +//! //! What this crate does not do, on purpose: it does not check frame //! provenance or external authorization. Those are real questions (who sealed //! these bytes; may this caller read them), but they belong to whoever hands From 52b8b6716fe61b62802110991ce4a1d97508bdf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:27:08 +0000 Subject: [PATCH 8/8] board: give the superseded D-NFR-6 v1 entry its own id in the entries index The generator carries existing rows forward by file, so renaming the heading alone left two D-NFR-6 rows and failed entries_index --check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .claude/board/entries/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 115d99af3..0574df52a 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -39,7 +39,7 @@ exactly one of them, never both. | 2026-10-23 | `D-TGA-5` | | [2026-10-23-d-tga-5-squash-gate-preregistration.md](2026-10-23-d-tga-5-squash-gate-preregistration.md) | | 2026-10-23 | `D-ONNX-RELALG-CAP` | S0 result: ONNX as import membrane, DR64³ plan not found | [2026-10-23-d-onnx-relalg-cap-s0.md](2026-10-23-d-onnx-relalg-cap-s0.md) | | 2026-10-23 | `E-D-ONNX-RELALG-CAP-S0-1` | | [2026-10-23-d-onnx-relalg-cap-s0-capstone-1750.md](2026-10-23-d-onnx-relalg-cap-s0-capstone-1750.md) | -| 2026-10-23 | `D-NFR-6` | | [2026-10-23-d-nfr-6-validate-once.md](2026-10-23-d-nfr-6-validate-once.md) | +| 2026-10-23 | `D-NFR-6-v1` | | [2026-10-23-d-nfr-6-validate-once.md](2026-10-23-d-nfr-6-validate-once.md) | | 2026-10-23 | `D-NFR-6` | | [2026-10-23-d-nfr-6-soa-borrow.md](2026-10-23-d-nfr-6-soa-borrow.md) | | 2026-10-23 | `D-NFR-0` | | [2026-10-23-d-nfr-0-native-repr-contract.md](2026-10-23-d-nfr-0-native-repr-contract.md) | | 2026-10-23 | `D-MPF-4` | | [2026-10-23-d-mpf-4-classid-route.md](2026-10-23-d-mpf-4-classid-route.md) |