From 8d11da0b8571b989546856ea249957d4315f71a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 11:13:03 +0000 Subject: [PATCH 1/4] report: AbiBatch lanes through the DT64 lane socket (D-LANESOCK-2) AbiLanes implements LaneSource over a resident AbiBatch: field positions resolve through the ClassView basis, Catalog and FieldId; lanes are the batch's own Arc-backed I32/U32 lanes, borrowed through LaneRef; one FrameStore per source seals (SourceId, generation) and publish() advances it. No production constructor: DR has no authoritative reader-scope issuer, so AbiLanes::unwitnessed exists only under the test-only unwitnessed-scope feature. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMMJzKZrYnckXRC8x2U --- crates/lance-graph-report/Cargo.toml | 13 + crates/lance-graph-report/src/lane_source.rs | 183 +++++++ crates/lance-graph-report/src/lib.rs | 2 + .../lance-graph-report/tests/lane_socket.rs | 503 ++++++++++++++++++ 4 files changed, 701 insertions(+) create mode 100644 crates/lance-graph-report/src/lane_source.rs create mode 100644 crates/lance-graph-report/tests/lane_socket.rs diff --git a/crates/lance-graph-report/Cargo.toml b/crates/lance-graph-report/Cargo.toml index f2e4cd95a..0de108a47 100644 --- a/crates/lance-graph-report/Cargo.toml +++ b/crates/lance-graph-report/Cargo.toml @@ -15,5 +15,18 @@ lance-graph-quack = { path = "../lance-graph-quack" } lance-graph-mask-risc = { path = "../lance-graph-mask-risc" } # ContentId / ContentStore / ContentSink: the KV ownership surface (zero-dep). lance-graph-contract = { path = "../lance-graph-contract" } +# The DT64 lane socket (`lane_source`). Depends on the contract only, so +# report -> dt64 adds no cycle. +lance-graph-dt64 = { path = "../lance-graph-dt64" } + +[features] +# TEST ONLY: exposes `AbiLanes::unwitnessed`, which takes a reader scope from +# the caller. DR has no authoritative scope issuer, so no production build +# may enable this. +unwitnessed-scope = [] + +[[test]] +name = "lane_socket" +required-features = ["unwitnessed-scope"] [dev-dependencies] diff --git a/crates/lance-graph-report/src/lane_source.rs b/crates/lance-graph-report/src/lane_source.rs new file mode 100644 index 000000000..d6cd6cb1d --- /dev/null +++ b/crates/lance-graph-report/src/lane_source.rs @@ -0,0 +1,183 @@ +//! The DT64 lane socket over a resident [`AbiBatch`] (D-LANESOCK-2). +//! +//! [`AbiLanes`] implements [`LaneSource`] for lanes this crate already holds. +//! It binds nothing itself and owns no field table: +//! +//! - **WHAT / WHICH BEHAVIOUR** is DT64's: `BindStore::bind_lanes` resolves the +//! class, its schema revision and its field basis, and `BindStore::admit` +//! admits the method. +//! - **WHERE** is the frame's: one [`FrameStore`] per source, whose revisions +//! are `(SourceId, relation map, generation)`. Publishing a replacement batch +//! advances it, so every binding sealed under the old batch is refused. +//! - **VALUES** stay in the batch: `borrow` hands out the batch's own `Arc` +//! lane as a `LaneRef`. Nothing is copied. +//! - **HOW** is Quack / mask-RISC, over the borrowed lanes. +//! +//! A field position resolves through the same [`ClassView`] basis DT64 checked +//! (`view.fields(view_class(class))[field].predicate_iri`), then the +//! [`Catalog`] (name → [`FieldId`]), then [`AbiBatch::column`] (FieldId → lane +//! index). Name lookup happens per bind and per check, never per row. +//! +//! ## Admission scope +//! +//! - **Value kinds:** `I32` and `U32` lanes only. A `U64` reference lane is +//! unbound. Exact decimal and workbook values are not admitted here. +//! - **Reader scope: NOT production-admitted.** DR has no authoritative issuer +//! of a reader [`Scope`] (it is a plain `{ id }` value), so this adapter +//! cannot certify which scope a batch serves. Its only constructor, +//! [`AbiLanes::unwitnessed`], exists under the test-only feature +//! `unwitnessed-scope` and takes the scope from the caller. A default build +//! has no way to construct an `AbiLanes`. +//! - **Writer:** none. This is a read adapter over resident lanes; durable +//! value ingress is out of scope. + +use std::sync::Arc; + +use lance_graph_contract::class_view::ClassView; +use lance_graph_dt64::dr::{Frame, FrameRevisions, FrameStore, Scope}; +use lance_graph_dt64::{view_class, CoordSpace, LaneAddr, LaneSource, SchemaRev}; +use lance_graph_mask_risc::LaneRef; + +use crate::batch::{AbiBatch, LaneData}; +use crate::boundary::Catalog; +use crate::ids::SourceId; + +/// The relation-map revision of every frame this adapter seals. The adapter +/// holds no relation map; the revision is fixed so that only a change of +/// source or generation moves the frame. +pub const RELATION_MAP: u64 = 0; + +/// Why a replacement batch was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PublishRefusal { + /// The replacement belongs to another source. + ForeignSource { bound: SourceId, offered: SourceId }, + /// The replacement's generation does not exceed the current one. + StaleGeneration { current: u32, offered: u32 }, +} + +/// A resident batch presented to DT64's lane socket. +#[derive(Debug)] +pub struct AbiLanes<'v, V: ClassView + ?Sized> { + view: &'v V, + catalog: &'v Catalog, + batch: Arc, + frames: FrameStore, + scope: Scope, +} + +impl<'v, V: ClassView + ?Sized> AbiLanes<'v, V> { + /// TEST ONLY. The caller asserts which reader scope the batch serves; no + /// DR authority stands behind it, which is why this constructor is not + /// available in a default build. + #[cfg(feature = "unwitnessed-scope")] + pub fn unwitnessed( + view: &'v V, + catalog: &'v Catalog, + batch: Arc, + scope: Scope, + ) -> Self { + let frames = FrameStore::new(revisions_of(&batch)); + Self { + view, + catalog, + batch, + frames, + scope, + } + } + + /// The frame owner for this source. Pass it to `BindStore::bind_lanes` + /// and `LaneBinding::check`. + pub fn frames(&self) -> &FrameStore { + &self.frames + } + + /// The current frame. + pub fn seal(&self) -> Frame { + self.frames.seal() + } + + /// The row order of the current batch. + pub fn space(&self) -> CoordSpace { + space_of(&self.batch) + } + + /// The resident batch. + pub fn batch(&self) -> &Arc { + &self.batch + } + + /// Replace the batch with a later generation of the same source and + /// advance the frame. Every binding sealed before is refused afterwards. + pub fn publish(&mut self, batch: Arc) -> Result { + if batch.source() != self.batch.source() { + return Err(PublishRefusal::ForeignSource { + bound: self.batch.source(), + offered: batch.source(), + }); + } + if batch.generation() <= self.batch.generation() { + return Err(PublishRefusal::StaleGeneration { + current: self.batch.generation(), + offered: batch.generation(), + }); + } + self.frames.advance(revisions_of(&batch)); + self.batch = batch; + Ok(self.frames.seal()) + } +} + +fn revisions_of(batch: &AbiBatch) -> FrameRevisions { + FrameRevisions { + source: u64::from(batch.source().0), + relation_map: RELATION_MAP, + coordinates: u64::from(batch.generation()), + } +} + +fn space_of(batch: &AbiBatch) -> CoordSpace { + CoordSpace { + population: u64::from(batch.source().0), + coordinates: u64::from(batch.generation()), + } +} + +impl LaneSource for AbiLanes<'_, V> { + type Lane<'a> + = LaneRef<'a> + where + Self: 'a; + + fn lane_of(&self, class: u32, _rev: SchemaRev, field: u8, frame: &Frame) -> Option { + // Only this source's current frame addresses its lanes. + self.frames.check(frame).ok()?; + let name = &self + .view + .fields(view_class(class)) + .get(usize::from(field))? + .predicate_iri; + let id = self.catalog.field(name)?; + let (lane, column) = self.batch.column(id)?; + match column.lane { + LaneData::I32(_) | LaneData::U32(_) => Some(LaneAddr { + space: space_of(&self.batch), + lane, + scope: self.scope, + }), + LaneData::U64(_) => None, + } + } + + fn borrow(&self, addr: &LaneAddr) -> Option> { + if addr.space != space_of(&self.batch) || addr.scope != self.scope { + return None; + } + match &self.batch.columns().get(usize::from(addr.lane))?.lane { + LaneData::I32(v) => Some(LaneRef::I32(v)), + LaneData::U32(v) => Some(LaneRef::U32(v)), + LaneData::U64(_) => None, + } + } +} diff --git a/crates/lance-graph-report/src/lib.rs b/crates/lance-graph-report/src/lib.rs index 06e07c6df..dc9d80bbc 100644 --- a/crates/lance-graph-report/src/lib.rs +++ b/crates/lance-graph-report/src/lib.rs @@ -52,6 +52,7 @@ pub mod boundary; pub mod exec; pub mod explain; pub mod ids; +pub mod lane_source; pub mod plan; pub mod render; pub mod result; @@ -60,6 +61,7 @@ pub mod selection; pub use batch::{AbiBatch, BatchError, Column, LaneData}; pub use exec::{Accumulator, ExecStats, PhysicalPlan, PlannerPolicy, Provider, SelectionCarrier}; pub use ids::{FieldId, MaskId, SourceId}; +pub use lane_source::{AbiLanes, PublishRefusal}; pub use plan::{ AxisRole, AxisSpec, CellValue, CoordSpec, FoldState, Measure, MeasureKind, PhysicalKey, ReportPlan, SourceRef, TopK, diff --git a/crates/lance-graph-report/tests/lane_socket.rs b/crates/lance-graph-report/tests/lane_socket.rs new file mode 100644 index 000000000..fd72ccaf3 --- /dev/null +++ b/crates/lance-graph-report/tests/lane_socket.rs @@ -0,0 +1,503 @@ +//! D-LANESOCK-2: resident `AbiBatch` lanes through DT64's lane socket, folded by +//! Quack / mask-RISC. Every refusal fires on a realistic input. +//! +//! Runs only with `--features unwitnessed-scope` (the reader scope here is the +//! test's assertion, not a DR witness; see `lane_source`'s module docs). + +use std::sync::Arc; + +use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ogar_codebook::compose_classid; +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +use lance_graph_contract::set_rules::{Population, Truth}; +use lance_graph_dt64::dr::{FrameRefusal, Scope}; +use lance_graph_dt64::{ + BindStore, ClassSchema, CoordSpace, Extent, LaneBinding, LaneRefusal, LaneRequest, + MethodBinding, MethodId, MethodRef, RouteAt, SchemaRev, SigRev, ValueDemand, +}; +use lance_graph_mask_risc::{execute_into, Foreign, LaneRef, Out, Planes, Scratch, Value}; +use lance_graph_quack::{lower, Agg, Cmp, Col, Filter, Query}; +use lance_graph_report::boundary::Catalog; +use lance_graph_report::{AbiBatch, AbiLanes, Column, FieldId, LaneData, PublishRefusal, SourceId}; + +// ── allocation counter (hot-path falsifier) ────────────────────────────────── + +struct CountingAlloc; +thread_local! { + static ALLOCATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} +unsafe impl std::alloc::GlobalAlloc for CountingAlloc { + unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 { + let _ = ALLOCATIONS.try_with(|c| c.set(c.get() + 1)); + unsafe { std::alloc::System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) { + unsafe { std::alloc::System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: std::alloc::Layout, size: usize) -> *mut u8 { + let _ = ALLOCATIONS.try_with(|c| c.set(c.get() + 1)); + unsafe { std::alloc::System.realloc(ptr, layout, size) } + } +} +#[global_allocator] +static GLOBAL: CountingAlloc = CountingAlloc; +fn allocations() -> u64 { + ALLOCATIONS.with(|c| c.get()) +} + +// ── the class, its ClassView basis, the catalog and the resident batch ─────── + +/// Concept half `0x0C1C`, custom half `1`, under the codebook's active split. +const CONCEPT: ClassId = 0x0C1C; +fn class() -> u32 { + compose_classid(CONCEPT, 1) +} +fn other_class() -> u32 { + compose_classid(CONCEPT, 2) +} +const POSITION: u16 = 9; +const READER: Scope = Scope { id: 41 }; +const SOURCE: SourceId = SourceId(7); +const N: usize = 300; + +/// Basis positions. +const REVENUE: u8 = 0; // I32 lane +const REGION: u8 = 1; // U32 coordinate lane +const NOT_CATALOGUED: u8 = 2; // name the catalog does not know +const NO_COLUMN: u8 = 3; // catalogued, but the batch has no column +const REFERENCE: u8 = 4; // U64 KV reference lane: not an admitted value kind + +const F_REVENUE: FieldId = FieldId(1); +const F_REGION: FieldId = FieldId(2); +const F_NO_COLUMN: FieldId = FieldId(3); +const F_REFERENCE: FieldId = FieldId(4); + +struct View(Vec); +impl ClassView for View { + fn fields(&self, class: ClassId) -> &[FieldRef] { + if class == CONCEPT { + &self.0 + } else { + &[] + } + } + fn template(&self, _class: ClassId) -> DisplayTemplate { + DisplayTemplate::Detail + } + fn dolce_category_id(&self, _class: ClassId) -> u8 { + 0 + } +} + +fn view() -> View { + View(vec![ + FieldRef::new("rpt:revenue", "Revenue"), + FieldRef::new("rpt:region", "Region"), + FieldRef::new("rpt:notCatalogued", "Not catalogued"), + FieldRef::new("rpt:noColumn", "No column"), + FieldRef::new("rpt:document", "Document"), + ]) +} + +fn catalog() -> Catalog { + Catalog::default() + .with("rpt:revenue", F_REVENUE) + .with("rpt:region", F_REGION) + .with("rpt:noColumn", F_NO_COLUMN) + .with("rpt:document", F_REFERENCE) +} + +fn revenue(generation: u32) -> Vec { + (0..N) + .map(|i| ((i * 37 + generation as usize * 11) % 1000) as i32 - 200) + .collect() +} +fn region() -> Vec { + (0..N).map(|i| (i % 5) as u32).collect() +} + +fn batch(generation: u32) -> Arc { + let refs: Arc<[u64]> = (0..N as u64).collect(); + Arc::new( + AbiBatch::new(SOURCE, generation, N) + .with_column(Column::value( + F_REVENUE, + LaneData::I32(revenue(generation).into()), + )) + .unwrap() + .with_column(Column::coordinate(F_REGION, region().into(), 5)) + .unwrap() + .with_column(Column::value(F_REFERENCE, LaneData::U64(refs))) + .unwrap(), + ) +} + +fn test_method(id: u32, reads: &[u8]) -> MethodBinding { + // A test fold, not a production F### implementation. + MethodBinding { + id: MethodId(id), + sig_rev: SigRev(1), + name: format!("test-only/fold-{id}"), + on: WideFieldMask::EMPTY, + when: None, + reads: WideFieldMask::from_positions(reads), + effects: Vec::new(), + population: Population::Distinct, + truth: Truth::TwoValued, + demand: ValueDemand::Values, + } +} + +fn store() -> (BindStore, RouteAt) { + let mut store = BindStore::new(); + store + .register( + class(), + ClassSchema { + schema_rev: SchemaRev(1), + field_count: 5, + extent: Extent::DR64_CUBE, + }, + vec![ + test_method(1, &[REVENUE, REGION]), + test_method(2, &[REGION]), + ], + ) + .unwrap(); + let version = store.route(POSITION, class()).unwrap(); + ( + store, + RouteAt { + position: POSITION, + version, + }, + ) +} + +fn request<'a>( + route: RouteAt, + fields: &'a WideFieldMask, + frame: &'a lance_graph_dt64::dr::Frame, + selection: CoordSpace, +) -> LaneRequest<'a> { + LaneRequest { + route, + rev: SchemaRev(1), + fields, + frame, + scope: READER, + selection, + } +} + +fn bind( + store: &BindStore, + view: &V, + lanes: &AbiLanes<'_, V>, + route: RouteAt, + positions: &[u8], +) -> Result { + let fields = WideFieldMask::from_positions(positions); + let frame = lanes.seal(); + store.bind_lanes( + view, + lanes.frames(), + lanes, + request(route, &fields, &frame, lanes.space()), + ) +} + +fn slice_i32(lane: LaneRef<'_>) -> &[i32] { + match lane { + LaneRef::I32(v) => v, + _ => panic!("expected an I32 lane"), + } +} +fn slice_u32(lane: LaneRef<'_>) -> &[u32] { + match lane { + LaneRef::U32(v) => v, + _ => panic!("expected a U32 lane"), + } +} + +// ── positive: borrow, admit, fold, parity, reuse, zero hot allocations ─────── + +#[test] +fn bound_lanes_are_the_batch_lanes_and_fold_with_scalar_parity() { + let (store, route) = store(); + let view = view(); + let catalog = catalog(); + let batch = batch(1); + let lanes = AbiLanes::unwitnessed(&view, &catalog, Arc::clone(&batch), READER); + let binding = bind(&store, &view, &lanes, route, &[REVENUE, REGION]).unwrap(); + + // Borrowing: the binding hands out the batch's own Arc-backed lanes. + let rev = binding + .lane(&store, &view, lanes.frames(), &lanes, REVENUE) + .unwrap(); + let reg = binding + .lane(&store, &view, lanes.frames(), &lanes, REGION) + .unwrap(); + assert_eq!( + slice_i32(rev).as_ptr() as usize, + batch.lane_addr(F_REVENUE).unwrap() + ); + assert_eq!( + slice_u32(reg).as_ptr() as usize, + batch.lane_addr(F_REGION).unwrap() + ); + + // Behaviour: DT64 admits each method; its reads lie inside the binding. + let m1 = MethodRef::of(class(), &test_method(1, &[REVENUE, REGION])); + let m2 = MethodRef::of(class(), &test_method(2, &[REGION])); + for m in [&m1, &m2] { + let admitted = store.admit(m).unwrap(); + assert!(admitted.reads.is_subset_of(binding.fields())); + } + + // Fold 1 (method 1): Σ revenue where region == 2 ∧ revenue > 100. + let sum = lower(&Query { + filter: Filter::and([ + Filter::cmp(Col(1), Cmp::EqU32(2)), + Filter::cmp(Col(0), Cmp::GtI32(100)), + ]), + agg: Agg::SumI32(Col(0)), + }) + .unwrap(); + // Fold 2 (method 2), same binding: count where region == 3. + let count = lower(&Query { + filter: Filter::cmp(Col(1), Cmp::EqU32(3)), + agg: Agg::Count, + }) + .unwrap(); + let mut s1 = Scratch::for_program(&sum, N).unwrap(); + let mut s2 = Scratch::for_program(&count, N).unwrap(); + + let warm = |s1: &mut Scratch, s2: &mut Scratch| { + let rev = binding + .lane(&store, &view, lanes.frames(), &lanes, REVENUE) + .unwrap(); + let reg = binding + .lane(&store, &view, lanes.frames(), &lanes, REGION) + .unwrap(); + let cols = [rev, reg]; + let inputs = Planes { + n_rows: N, + masks: &[], + lanes: &cols, + }; + let a = execute_into(&sum, &inputs, &Foreign::NONE, s1, Out::None).unwrap(); + let b = execute_into(&count, &inputs, &Foreign::NONE, s2, Out::None).unwrap(); + (a, b) + }; + let (got_sum, got_count) = warm(&mut s1, &mut s2); + + // Independent scalar oracle over freshly generated values, not the lanes. + let (want_rev, want_reg) = (revenue(1), region()); + let want_sum: i64 = (0..N) + .filter(|&i| want_reg[i] == 2 && want_rev[i] > 100) + .map(|i| i64::from(want_rev[i])) + .sum(); + let want_count = (0..N).filter(|&i| want_reg[i] == 3).count(); + assert_eq!(got_sum, Value::SumI64(want_sum)); + assert_eq!(got_count, Value::Count(want_count)); + let kept = (0..N) + .filter(|&i| want_reg[i] == 2 && want_rev[i] > 100) + .count(); + assert!(kept > 0 && kept < N / 5, "non-trivial filter: {kept}"); + + // Reuse: the same binding checks and folds again, with no allocation. + let before = allocations(); + for _ in 0..3 { + binding + .check(&store, &view, lanes.frames(), &lanes) + .unwrap(); + let (a, b) = warm(&mut s1, &mut s2); + assert_eq!((a, b), (Value::SumI64(want_sum), Value::Count(want_count))); + } + assert_eq!(allocations() - before, 0, "hot path allocated"); +} + +// ── negative ───────────────────────────────────────────────────────────────── + +/// A published replacement batch moves the frame: the old binding refuses, +/// a fresh bind succeeds on the new lanes. +#[test] +fn a_published_replacement_refuses_the_old_binding() { + let (store, route) = store(); + let view = view(); + let catalog = catalog(); + let mut lanes = AbiLanes::unwitnessed(&view, &catalog, batch(1), READER); + let old = bind(&store, &view, &lanes, route, &[REVENUE]).unwrap(); + lanes.publish(batch(2)).unwrap(); + assert_eq!( + old.check(&store, &view, lanes.frames(), &lanes), + Err(LaneRefusal::Frame(FrameRefusal::CoordinatesChanged)) + ); + assert!(old + .lane(&store, &view, lanes.frames(), &lanes, REVENUE) + .is_err()); + let fresh = bind(&store, &view, &lanes, route, &[REVENUE]).unwrap(); + let lane = fresh + .lane(&store, &view, lanes.frames(), &lanes, REVENUE) + .unwrap(); + assert_eq!(slice_i32(lane), revenue(2).as_slice()); + + // A replacement must be a later generation of the same source. + assert_eq!( + lanes.publish(batch(2)).err(), + Some(PublishRefusal::StaleGeneration { + current: 2, + offered: 2 + }) + ); + let foreign = Arc::new(AbiBatch::new(SourceId(8), 9, N)); + assert_eq!( + lanes.publish(foreign).err(), + Some(PublishRefusal::ForeignSource { + bound: SOURCE, + offered: SourceId(8) + }) + ); +} + +/// A frame sealed by another source's store is refused, even with equal +/// revisions. +#[test] +fn a_frame_from_another_store_is_refused() { + let (store, route) = store(); + let view = view(); + let catalog = catalog(); + let lanes = AbiLanes::unwitnessed(&view, &catalog, batch(1), READER); + let twin = AbiLanes::unwitnessed(&view, &catalog, batch(1), READER); + let fields = WideFieldMask::from_positions(&[REVENUE]); + let foreign = twin.seal(); + assert_eq!( + store + .bind_lanes( + &view, + lanes.frames(), + &lanes, + request(route, &fields, &foreign, lanes.space()) + ) + .err(), + Some(LaneRefusal::Frame(FrameRefusal::ForeignFrame)) + ); +} + +/// The position re-routes to another class: the binding refuses. +#[test] +fn a_wrong_route_is_refused() { + let (mut store, route) = store(); + let view = view(); + let catalog = catalog(); + let lanes = AbiLanes::unwitnessed(&view, &catalog, batch(1), READER); + let binding = bind(&store, &view, &lanes, route, &[REVENUE]).unwrap(); + store + .register_derived( + class(), + other_class(), + ClassSchema { + schema_rev: SchemaRev(1), + field_count: 5, + extent: Extent::DR64_CUBE, + }, + Vec::new(), + ) + .unwrap(); + store.route(POSITION, other_class()).unwrap(); + assert_eq!( + binding.check(&store, &view, lanes.frames(), &lanes), + Err(LaneRefusal::RouteChanged { + position: POSITION, + bound: class(), + now: Some(other_class()), + }) + ); +} + +/// A selection in another row order (another source, or another generation +/// of this one) is refused: reading through it would be a gather. +#[test] +fn an_incompatible_coordinate_space_is_refused() { + let (store, route) = store(); + let view = view(); + let catalog = catalog(); + let lanes = AbiLanes::unwitnessed(&view, &catalog, batch(1), READER); + let fields = WideFieldMask::from_positions(&[REVENUE]); + let frame = lanes.seal(); + for selection in [ + CoordSpace { + population: 8, + coordinates: 1, + }, + CoordSpace { + population: 7, + coordinates: 2, + }, + ] { + assert_eq!( + store + .bind_lanes( + &view, + lanes.frames(), + &lanes, + request(route, &fields, &frame, selection) + ) + .err(), + Some(LaneRefusal::CoordinateSpace { + field: REVENUE, + selection, + lane: lanes.space(), + }) + ); + } +} + +/// Unknown name, catalogued-but-absent column, and a U64 reference lane all +/// leave the field unbound; a position outside the basis is refused by DT64. +#[test] +fn unknown_or_unadmitted_fields_are_refused() { + let (store, route) = store(); + let view = view(); + let catalog = catalog(); + let lanes = AbiLanes::unwitnessed(&view, &catalog, batch(1), READER); + for field in [NOT_CATALOGUED, NO_COLUMN, REFERENCE] { + assert_eq!( + bind(&store, &view, &lanes, route, &[REVENUE, field]).err(), + Some(LaneRefusal::Unbound { + class: class(), + field + }), + "field {field}" + ); + } + assert_eq!( + bind(&store, &view, &lanes, route, &[5]).err(), + Some(LaneRefusal::FieldNotInBasis { + class: class(), + field: 5 + }) + ); +} + +/// A reader in another scope is refused. +#[test] +fn a_foreign_scope_is_refused() { + let (store, route) = store(); + let view = view(); + let catalog = catalog(); + let lanes = AbiLanes::unwitnessed(&view, &catalog, batch(1), READER); + let fields = WideFieldMask::from_positions(&[REVENUE]); + let frame = lanes.seal(); + let mut req = request(route, &fields, &frame, lanes.space()); + req.scope = Scope { id: 42 }; + assert_eq!( + store.bind_lanes(&view, lanes.frames(), &lanes, req).err(), + Some(LaneRefusal::Scope { + field: REVENUE, + reader: Scope { id: 42 }, + lane: READER, + }) + ); +} From 3c5a3e5a1695ae0c9282b68d22980eef11839e4f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 11:13:38 +0000 Subject: [PATCH 2/4] report: falsifiers for the old row order and a foreign frame store Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMMJzKZrYnckXRC8x2U --- .../lance-graph-report/tests/lane_socket.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/lance-graph-report/tests/lane_socket.rs b/crates/lance-graph-report/tests/lane_socket.rs index fd72ccaf3..a999a34aa 100644 --- a/crates/lance-graph-report/tests/lane_socket.rs +++ b/crates/lance-graph-report/tests/lane_socket.rs @@ -501,3 +501,60 @@ fn a_foreign_scope_is_refused() { }) ); } + +/// After a publish, a selection still in the old generation's row order is +/// refused against a fresh bind. +#[test] +fn the_old_row_order_is_refused_after_a_publish() { + let (store, route) = store(); + let view = view(); + let catalog = catalog(); + let mut lanes = AbiLanes::unwitnessed(&view, &catalog, batch(1), READER); + let old_space = lanes.space(); + lanes.publish(batch(2)).unwrap(); + let fields = WideFieldMask::from_positions(&[REVENUE]); + let frame = lanes.seal(); + assert_eq!( + store + .bind_lanes( + &view, + lanes.frames(), + &lanes, + request(route, &fields, &frame, old_space) + ) + .err(), + Some(LaneRefusal::CoordinateSpace { + field: REVENUE, + selection: old_space, + lane: lanes.space(), + }) + ); +} + +/// The adapter answers only for frames its own store sealed: another +/// source's store and frame, presented together, pass DT64's frame check +/// but leave every field unbound here. +#[test] +fn a_frame_and_store_of_another_source_bind_nothing() { + let (store, route) = store(); + let view = view(); + let catalog = catalog(); + let lanes = AbiLanes::unwitnessed(&view, &catalog, batch(1), READER); + let other = AbiLanes::unwitnessed(&view, &catalog, batch(1), READER); + let fields = WideFieldMask::from_positions(&[REVENUE]); + let frame = other.seal(); + assert_eq!( + store + .bind_lanes( + &view, + other.frames(), + &lanes, + request(route, &fields, &frame, lanes.space()) + ) + .err(), + Some(LaneRefusal::Unbound { + class: class(), + field: REVENUE + }) + ); +} From 26dfc6d050f9135f5a643f581702bf0758906aae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 11:16:29 +0000 Subject: [PATCH 3/4] ontology: pin RegistryClassView concept-half key and its DOLCE defect; CI + board for D-LANESOCK-2 Keyed by classid_concept (DT64 view_class) the basis resolves; the registry category is keyed by append-order entity_type_id and stays unreachable through the concept key (pinned, not fixed). rust-test.yml now runs lance-graph-report, plain and the unwitnessed-scope socket suite. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMMJzKZrYnckXRC8x2U --- .claude/board/LATEST_STATE.md | 6 ++ .claude/board/STATUS_BOARD.md | 1 + .../2026-11-06-abibatch-lane-socket.md | 27 +++++++++ .claude/board/entries/README.md | 3 +- .github/workflows/rust-test.yml | 8 +++ .../src/class_resolver.rs | 60 +++++++++++++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 .claude/board/entries/2026-11-06-abibatch-lane-socket.md diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index fd6c33ec7..dd2d7a2c2 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,9 @@ +## 2026-11-06 — branch `ccr-fd2682ed-sai06j-abi-lanes` (D-LANESOCK-2, in PR) + +- `lance-graph-report::AbiLanes` (`lane_source` module): `LaneSource` over a resident `AbiBatch`; `PublishRefusal`. Field positions via ClassView basis → `Catalog` → `FieldId`; I32/U32 lanes only; one `FrameStore` per source over `(SourceId, generation)`. No production constructor (no DR reader-scope witness); test-only feature `unwitnessed-scope`. +- `rust-test.yml` now runs the `lance-graph-report` tests (plain and the lane-socket suite). +- Open: value ingress, durable writer, codebook, exact decimal, D4/S-INGRESS; `RegistryClassView` `entity_type_id` vs concept-key DOLCE defect. + ## 2026-11-06 — main after #1964–#1967 (merged `9ca5daa`), OGAR #371 - OGAR `ogar_dir_core::correspond::Output::entra_mailbox`: the fold's sole Entra→EXO row ordinal (`NO_ROW` for none or several). `Ruler::from_fold` reads it instead of re-merging. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 95d017a44..08fae2e7c 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -4,6 +4,7 @@ | --- | --- | --- | | D-LANESOCK-0 | normative spec: socket, joint version witness, refusals | Shipped (#1966) | | D-LANESOCK-1 | `LaneSource` + `BindStore::bind_lanes` + falsifiers in lance-graph-dt64 and the dir-sim lab | Shipped (#1967) | +| D-LANESOCK-2 | `AbiLanes`: resident `AbiBatch` I32/U32 lanes through `LaneSource` (read adapter; no production constructor until a DR reader-scope witness exists) | In PR | ## D-CGMM — Lazy Codegraph fact/demand + MeTTa class-method scratch (2026-11-04) diff --git a/.claude/board/entries/2026-11-06-abibatch-lane-socket.md b/.claude/board/entries/2026-11-06-abibatch-lane-socket.md new file mode 100644 index 000000000..831a0a832 --- /dev/null +++ b/.claude/board/entries/2026-11-06-abibatch-lane-socket.md @@ -0,0 +1,27 @@ +# AbiBatch lanes through the DT64 lane socket (D-LANESOCK-2) + +**Date:** 2026-11-06. **Status:** read adapter, not production-admitted (no authoritative reader-scope witness). Follows D-LANESOCK-1 (#1967). + +## What it is + +`lance_graph_report::AbiLanes` implements `lance_graph_dt64::LaneSource` over a resident `AbiBatch`: +- field position → `ClassView::fields(view_class(class))[pos].predicate_iri` → `Catalog` → `FieldId` → `AbiBatch::column` lane index; +- `borrow` hands out the batch's own `Arc` lane as `LaneRef` (pointer equality tested); +- one `FrameStore` per source, revisions `(SourceId, 0, generation)`; `publish` takes a later generation of the same source and advances it; +- `I32` / `U32` only; a `U64` reference lane is unbound. + +DT64 binds the class and admits the method; Quack / mask-RISC folds. No store, field registry, codebook or BindStore was added. + +## Admission scope + +- Reader scope: DR's `Scope` is a plain `{ id }` with no issuer. `AbiLanes` has no production constructor; `AbiLanes::unwitnessed` exists only under the test-only feature `unwitnessed-scope`. +- No writer: a read adapter over resident lanes. +- Open, unchanged: value ingress, durable writer, codebook, exact decimal, D4/S-INGRESS. + +## Falsifiers (`crates/lance-graph-report/tests/lane_socket.rs`) + +Borrowing (pointer equality), scalar parity on two folds with one binding, reuse with zero hot allocations, published replacement refuses the old binding, old row order refused after a publish, foreign frame, another source's frame + store, wrong route, incompatible coordinate space, unknown / absent / `U64` field, field outside the basis, foreign scope. Disable runs (committed first): admitting `U64`, copying the lane, skipping the frame advance, dropping the generation from the coordinate space, an allocation in `lane_of`, and dropping the adapter's own frame check each turn a test red. + +## RegistryClassView + +`class_resolver::tests::concept_half_keys_the_basis_but_not_the_registry_category`: keyed by `classid_concept` (= DT64 `view_class`), the basis resolves. Defect pinned, not fixed: `dolce_category_id` / `is_known` read the same key as the registry's append-order `entity_type_id`, so a registered class's DOLCE category is unreachable through the concept key. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index ff362fe39..435638a58 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,11 +25,12 @@ 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. -580 entries, 2026-08-06 .. 2026-11-06. +581 entries, 2026-08-06 .. 2026-11-06. | date | entry id | finding | file | |---|---|---|---| | 2026-11-06 | `ruler-plane-batch-selection-lab` | | [2026-11-06-ruler-plane-batch-selection-lab.md](2026-11-06-ruler-plane-batch-selection-lab.md) | +| 2026-11-06 | `D-LANESOCK-2` | | [2026-11-06-abibatch-lane-socket.md](2026-11-06-abibatch-lane-socket.md) | | 2026-11-05 | `hybrid-ordinal-chain-lab-v2` | | [2026-11-05-hybrid-ordinal-chain-lab-v2.md](2026-11-05-hybrid-ordinal-chain-lab-v2.md) | | 2026-11-05 | `coresearch-workbook-hypercube-soa-projection` | | [2026-11-05-coresearch-workbook-hypercube-soa-projection.md](2026-11-05-coresearch-workbook-hypercube-soa-projection.md) | | 2026-11-04 | `D-DRIVER-ZC-0` | | [2026-11-04-d-driver-zc-0-virtual-bus-contract.md](2026-11-04-d-driver-zc-0-virtual-bus-contract.md) | diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index 8c0dfe880..f2f3d1369 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -150,6 +150,14 @@ jobs: run: cargo test --manifest-path crates/lance-graph-dt64/Cargo.toml - name: Run xlog tests run: cargo test --manifest-path crates/lance-graph-xlog/Cargo.toml + # lance-graph-report: no workflow ran its tests. The second line runs the + # D-LANESOCK-2 socket suite, which needs the test-only `unwitnessed-scope` + # feature (the reader scope there is the test's assertion, not a DR + # witness) and is skipped by the plain run. + - name: Run report tests + run: cargo test --manifest-path crates/lance-graph-report/Cargo.toml + - name: Run report lane-socket tests (test-only scope) + run: cargo test --manifest-path crates/lance-graph-report/Cargo.toml --features unwitnessed-scope --test lane_socket # lance-graph-codegraph is workspace-EXCLUDED (own [workspace], as # sag-ra-probe), so no line above reaches it. Its suites pin the atomic # refresh (a refused refresh changes nothing; a retry equals the refresh diff --git a/crates/lance-graph-ontology/src/class_resolver.rs b/crates/lance-graph-ontology/src/class_resolver.rs index a820c74ae..6e8d8e40f 100644 --- a/crates/lance-graph-ontology/src/class_resolver.rs +++ b/crates/lance-graph-ontology/src/class_resolver.rs @@ -205,6 +205,66 @@ mod tests { assert_eq!(cv.dolce_category_id(7), dolce_id::ENDURANT); } + /// DT64 reads a basis with `view.fields(view_class(classid))`, and + /// `view_class` is `classid_concept`: the canon half under the codebook's + /// active split. Keyed by that half, the field basis resolves. + /// + /// DEFECT, pinned as it stands (not fixed here): `dolce_category_id` and + /// `is_known` look the same key up as the registry's `entity_type_id`, + /// which is minted in append order and is not the OGAR concept. The + /// category of a registered class is therefore unreachable through the + /// concept key. When the registry learns a concept → entity_type link, + /// this test fails and must be re-pinned. + #[test] + fn concept_half_keys_the_basis_but_not_the_registry_category() { + use crate::namespace::OgitUri; + use crate::{MappingProposal, MappingProposalKind}; + use lance_graph_contract::ogar_codebook::{classid_concept, compose_classid}; + use lance_graph_contract::property::{Marking, Schema}; + + let uri = "ogit.Odoo:account.move"; + assert_eq!(classify_odoo(uri), DolceCategory::Perdurant, "precondition"); + let parsed = OgitUri::parse(uri).unwrap(); + let reg = OntologyRegistry::new_in_memory(); + let handle = reg + .append_mapping(MappingProposal { + public_name: "AccountMove".into(), + bridge_id: "odoo".into(), + namespace: parsed.namespace().unwrap().to_string(), + ogit_uri: parsed, + kind: MappingProposalKind::Entity { + schema: Schema::builder("account.move").build(), + }, + marking: Marking::Internal, + confidence: 1.0, + source_uri: "test://account.move".into(), + checksum: "ck-account.move".into(), + created_by: "test".into(), + }) + .unwrap(); + let entity_type = handle.schema_ptr.entity_type_id(); + + let concept: ClassId = 0x0C1C; + let classid = compose_classid(concept, 1); + assert_eq!(classid_concept(classid), concept); + assert_ne!(classid as u16, concept, "the low half is not the key"); + + let mut views = HashMap::new(); + views.insert(classid_concept(classid), invoice_view()); + let cv = RegistryClassView::new(®, views); + + // Compatible: the basis DT64 reads. + assert_eq!(cv.fields(classid_concept(classid)).len(), 3); + assert!(cv.fields(classid as u16).is_empty()); + + // The defect: the concept key misses the registered row. + assert_ne!(entity_type, concept); + assert!(!cv.is_known(concept)); + assert_eq!(cv.dolce_category_id(concept), dolce_id::ENDURANT); + assert!(cv.is_known(entity_type)); + assert_eq!(cv.dolce_category_id(entity_type), dolce_id::PERDURANT); + } + #[test] fn dolce_id_mapping_is_total_and_stable() { assert_eq!(dolce_to_id(DolceCategory::Endurant), dolce_id::ENDURANT); From e3fd38b85ddf851cce9bc96dc499b7022a8aef53 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 11:27:56 +0000 Subject: [PATCH 4/4] report: cap a batch at the u16 lane space; checked lane index AbiBatch::column truncated its lane index with `as u16`, so a column at 65536 or later read as column 0 through every caller (exec, selection, render, and the new lane socket). with_column now refuses past MAX_COLUMNS (TooManyColumns) and column() converts with a checked u16. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMMJzKZrYnckXRC8x2U --- crates/lance-graph-report/src/batch.rs | 11 +++++++- crates/lance-graph-report/src/lib.rs | 2 +- .../lance-graph-report/tests/lane_socket.rs | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/lance-graph-report/src/batch.rs b/crates/lance-graph-report/src/batch.rs index f083d3218..13d72c28a 100644 --- a/crates/lance-graph-report/src/batch.rs +++ b/crates/lance-graph-report/src/batch.rs @@ -110,8 +110,14 @@ pub enum BatchError { DuplicateField(FieldId), /// Two masks share an id, or a mask claimed [`MaskId::ALPHA`]. DuplicateMask(MaskId), + /// The batch already holds [`MAX_COLUMNS`] columns: another one would + /// have no `u16` lane index. + TooManyColumns(FieldId), } +/// The most columns a batch holds: every lane index fits a `u16`. +pub const MAX_COLUMNS: usize = u16::MAX as usize + 1; + /// The resident population a report is evaluated over. /// /// Plane 0 is always the validity plane ([`MaskId::ALPHA`]): the table IS its @@ -156,6 +162,9 @@ impl AbiBatch { if column.domain.is_some() && !matches!(column.lane, LaneData::U32(_)) { return Err(BatchError::DomainOnNonOrdinalLane(column.field)); } + if self.columns.len() >= MAX_COLUMNS { + return Err(BatchError::TooManyColumns(column.field)); + } if self.columns.iter().any(|c| c.field == column.field) { return Err(BatchError::DuplicateField(column.field)); } @@ -208,7 +217,7 @@ impl AbiBatch { .iter() .enumerate() .find(|(_, c)| c.field == field) - .map(|(i, c)| (i as u16, c)) + .and_then(|(i, c)| u16::try_from(i).ok().map(|i| (i, c))) } /// Plane index of a resident mask, or `None` when no resident mask has diff --git a/crates/lance-graph-report/src/lib.rs b/crates/lance-graph-report/src/lib.rs index dc9d80bbc..6b3ebb0ee 100644 --- a/crates/lance-graph-report/src/lib.rs +++ b/crates/lance-graph-report/src/lib.rs @@ -58,7 +58,7 @@ pub mod render; pub mod result; pub mod selection; -pub use batch::{AbiBatch, BatchError, Column, LaneData}; +pub use batch::{AbiBatch, BatchError, Column, LaneData, MAX_COLUMNS}; pub use exec::{Accumulator, ExecStats, PhysicalPlan, PlannerPolicy, Provider, SelectionCarrier}; pub use ids::{FieldId, MaskId, SourceId}; pub use lane_source::{AbiLanes, PublishRefusal}; diff --git a/crates/lance-graph-report/tests/lane_socket.rs b/crates/lance-graph-report/tests/lane_socket.rs index a999a34aa..07be0689a 100644 --- a/crates/lance-graph-report/tests/lane_socket.rs +++ b/crates/lance-graph-report/tests/lane_socket.rs @@ -558,3 +558,30 @@ fn a_frame_and_store_of_another_source_bind_nothing() { }) ); } + +/// A batch holds at most `MAX_COLUMNS` columns, so every lane index the +/// adapter publishes is the column it names (no `u16` wrap onto column 0). +#[test] +fn a_column_past_the_u16_lane_space_is_refused() { + use lance_graph_report::{BatchError, MAX_COLUMNS}; + let mut b = AbiBatch::new(SOURCE, 1, 1); + for i in 0..MAX_COLUMNS as u32 { + b = b + .with_column(Column::value( + FieldId(i), + LaneData::I32(vec![i as i32].into()), + )) + .unwrap(); + } + let last = FieldId(MAX_COLUMNS as u32 - 1); + let (lane, _) = b.column(last).unwrap(); + assert_eq!(usize::from(lane), MAX_COLUMNS - 1); + assert_eq!( + b.with_column(Column::value( + FieldId(MAX_COLUMNS as u32), + LaneData::I32(vec![0].into()) + )) + .err(), + Some(BatchError::TooManyColumns(FieldId(MAX_COLUMNS as u32))) + ); +}