diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index dd2d7a2c2..450c78bfc 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,7 +1,14 @@ -## 2026-11-06 — branch `ccr-fd2682ed-sai06j-abi-lanes` (D-LANESOCK-2, in PR) +## 2026-11-06 — branch `ccr-fd2682ed-sai06j-lanesock-harden` (D-LANESOCK-3, 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). +- `LaneSource::lane_of` takes the `FieldRef` DT64 validated (`basis`); `AbiLanes` holds no ClassView. A check through a reordered ClassView of equal class, revision and count refuses `MappingChanged`. +- `AbiLanes::unwitnessed` is `#[cfg(test)]`; the `unwitnessed-scope` feature is gone, so no build (any features) constructs an `AbiLanes`. +- Spec §8: downstream consumer contract (Cockpit / Hub / Matrix over one scoped projection) and the reader-scope witness inventory. + +## 2026-11-06 — main after #1969 (merged `53e849a`) + +- `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). +- `AbiBatch` holds at most `MAX_COLUMNS` (65,536) columns; `column()` returns a checked `u16` lane index. +- `rust-test.yml` now runs the `lance-graph-report` tests. - 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 diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 9afc60770..329b86832 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -1,3 +1,10 @@ +## 2026-11-06 — lance-graph PR #1969 (merged `53e849a`) — D-LANESOCK-2: AbiBatch lanes through the DT64 lane socket + +- **Added:** `lance-graph-report::AbiLanes` (`LaneSource` over a resident `AbiBatch`, `publish`, `PublishRefusal`); `AbiBatch` column cap `MAX_COLUMNS` with a checked `u16` lane index (Codex P2: `i as u16` wrapped column 65,536 onto column 0 for every caller); `RegistryClassView` concept-half key test; `rust-test.yml` runs the report tests. +- **Locked (measured):** pointer-equal borrowing; two Quack folds over one binding match an independent scalar oracle; zero hot allocations on reuse; refusals for a published replacement, old row order, foreign frame, foreign source store, re-route, other coordinate space, unknown / absent / `U64` field, out-of-basis position and foreign scope; seven guards disable-verified red-then-green. +- **Not done:** no production constructor (no DR reader-scope witness); no writer or value ingress; exact decimal, codebook and D4/S-INGRESS open; `RegistryClassView` DOLCE `entity_type_id` defect pinned, not fixed. Found afterwards (D-LANESOCK-3): `AbiLanes` read a ClassView of its own and could disagree with DT64's basis; the `unwitnessed-scope` feature exposed the constructor to any build that enabled it. +- **Confidence:** CI green on the merged head. + ## 2026-11-06 — lance-graph PRs #1964–#1967 (merged `9ca5daa`), with OGAR #371 — hybrid ordinal chain, Ruler-plane selection, DR × DT64 lane socket - **Added:** diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 08fae2e7c..e19cc647f 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -4,7 +4,8 @@ | --- | --- | --- | | 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-LANESOCK-2 | `AbiLanes`: resident `AbiBatch` I32/U32 lanes through `LaneSource` (read adapter; no production constructor until a DR reader-scope witness exists) | Shipped (#1969) | +| D-LANESOCK-3 | one field basis (`lane_of` receives DT64's validated `FieldRef`); `AbiLanes` constructor `#[cfg(test)]`-only; downstream consumer contract + reader-scope inventory (spec §8) | In PR | ## D-CGMM — Lazy Codegraph fact/demand + MeTTa class-method scratch (2026-11-04) diff --git a/.claude/board/entries/2026-11-06-lanesock-one-field-basis.md b/.claude/board/entries/2026-11-06-lanesock-one-field-basis.md new file mode 100644 index 000000000..fa5a77103 --- /dev/null +++ b/.claude/board/entries/2026-11-06-lanesock-one-field-basis.md @@ -0,0 +1,20 @@ +# One field basis for the lane socket; test-only scope admission (D-LANESOCK-3) + +**Date:** 2026-11-06. **Status:** in PR. Follows D-LANESOCK-2 (#1969, merged `53e849a`). + +## Defect (RED, recorded at `49d11c2`) + +DT64 validated ClassView A; `AbiLanes` resolved its own ClassView B with the same class, revision and field count but the first two fields swapped. The bind succeeded and A's `revenue` field borrowed B's `region` lane: a wrong, nonzero value with every check passing. DT64's basis check compares the field count, so it cannot see a reorder. + +## Fix + +- `LaneSource::lane_of(.., field, basis: &FieldRef, frame)`: DT64 passes the basis entry it resolved, at bind and on every check. A source maps that field, never a position. +- `AbiLanes` drops its ClassView. A check through a reordered view refuses `MappingChanged`; a bind under that view borrows the lane it names. +- The three implementers were updated (dt64 test source, dir-sim lab, `AbiLanes`). +- `AbiLanes::unwitnessed` and its suite moved into a `#[cfg(test)]` unit module; the `unwitnessed-scope` feature was removed. No build, `--all-features` included, constructs an `AbiLanes`. + +Disable run: accepting a changed mapping in `LaneBinding::check` turns the basis test red. + +## Handoff + +Spec §8: Cockpit / Hub / Matrix consume one scoped projection through DT64 → borrowed lanes → Quack; reader-scope witness inventory (OGAR `RbacAuthority` → `RbacBinding` → `authorize_scoped` / `authorize_memberships`); the missing seam is a witness sealing a decision into `dr::Scope`. Not wired. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 435638a58..2105f5edb 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. -581 entries, 2026-08-06 .. 2026-11-06. +582 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-3` | | [2026-11-06-lanesock-one-field-basis.md](2026-11-06-lanesock-one-field-basis.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) | diff --git a/.claude/plans/d-lanesock-dr-dt64-lane-socket-v1.md b/.claude/plans/d-lanesock-dr-dt64-lane-socket-v1.md index 8a9f0e761..7764518e4 100644 --- a/.claude/plans/d-lanesock-dr-dt64-lane-socket-v1.md +++ b/.claude/plans/d-lanesock-dr-dt64-lane-socket-v1.md @@ -35,12 +35,13 @@ pub struct LaneAddr { pub space: CoordSpace, pub lane: u16, pub scope: Scope } pub trait LaneSource { type Lane<'a> where Self: 'a; // the borrowed lane (e.g. mask-RISC LaneRef) - fn lane_of(&self, class: u32, rev: SchemaRev, field: u8, frame: &Frame) -> Option; + fn lane_of(&self, class: u32, rev: SchemaRev, field: u8, basis: &FieldRef, frame: &Frame) + -> Option; fn borrow(&self, addr: &LaneAddr) -> Option>; } ``` -`None` from `lane_of` is UNBOUND and is refused, never read as empty. `borrow` hands out the source's own lane; it never copies, reorders or allocates a lane. +`basis` is the field DT64 resolved at `field` through the `ClassView` it validated (amended by D-LANESOCK-3: a source that read a ClassView of its own could disagree with DT64 on field order or identity at equal class, revision and count, and returned another lane). A source maps `basis`, never a position. `None` from `lane_of` is UNBOUND and is refused, never read as empty. `borrow` hands out the source's own lane; it never copies, reorders or allocates a lane. ## 3. The joint witness (DT64) @@ -93,3 +94,42 @@ Gather ban, ADMISSION B, NotASide, OP-14, DRISA M1/M2, D4/S-INGRESS. The socket 3. A DT64-admitted method drives one Quack fold over a borrowed lane; the same binding drives a second operation on the other lane. 4. Independent scalar oracle parity; zero hot allocations. 5. Each refusal in §4 fires on a realistic input, and a disabled guard turns its test red. + +## 8. Downstream consumer contract (handoff, not wiring) + +The report-rs Cockpit (`/view/mobile`), Hub (`/view`) and Matrix (`/`) are three lenses over one scoped projection (report-rs #43). When they move onto source-backed lanes, they consume one answer through this chain and add nothing upstream: + +```text +authoritative role/customer scope + → admitted source population and frame (source owner, DR FrameStore) + → DT64 property binding (BindStore::bind_lanes, one field basis) + → borrowed source-owned lanes (LaneSource::borrow, e.g. AbiLanes) + → Quack (admitted MethodRef, one evaluator) + → authorized projection (field mask ∩ row scope) + → Cockpit / Hub / Matrix (presentation only) +``` + +No lens gets its own lane binding, KPI evaluator, SoA or storage in lance-graph. A KPI is a Quack fold over the same binding; the three surfaces differ only in what they render. + +### Reader-scope witness: inventory (2026-11-06) + +`dr::Scope` is a bare `{ id: u64 }`; nothing issues it with authority, so no production `LaneSource` may be constructed yet (`AbiLanes` has only a `#[cfg(test)]` constructor). Existing authority pieces, read in code: + +| Piece | Where | What it yields | +|---|---|---| +| `RbacAuthority::bind(&RbacPlug) -> RbacBinding` | `lance-graph-contract/src/rbac_plug.rs` | grants for exactly the plugged classids and roles; private fields, no `Default` (fails closed) | +| `authorize_scoped` → `ScopedDecision { decision, scope: Option, field_mask }` | `lance-graph-rbac/src/authorize.rs` | stage-1 verdict, restrictive-AND row scope, union field mask | +| `authorize_memberships` → `MembershipDecision { decision, scope: Option, field_mask }` | `lance-graph-rbac/src/authorize.rs` | the same over per-membership scopes | +| `ogar_rbac::authorize_identity(&RbacBinding, &AuthenticatedUser, ..)` | OGAR `crates/ogar-rbac/src/lib.rs` | the membership decision for an `ogar-auth` user | +| `ActorContext { actor_id, tenant_id, roles }` | `lance-graph-contract/src/auth.rs` | JWT-derived actor; not a decision | + +The missing seam is a witness that seals an authorized decision (decision + row scope + field mask, for one actor, one class, one frame) into what `LaneRequest.scope` / `LaneAddr.scope` compare. Rules it must keep: + +- A numeric `Scope` alone authorizes nothing; equality of two ids is not a grant. +- `scope: None` ("unrestricted") from a decision is never widened automatically into customer-wide access; it must be an explicit grant. +- report-rs's ZITADEL login and its `RoleScopeAuthority` are a downstream integration point that would supply the actor to this chain, not a second upstream identity authority. +- Source status (available, stale, unknown) is reported only from observed evidence. + +### Still closed + +Value ingress and the durable workbook writer; D4/S-INGRESS and layout admission; text/codebook authority; exact decimal; production CRM_COMPANY XVERWEIS; cross-coordinate Gather, ADMISSION B, NotASide, OP-14; the `RegistryClassView` DOLCE `entity_type_id` defect (tracked separately). diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index f2f3d1369..98108c98d 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -150,14 +150,11 @@ 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. + # lance-graph-report: no workflow ran its tests before D-LANESOCK-2. The + # lane-socket suite is a `#[cfg(test)]` unit module (its constructor + # takes an unwitnessed reader scope), so the plain run covers it. - 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-dir-sim/tests/ordinal_chain_lab.rs b/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs index b5672bcbb..1985da677 100644 --- a/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs +++ b/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs @@ -1083,6 +1083,7 @@ mod lanesock { class: u32, rev: SchemaRev, field: u8, + _basis: &FieldRef, _frame: &Frame, ) -> Option { if class != HYBRID_USER || rev != SchemaRev(1) { diff --git a/crates/lance-graph-dt64/src/lane.rs b/crates/lance-graph-dt64/src/lane.rs index dcfd26b6f..3e254e775 100644 --- a/crates/lance-graph-dt64/src/lane.rs +++ b/crates/lance-graph-dt64/src/lane.rs @@ -17,6 +17,7 @@ //! is admitted here, so reading one through the other would be a gather. use lance_graph_contract::class_view::{ClassView, WideFieldMask}; +use lance_graph_contract::ontology::FieldRef; use crate::dr::{Frame, FrameRefusal, FrameStore, RouteVersion, Scope}; use crate::{BindStore, Refusal, SchemaRev}; @@ -42,13 +43,25 @@ pub struct LaneAddr { /// the field is unbound, which is refused, never read as empty. `borrow` /// hands out the source's own lane; it must not copy, reorder or allocate a /// lane. +/// +/// `basis` is the field DT64 resolved at `field` through the [`ClassView`] it +/// validated. A source maps that field, never a position it read from a +/// ClassView of its own: two views can agree on class, revision and field +/// count and still order or name their fields differently. pub trait LaneSource { /// The borrowed lane (for example a mask-RISC `LaneRef`). type Lane<'a> where Self: 'a; - fn lane_of(&self, class: u32, rev: SchemaRev, field: u8, frame: &Frame) -> Option; + fn lane_of( + &self, + class: u32, + rev: SchemaRev, + field: u8, + basis: &FieldRef, + frame: &Frame, + ) -> Option; fn borrow(&self, addr: &LaneAddr) -> Option>; } @@ -150,17 +163,16 @@ impl BindStore { })?; current_route(self, req.route.position, class)?; let resolved = self.resolve(view, class, req.rev)?; - let basis = resolved.fields.len(); let mut lanes = Vec::with_capacity(req.fields.count() as usize); for field in 0..=u8::MAX { if !req.fields.has(field) { continue; } - if usize::from(field) >= basis { + let Some(field_ref) = resolved.fields.get(usize::from(field)) else { return Err(LaneRefusal::FieldNotInBasis { class, field }); - } + }; let addr = source - .lane_of(class, req.rev, field, req.frame) + .lane_of(class, req.rev, field, field_ref, req.frame) .ok_or(LaneRefusal::Unbound { class, field })?; admit_lane(field, &addr, req.scope, req.selection)?; lanes.push((field, addr)); @@ -259,10 +271,18 @@ impl LaneBinding { ) -> Result<(), LaneRefusal> { frames.check(&self.frame).map_err(LaneRefusal::Frame)?; current_route(store, self.route.position, self.class)?; - store.resolve(view, self.class, self.rev)?; + let resolved = store.resolve(view, self.class, self.rev)?; for (field, addr) in &self.lanes { let field = *field; - match source.lane_of(self.class, self.rev, field, &self.frame) { + let field_ref = + resolved + .fields + .get(usize::from(field)) + .ok_or(LaneRefusal::FieldNotInBasis { + class: self.class, + field, + })?; + match source.lane_of(self.class, self.rev, field, field_ref, &self.frame) { Some(current) if current == *addr => {} Some(_) => return Err(LaneRefusal::MappingChanged { field }), None => { diff --git a/crates/lance-graph-dt64/tests/lane_socket.rs b/crates/lance-graph-dt64/tests/lane_socket.rs index 83a58848e..b87402a97 100644 --- a/crates/lance-graph-dt64/tests/lane_socket.rs +++ b/crates/lance-graph-dt64/tests/lane_socket.rs @@ -75,7 +75,14 @@ impl Source { impl LaneSource for Source { type Lane<'a> = &'a [i32]; - fn lane_of(&self, class: u32, rev: SchemaRev, field: u8, _frame: &Frame) -> Option { + fn lane_of( + &self, + class: u32, + rev: SchemaRev, + field: u8, + _basis: &FieldRef, + _frame: &Frame, + ) -> Option { if class != HYBRID || rev != SchemaRev(1) { return None; } diff --git a/crates/lance-graph-report/Cargo.toml b/crates/lance-graph-report/Cargo.toml index 0de108a47..e5848429b 100644 --- a/crates/lance-graph-report/Cargo.toml +++ b/crates/lance-graph-report/Cargo.toml @@ -19,14 +19,4 @@ lance-graph-contract = { path = "../lance-graph-contract" } # 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 index d6cd6cb1d..a7663dbbe 100644 --- a/crates/lance-graph-report/src/lane_source.rs +++ b/crates/lance-graph-report/src/lane_source.rs @@ -13,10 +13,11 @@ //! 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. +//! A field resolves from the basis entry DT64 validated and hands to +//! `lane_of` (its `predicate_iri`), then the [`Catalog`] (name → [`FieldId`]), +//! then [`AbiBatch::column`] (FieldId → lane index). The adapter holds no +//! ClassView of its own, so it cannot disagree with DT64 about which field a +//! position names. Name lookup happens per bind and per check, never per row. //! //! ## Admission scope //! @@ -25,17 +26,17 @@ //! - **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`. +//! `AbiLanes::unwitnessed`, is `#[cfg(test)]`: it exists only inside this +//! crate's own unit tests, and no build of the library (any feature set) +//! can 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_contract::ontology::FieldRef; use lance_graph_dt64::dr::{Frame, FrameRevisions, FrameStore, Scope}; -use lance_graph_dt64::{view_class, CoordSpace, LaneAddr, LaneSource, SchemaRev}; +use lance_graph_dt64::{CoordSpace, LaneAddr, LaneSource, SchemaRev}; use lance_graph_mask_risc::LaneRef; use crate::batch::{AbiBatch, LaneData}; @@ -58,28 +59,21 @@ pub enum PublishRefusal { /// A resident batch presented to DT64's lane socket. #[derive(Debug)] -pub struct AbiLanes<'v, V: ClassView + ?Sized> { - view: &'v V, - catalog: &'v Catalog, +pub struct AbiLanes<'c> { + catalog: &'c Catalog, batch: Arc, frames: FrameStore, scope: Scope, } -impl<'v, V: ClassView + ?Sized> AbiLanes<'v, V> { +impl<'c> AbiLanes<'c> { /// 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 { + /// DR authority stands behind it, which is why this exists only in this + /// crate's unit tests. + #[cfg(test)] + pub(crate) fn unwitnessed(catalog: &'c Catalog, batch: Arc, scope: Scope) -> Self { let frames = FrameStore::new(revisions_of(&batch)); Self { - view, catalog, batch, frames, @@ -144,21 +138,23 @@ fn space_of(batch: &AbiBatch) -> CoordSpace { } } -impl LaneSource for AbiLanes<'_, V> { +impl LaneSource for AbiLanes<'_> { type Lane<'a> = LaneRef<'a> where Self: 'a; - fn lane_of(&self, class: u32, _rev: SchemaRev, field: u8, frame: &Frame) -> Option { + fn lane_of( + &self, + _class: u32, + _rev: SchemaRev, + _field: u8, + basis: &FieldRef, + 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 id = self.catalog.field(&basis.predicate_iri)?; let (lane, column) = self.batch.column(id)?; match column.lane { LaneData::I32(_) | LaneData::U32(_) => Some(LaneAddr { @@ -181,3 +177,6 @@ impl LaneSource for AbiLanes<'_, V> { } } } + +#[cfg(test)] +mod tests; diff --git a/crates/lance-graph-report/tests/lane_socket.rs b/crates/lance-graph-report/src/lane_source/tests.rs similarity index 84% rename from crates/lance-graph-report/tests/lane_socket.rs rename to crates/lance-graph-report/src/lane_source/tests.rs index 07be0689a..06b88463a 100644 --- a/crates/lance-graph-report/tests/lane_socket.rs +++ b/crates/lance-graph-report/src/lane_source/tests.rs @@ -1,8 +1,9 @@ -//! D-LANESOCK-2: resident `AbiBatch` lanes through DT64's lane socket, folded by -//! Quack / mask-RISC. Every refusal fires on a realistic input. +//! D-LANESOCK-2/3: 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). +//! These are unit tests because the only `AbiLanes` constructor, +//! `AbiLanes::unwitnessed`, takes the reader scope from the caller and exists +//! only under `#[cfg(test)]` (see the module docs: no DR scope witness). use std::sync::Arc; @@ -17,8 +18,11 @@ use lance_graph_dt64::{ }; 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}; + +use super::{AbiLanes, PublishRefusal}; +use crate::batch::{AbiBatch, BatchError, Column, LaneData, MAX_COLUMNS}; +use crate::boundary::Catalog; +use crate::ids::{FieldId, SourceId}; // ── allocation counter (hot-path falsifier) ────────────────────────────────── @@ -26,6 +30,7 @@ struct CountingAlloc; thread_local! { static ALLOCATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +#[allow(unsafe_code)] // test-only counting allocator; the library forbids unsafe 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)); @@ -193,7 +198,7 @@ fn request<'a>( fn bind( store: &BindStore, view: &V, - lanes: &AbiLanes<'_, V>, + lanes: &AbiLanes<'_>, route: RouteAt, positions: &[u8], ) -> Result { @@ -228,7 +233,7 @@ fn bound_lanes_are_the_batch_lanes_and_fold_with_scalar_parity() { let view = view(); let catalog = catalog(); let batch = batch(1); - let lanes = AbiLanes::unwitnessed(&view, &catalog, Arc::clone(&batch), READER); + let lanes = AbiLanes::unwitnessed(&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. @@ -327,7 +332,7 @@ 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 mut lanes = AbiLanes::unwitnessed(&catalog, batch(1), READER); let old = bind(&store, &view, &lanes, route, &[REVENUE]).unwrap(); lanes.publish(batch(2)).unwrap(); assert_eq!( @@ -368,8 +373,8 @@ 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 lanes = AbiLanes::unwitnessed(&catalog, batch(1), READER); + let twin = AbiLanes::unwitnessed(&catalog, batch(1), READER); let fields = WideFieldMask::from_positions(&[REVENUE]); let foreign = twin.seal(); assert_eq!( @@ -391,7 +396,7 @@ 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 lanes = AbiLanes::unwitnessed(&catalog, batch(1), READER); let binding = bind(&store, &view, &lanes, route, &[REVENUE]).unwrap(); store .register_derived( @@ -423,7 +428,7 @@ 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 lanes = AbiLanes::unwitnessed(&catalog, batch(1), READER); let fields = WideFieldMask::from_positions(&[REVENUE]); let frame = lanes.seal(); for selection in [ @@ -461,7 +466,7 @@ 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); + let lanes = AbiLanes::unwitnessed(&catalog, batch(1), READER); for field in [NOT_CATALOGUED, NO_COLUMN, REFERENCE] { assert_eq!( bind(&store, &view, &lanes, route, &[REVENUE, field]).err(), @@ -487,7 +492,7 @@ 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 lanes = AbiLanes::unwitnessed(&catalog, batch(1), READER); let fields = WideFieldMask::from_positions(&[REVENUE]); let frame = lanes.seal(); let mut req = request(route, &fields, &frame, lanes.space()); @@ -509,7 +514,7 @@ 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 mut lanes = AbiLanes::unwitnessed(&catalog, batch(1), READER); let old_space = lanes.space(); lanes.publish(batch(2)).unwrap(); let fields = WideFieldMask::from_positions(&[REVENUE]); @@ -539,8 +544,8 @@ 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 lanes = AbiLanes::unwitnessed(&catalog, batch(1), READER); + let other = AbiLanes::unwitnessed(&catalog, batch(1), READER); let fields = WideFieldMask::from_positions(&[REVENUE]); let frame = other.seal(); assert_eq!( @@ -563,7 +568,6 @@ fn a_frame_and_store_of_another_source_bind_nothing() { /// 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 @@ -585,3 +589,49 @@ fn a_column_past_the_u16_lane_space_is_refused() { Some(BatchError::TooManyColumns(FieldId(MAX_COLUMNS as u32))) ); } + +/// DT64 validates ClassView A at bind. Checked later through ClassView B +/// (same class, revision and field count, first two fields swapped), the +/// binding refuses: the source maps the field B now names, which is another +/// lane. Both candidate lanes are nonzero and different. Before D-LANESOCK-3 +/// the source read a ClassView of its own and A's revenue field borrowed B's +/// region lane. +#[test] +fn a_disagreeing_field_basis_never_returns_the_wrong_lane() { + let (store, route) = store(); + let a = view(); + let mut b = view(); + b.0.swap(usize::from(REVENUE), usize::from(REGION)); + let catalog = catalog(); + let lanes = AbiLanes::unwitnessed(&catalog, batch(1), READER); + // The two candidate lanes hold different, nonzero values. + let (rev, reg) = (revenue(1), region()); + assert!(rev.iter().any(|&v| v != 0) && reg.iter().any(|&v| v != 0)); + assert!((0..N).any(|i| i64::from(rev[i]) != i64::from(reg[i]))); + + // Bound under A, A's revenue field is the revenue lane. + let binding = bind(&store, &a, &lanes, route, &[REVENUE]).unwrap(); + let lane = binding + .lane(&store, &a, lanes.frames(), &lanes, REVENUE) + .unwrap(); + assert_eq!(slice_i32(lane), revenue(1).as_slice()); + + // Through B the same position names region: refused, no lane returned. + assert_eq!( + binding.check(&store, &b, lanes.frames(), &lanes), + Err(LaneRefusal::MappingChanged { field: REVENUE }) + ); + assert_eq!( + binding + .lane(&store, &b, lanes.frames(), &lanes, REVENUE) + .err(), + Some(LaneRefusal::MappingChanged { field: REVENUE }) + ); + + // Bound under B, position 0 is region, and that is what it borrows. + let under_b = bind(&store, &b, &lanes, route, &[REVENUE]).unwrap(); + let lane = under_b + .lane(&store, &b, lanes.frames(), &lanes, REVENUE) + .unwrap(); + assert_eq!(slice_u32(lane), region().as_slice()); +} diff --git a/crates/lance-graph-report/src/lib.rs b/crates/lance-graph-report/src/lib.rs index 6b3ebb0ee..12eb74d22 100644 --- a/crates/lance-graph-report/src/lib.rs +++ b/crates/lance-graph-report/src/lib.rs @@ -45,7 +45,10 @@ //! * [`render`] — terminal adapters (JSON / CSV data export): the materialization boundary. Paged / screen output goes through OGAR (`lance-graph-report-ogar`). //! * [`explain`] — the human-readable physical plan. -#![forbid(unsafe_code)] +// The library forbids unsafe code. Unit tests only `deny` it, so the one +// counting allocator in `lane_source::tests` can opt in with an `allow`. +#![cfg_attr(not(test), forbid(unsafe_code))] +#![cfg_attr(test, deny(unsafe_code))] pub mod batch; pub mod boundary;